1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Desktop: Accessibility: Add ARIA information to the sidebar's notebook and tag list (#11196)

This commit is contained in:
Henry Heino 2024-10-15 09:59:51 -07:00 committed by GitHub
parent 609ee3e227
commit 38be0e81a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 468 additions and 191 deletions

View File

@ -419,16 +419,19 @@ packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
packages/app-desktop/gui/Sidebar/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.js
packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/ListItemWrapper.js
packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
packages/app-desktop/gui/Sidebar/styles/index.js

3
.gitignore vendored
View File

@ -396,16 +396,19 @@ packages/app-desktop/gui/Sidebar/commands/focusElementSideBar.js
packages/app-desktop/gui/Sidebar/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderListWrapper.js
packages/app-desktop/gui/Sidebar/hooks/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.js
packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.js
packages/app-desktop/gui/Sidebar/listItemComponents/AllNotesItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/ListItemWrapper.js
packages/app-desktop/gui/Sidebar/listItemComponents/NoteCount.js
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
packages/app-desktop/gui/Sidebar/styles/index.js

View File

@ -17,7 +17,7 @@ export default function(props: Props) {
} else if (folderIcon.type === FolderIconType.DataUrl) {
return <img style={{ width, height, opacity }} src={folderIcon.dataUrl} />;
} else if (folderIcon.type === FolderIconType.FontAwesome) {
return <i style={{ fontSize: 18, width, opacity }} className={folderIcon.name}></i>;
return <i style={{ fontSize: 18, width, opacity }} className={folderIcon.name} role='img'></i>;
} else {
throw new Error(`Unsupported folder icon type: ${folderIcon.type}`);
}

View File

@ -5,12 +5,18 @@ interface Props<ItemType> {
style: React.CSSProperties & { height: number };
itemHeight: number;
items: ItemType[];
disabled?: boolean;
onKeyDown?: KeyboardEventHandler<HTMLElement>;
itemRenderer: (item: ItemType, index: number)=> React.JSX.Element;
className?: string;
itemRenderer: (item: ItemType, index: number)=> React.JSX.Element;
renderContentWrapper?: (listItems: React.ReactNode[])=> React.ReactNode;
onKeyDown?: KeyboardEventHandler<HTMLElement>;
onItemDrop?: DragEventHandler<HTMLElement>;
selectedIndex?: number;
alwaysRenderSelection?: boolean;
id?: string;
role?: string;
'aria-label'?: string;
@ -23,13 +29,13 @@ interface State {
class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
private scrollTop_: number;
private lastScrollTop_: number;
private listRef: React.MutableRefObject<HTMLDivElement>;
public constructor(props: Props<ItemType>) {
super(props);
this.scrollTop_ = 0;
this.lastScrollTop_ = 0;
this.listRef = React.createRef();
@ -46,10 +52,10 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
public updateStateItemIndexes(props: Props<ItemType> = undefined) {
if (typeof props === 'undefined') props = this.props;
const topItemIndex = Math.floor(this.scrollTop_ / props.itemHeight);
const topItemIndex = Math.floor(this.offsetScroll() / props.itemHeight);
const visibleItemCount = this.visibleItemCount(props);
let bottomItemIndex = topItemIndex + (visibleItemCount - 1);
let bottomItemIndex = topItemIndex + visibleItemCount;
if (bottomItemIndex >= props.items.length) bottomItemIndex = props.items.length - 1;
this.setState({
@ -63,7 +69,7 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
}
public offsetScroll() {
return this.scrollTop_;
return this.container?.scrollTop ?? this.lastScrollTop_;
}
public get container() {
@ -79,7 +85,7 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
}
public onScroll: UIEventHandler<HTMLDivElement> = event => {
this.scrollTop_ = (event.target as HTMLElement).scrollTop;
this.lastScrollTop_ = (event.target as HTMLElement).scrollTop;
this.updateStateItemIndexes();
};
@ -104,24 +110,29 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
}
public makeItemIndexVisible(itemIndex: number) {
if (this.isIndexVisible(itemIndex)) return;
// The first and last visible indices are often partially out of view and can thus be made more visible
if (this.isIndexVisible(itemIndex) && itemIndex !== this.lastVisibleIndex && itemIndex !== this.firstVisibleIndex) {
return;
}
const top = this.firstVisibleIndex;
let scrollTop = 0;
if (itemIndex < top) {
const currentScroll = this.offsetScroll();
let scrollTop = currentScroll;
if (itemIndex <= this.firstVisibleIndex) {
scrollTop = this.props.itemHeight * itemIndex;
} else {
scrollTop = this.props.itemHeight * itemIndex - (this.visibleItemCount() - 1) * this.props.itemHeight;
} else if (itemIndex >= this.lastVisibleIndex - 1) {
const scrollBottom = this.props.itemHeight * (itemIndex + 1);
scrollTop = scrollBottom - this.props.style.height;
}
if (scrollTop < 0) scrollTop = 0;
this.scrollTop_ = scrollTop;
if (currentScroll !== scrollTop) {
this.lastScrollTop_ = scrollTop;
this.listRef.current.scrollTop = scrollTop;
this.updateStateItemIndexes();
}
}
// shouldComponentUpdate(nextProps, nextState) {
// for (const n in this.props) {
@ -155,18 +166,42 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
return <div key={key} style={{ height: height }}></div>;
};
const itemComps = [blankItem('top', this.state.topItemIndex * this.props.itemHeight)];
type RenderRange = { from: number; to: number };
const renderableBlocks: RenderRange[] = [];
for (let i = this.state.topItemIndex; i <= this.state.bottomItemIndex; i++) {
const itemComp = this.props.itemRenderer(items[i], i);
if (this.props.alwaysRenderSelection && isFinite(this.props.selectedIndex)) {
const selectionVisible = this.props.selectedIndex >= this.state.topItemIndex && this.props.selectedIndex <= this.state.bottomItemIndex;
const isValidSelection = this.props.selectedIndex >= 0 && this.props.selectedIndex < items.length;
if (!selectionVisible && isValidSelection) {
renderableBlocks.push({ from: this.props.selectedIndex, to: this.props.selectedIndex });
}
}
renderableBlocks.push({ from: this.state.topItemIndex, to: this.state.bottomItemIndex });
// Ascending order
renderableBlocks.sort(({ from: fromA }, { from: fromB }) => fromA - fromB);
const itemComps: React.ReactNode[] = [];
for (let i = 0; i < renderableBlocks.length; i++) {
const currentBlock = renderableBlocks[i];
if (i === 0) {
itemComps.push(blankItem('top', currentBlock.from * this.props.itemHeight));
}
for (let j = currentBlock.from; j <= currentBlock.to; j++) {
const itemComp = this.props.itemRenderer(items[j], j);
itemComps.push(itemComp);
}
itemComps.push(blankItem('bottom', (items.length - this.state.bottomItemIndex - 1) * this.props.itemHeight));
const nextBlockFrom = i + 1 < renderableBlocks.length ? renderableBlocks[i + 1].from : items.length;
itemComps.push(blankItem(`after-${i}`, (nextBlockFrom - currentBlock.to - 1) * this.props.itemHeight));
}
const classes = ['item-list'];
if (this.props.className) classes.push(this.props.className);
const wrapContent = this.props.renderContentWrapper ?? ((children) => <>{children}</>);
return (
<div
ref={this.listRef}
@ -182,7 +217,7 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
onKeyDown={this.onKeyDown}
onDrop={this.onDrop}
>
{itemComps}
{wrapContent(itemComps)}
</div>
);
}

View File

@ -14,6 +14,7 @@ import useFocusHandler from './hooks/useFocusHandler';
import useOnRenderItem from './hooks/useOnRenderItem';
import { ListItem } from './types';
import useSidebarCommandHandler from './hooks/useSidebarCommandHandler';
import useOnRenderListWrapper from './hooks/useOnRenderListWrapper';
interface Props {
dispatch: Dispatch;
@ -39,11 +40,12 @@ const FolderAndTagList: React.FC<Props> = props => {
listItems: listItems,
});
const [selectedListElement, setSelectedListElement] = useState<HTMLElement|null>(null);
const listContainerRef = useRef<HTMLDivElement|null>(null);
const onRenderItem = useOnRenderItem({
...props,
selectedIndex,
onSelectedElementShown: setSelectedListElement,
listItems,
containerRef: listContainerRef,
});
const onKeyEventHandler = useOnSidebarKeyDownHandler({
@ -55,14 +57,17 @@ const FolderAndTagList: React.FC<Props> = props => {
});
const itemListRef = useRef<ItemList<ListItem>>();
const { focusSidebar } = useFocusHandler({ itemListRef, selectedListElement, selectedIndex, listItems });
const { focusSidebar } = useFocusHandler({ itemListRef, selectedIndex, listItems });
useSidebarCommandHandler({ focusSidebar });
const [itemListContainer, setItemListContainer] = useState<HTMLDivElement|null>(null);
listContainerRef.current = itemListContainer;
const listHeight = useElementHeight(itemListContainer);
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
const onRenderContentWrapper = useOnRenderListWrapper({ selectedIndex, onKeyDown: onKeyEventHandler });
return (
<div
className='folder-and-tag-list'
@ -72,9 +77,15 @@ const FolderAndTagList: React.FC<Props> = props => {
className='items'
ref={itemListRef}
style={listStyle}
items={listItems}
itemRenderer={onRenderItem}
onKeyDown={onKeyEventHandler}
renderContentWrapper={onRenderContentWrapper}
// The selected item is the only item with tabindex=0. Always render it
// to allow the item list to be focused.
alwaysRenderSelection={true}
selectedIndex={selectedIndex}
itemHeight={30}
/>

View File

@ -1,29 +1,16 @@
import { MutableRefObject, RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
import { RefObject, useCallback, useEffect, useMemo, useRef } from 'react';
import { ListItem } from '../types';
import ItemList from '../../ItemList';
import { focus } from '@joplin/lib/utils/focusHandler';
interface Props {
itemListRef: RefObject<ItemList<ListItem>>;
selectedListElement: HTMLElement|null;
selectedIndex: number;
listItems: ListItem[];
}
const useFocusAfterNextRenderHandler = (
shouldFocusAfterNextRender: MutableRefObject<boolean>,
selectedListElement: HTMLElement|null,
) => {
useEffect(() => {
if (!shouldFocusAfterNextRender.current || !selectedListElement) return;
focus('FolderAndTagList/useFocusHandler/afterRender', selectedListElement);
shouldFocusAfterNextRender.current = false;
}, [selectedListElement, shouldFocusAfterNextRender]);
};
const useRefocusOnSelectionChangeHandler = (
const useScrollToSelectionHandler = (
itemListRef: RefObject<ItemList<ListItem>>,
shouldFocusAfterNextRender: MutableRefObject<boolean>,
listItems: ListItem[],
selectedIndex: number,
) => {
@ -49,32 +36,33 @@ const useRefocusOnSelectionChangeHandler = (
useEffect(() => {
if (!itemListRef.current || !selectedItemKey) return;
const hasFocus = !!itemListRef.current.container.querySelector(':scope :focus');
shouldFocusAfterNextRender.current = hasFocus;
const hasFocus = !!itemListRef.current.container.contains(document.activeElement);
if (hasFocus) {
itemListRef.current.makeItemIndexVisible(selectedIndexRef.current);
}
}, [selectedItemKey, itemListRef, shouldFocusAfterNextRender]);
}, [selectedItemKey, itemListRef]);
};
const useFocusHandler = (props: Props) => {
const { itemListRef, selectedListElement, selectedIndex, listItems } = props;
const { itemListRef, selectedIndex, listItems } = props;
// When set to true, when selectedListElement next changes, select it.
const shouldFocusAfterNextRender = useRef(false);
useRefocusOnSelectionChangeHandler(itemListRef, shouldFocusAfterNextRender, listItems, selectedIndex);
useFocusAfterNextRenderHandler(shouldFocusAfterNextRender, selectedListElement);
useScrollToSelectionHandler(itemListRef, listItems, selectedIndex);
const focusSidebar = useCallback(() => {
if (!selectedListElement || !itemListRef.current.isIndexVisible(selectedIndex)) {
if (!itemListRef.current.isIndexVisible(selectedIndex)) {
itemListRef.current.makeItemIndexVisible(selectedIndex);
shouldFocusAfterNextRender.current = true;
} else {
focus('FolderAndTagList/useFocusHandler/focusSidebar', selectedListElement);
}
}, [selectedListElement, selectedIndex, itemListRef]);
const focusableItem = itemListRef.current.container.querySelector('[role="treeitem"][tabindex="0"]');
const focusableContainer = itemListRef.current.container.querySelector('[role="tree"][tabindex="0"]');
if (focusableItem) {
focus('FolderAndTagList/focusSidebarItem', focusableItem);
} else if (focusableContainer) {
// Handles the case where no items in the tree can be focused.
focus('FolderAndTagList/focusSidebarTree', focusableContainer);
}
}, [selectedIndex, itemListRef]);
return { focusSidebar };
};

View File

@ -29,6 +29,8 @@ import Logger from '@joplin/utils/Logger';
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
import HeaderItem from '../listItemComponents/HeaderItem';
import AllNotesItem from '../listItemComponents/AllNotesItem';
import ListItemWrapper from '../listItemComponents/ListItemWrapper';
import { focus } from '@joplin/lib/utils/focusHandler';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@ -41,15 +43,27 @@ interface Props {
plugins: PluginStates;
folders: FolderEntity[];
collapsedFolderIds: string[];
containerRef: React.RefObject<HTMLDivElement>;
selectedIndex: number;
onSelectedElementShown: (element: HTMLElement)=> void;
listItems: ListItem[];
}
type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
const menuUtils = new MenuUtils(CommandService.instance());
const focusListItem = (item: HTMLElement|null) => {
if (item) {
// Avoid scrolling to the selected item when refocusing the note list. Such a refocus
// can happen if the note list rerenders and the selection is scrolled out of view and
// can cause scroll to change unexpectedly.
focus('useOnRenderItem', item, { preventScroll: true });
}
};
const noFocusListItem = () => {};
const useOnRenderItem = (props: Props) => {
const pluginsRef = useRef<PluginStates>(null);
@ -326,26 +340,24 @@ const useOnRenderItem = (props: Props) => {
const selectedIndexRef = useRef(props.selectedIndex);
selectedIndexRef.current = props.selectedIndex;
const itemCount = props.listItems.length;
return useCallback((item: ListItem, index: number) => {
const selected = props.selectedIndex === index;
const anchorRefCallback = selected ? (
(element: HTMLElement) => {
if (selectedIndexRef.current === index) {
props.onSelectedElementShown(element);
}
}
) : null;
const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
const anchorRef = (focusInList && selected) ? focusListItem : noFocusListItem;
if (item.kind === ListItemType.Tag) {
const tag = item.tag;
return <TagItem
key={item.key}
anchorRef={anchorRefCallback}
anchorRef={anchorRef}
selected={selected}
onClick={tagItem_click}
onTagDrop={onTagDrop_}
onContextMenu={onItemContextMenu}
tag={tag}
itemCount={itemCount}
index={index}
/>;
} else if (item.kind === ListItemType.Folder) {
const folder = item.folder;
@ -368,7 +380,7 @@ const useOnRenderItem = (props: Props) => {
}
return <FolderItem
key={item.key}
anchorRef={anchorRefCallback}
anchorRef={anchorRef}
selected={selected}
folderId={folder.id}
folderTitle={Folder.displayTitle(folder)}
@ -386,23 +398,41 @@ const useOnRenderItem = (props: Props) => {
shareId={folder.share_id}
parentId={folder.parent_id}
showFolderIcon={showFolderIcons}
index={index}
itemCount={itemCount}
/>;
} else if (item.kind === ListItemType.Header) {
return <HeaderItem
key={item.id}
anchorRef={anchorRef}
item={item}
anchorRef={anchorRefCallback}
isSelected={selected}
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
index={index}
itemCount={itemCount}
/>;
} else if (item.kind === ListItemType.AllNotes) {
return <AllNotesItem
key={item.key}
anchorRef={anchorRef}
selected={selected}
anchorRef={anchorRefCallback}
index={index}
itemCount={itemCount}
/>;
} else if (item.kind === ListItemType.Spacer) {
return (
<a key={item.key} className='sidebar-spacer-item' ref={anchorRefCallback} aria-label={_('Spacer')}></a>
<ListItemWrapper
key={item.key}
containerRef={anchorRef}
depth={0}
selected={selected}
itemIndex={index}
itemCount={itemCount}
highlightOnHover={false}
className='sidebar-spacer-item'
>
<div aria-label={_('Spacer')}></div>
</ListItemWrapper>
);
} else {
const exhaustivenessCheck: never = item;
@ -421,7 +451,8 @@ const useOnRenderItem = (props: Props) => {
showFolderIcons,
tagItem_click,
props.selectedIndex,
props.onSelectedElementShown,
props.containerRef,
itemCount,
]);
};

View File

@ -0,0 +1,46 @@
import * as React from 'react';
import { useCallback } from 'react';
import { _ } from '@joplin/lib/locale';
import CommandService from '@joplin/lib/services/CommandService';
interface Props {
selectedIndex: number;
onKeyDown: React.KeyboardEventHandler;
}
const onAddFolderButtonClick = () => {
void CommandService.instance().execute('newFolder');
};
const NewFolderButton = () => {
// To allow it to be accessed by accessibility tools, the new folder button
// is not included in the portion of the list with role='tree'.
return <button onClick={onAddFolderButtonClick} className='new-folder-button'>
<i
aria-label={_('New notebook')}
role='img'
className='fas fa-plus'
/>
</button>;
};
const useOnRenderListWrapper = ({ selectedIndex, onKeyDown }: Props) => {
return useCallback((listItems: React.ReactNode[]) => {
const listHasValidSelection = selectedIndex >= 0;
const allowContainerFocus = !listHasValidSelection;
return <>
<NewFolderButton/>
<div
role='tree'
className='sidebar-list-items-wrapper'
aria-setsize={listItems.length}
tabIndex={allowContainerFocus ? 0 : undefined}
onKeyDown={onKeyDown}
>
{...listItems}
</div>
</>;
}, [selectedIndex, onKeyDown]);
};
export default useOnRenderListWrapper;

View File

@ -1,7 +1,8 @@
import { Dispatch } from 'redux';
import { FolderListItem, ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
import { ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
import { KeyboardEventHandler, useCallback } from 'react';
import CommandService from '@joplin/lib/services/CommandService';
import toggleHeader from './utils/toggleHeader';
interface Props {
dispatch: Dispatch;
@ -12,15 +13,20 @@ interface Props {
}
const isToggleShortcut = (keyCode: string, selectedItem: FolderListItem, collapsedFolderIds: string[]) => {
const isToggleShortcut = (keyCode: string, selectedItem: ListItem, collapsedFolderIds: string[]) => {
if (selectedItem.kind !== ListItemType.Header && selectedItem.kind !== ListItemType.Folder) {
return false;
}
if (!['Space', 'ArrowLeft', 'ArrowRight'].includes(keyCode)) {
return false;
}
if (keyCode === 'Space') {
return true;
}
const isCollapsed = collapsedFolderIds.includes(selectedItem.folder.id);
const isCollapsed = 'expanded' in selectedItem ? !selectedItem.expanded : collapsedFolderIds.includes(selectedItem.folder.id);
return (keyCode === 'ArrowRight') === isCollapsed;
};
@ -29,21 +35,22 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
const selectedItem = listItems[selectedIndex];
if (selectedItem?.kind === ListItemType.Folder && isToggleShortcut(event.code, selectedItem, collapsedFolderIds)) {
let indexChange = 0;
if (selectedItem && isToggleShortcut(event.code, selectedItem, collapsedFolderIds)) {
event.preventDefault();
if (selectedItem.kind === ListItemType.Folder) {
dispatch({
type: 'FOLDER_TOGGLE',
id: selectedItem.folder.id,
});
} else if (selectedItem.kind === ListItemType.Header) {
toggleHeader(selectedItem.id);
}
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
event.preventDefault();
}
let indexChange = 0;
if (event.code === 'ArrowUp') {
} else if (event.code === 'ArrowUp') {
indexChange = -1;
} else if (event.code === 'ArrowDown') {
indexChange = 1;

View File

@ -3,8 +3,7 @@ import { FolderListItem, HeaderId, HeaderListItem, ListItem, ListItemType, TagLi
import { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import { buildFolderTree, renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
import { _ } from '@joplin/lib/locale';
import CommandService from '@joplin/lib/services/CommandService';
import Setting from '@joplin/lib/models/Setting';
import toggleHeader from './utils/toggleHeader';
interface Props {
tags: TagsWithNoteCountEntity[];
@ -14,16 +13,6 @@ interface Props {
tagHeaderIsExpanded: boolean;
}
const onAddFolderButtonClick = () => {
void CommandService.instance().execute('newFolder');
};
const onHeaderClick = (headerId: HeaderId) => {
const settingKey = headerId === HeaderId.TagHeader ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded';
const current = Setting.value(settingKey);
Setting.setValue(settingKey, !current);
};
const useSidebarListData = (props: Props): ListItem[] => {
const tagItems = useMemo(() => {
return renderTags<ListItem>(props.tags, (tag): TagListItem => {
@ -60,10 +49,10 @@ const useSidebarListData = (props: Props): ListItem[] => {
kind: ListItemType.Header,
label: _('Notebooks'),
iconName: 'icon-notebooks',
expanded: props.folderHeaderIsExpanded,
id: HeaderId.FolderHeader,
key: HeaderId.FolderHeader,
onClick: onHeaderClick,
onPlusButtonClick: onAddFolderButtonClick,
onClick: toggleHeader,
extraProps: {
['data-folder-id']: '',
},
@ -79,10 +68,10 @@ const useSidebarListData = (props: Props): ListItem[] => {
kind: ListItemType.Header,
label: _('Tags'),
iconName: 'icon-tags',
expanded: props.tagHeaderIsExpanded,
id: HeaderId.TagHeader,
key: HeaderId.TagHeader,
onClick: onHeaderClick,
onPlusButtonClick: null,
onClick: toggleHeader,
extraProps: { },
supportsFolderDrop: false,
};

View File

@ -0,0 +1,10 @@
import Setting from '@joplin/lib/models/Setting';
import { HeaderId } from '../../types';
const toggleHeader = (headerId: HeaderId) => {
const settingKey = headerId === HeaderId.TagHeader ? 'tagHeaderIsExpanded' : 'folderHeaderIsExpanded';
const current = Setting.value(settingKey);
Setting.setValue(settingKey, !current);
};
export default toggleHeader;

View File

@ -1,5 +1,5 @@
import * as React from 'react';
import { StyledAllNotesIcon, StyledListItem, StyledListItemAnchor } from '../styles';
import { StyledAllNotesIcon, StyledListItemAnchor } from '../styles';
import { useCallback } from 'react';
import { Dispatch } from 'redux';
import bridge from '../../../services/bridge';
@ -10,6 +10,7 @@ import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSort
import { _ } from '@joplin/lib/locale';
import { connect } from 'react-redux';
import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
const Menu = bridge().Menu;
@ -17,8 +18,10 @@ const MenuItem = bridge().MenuItem;
interface Props {
dispatch: Dispatch;
anchorRef: ListItemRef;
selected: boolean;
anchorRef: React.Ref<HTMLAnchorElement>;
index: number;
itemCount: number;
}
const menuUtils = new MenuUtils(CommandService.instance());
@ -46,21 +49,28 @@ const AllNotesItem: React.FC<Props> = props => {
}, []);
return (
<StyledListItem key="allNotesHeader" selected={props.selected} className={'list-item-container list-item-depth-0 all-notes'} isSpecialItem={true}>
<ListItemWrapper
containerRef={props.anchorRef}
key="allNotesHeader"
selected={props.selected}
depth={1}
className={'list-item-container list-item-depth-0 all-notes'}
highlightOnHover={true}
itemIndex={props.index}
itemCount={props.itemCount}
>
<EmptyExpandLink/>
<StyledAllNotesIcon className="icon-notes"/>
<StyledAllNotesIcon aria-label='' role='img' className='icon-notes'/>
<StyledListItemAnchor
ref={props.anchorRef}
className="list-item"
isSpecialItem={true}
href="#"
selected={props.selected}
onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu}
>
{_('All notes')}
</StyledListItemAnchor>
</StyledListItem>
</ListItemWrapper>
);
};

View File

@ -2,10 +2,11 @@ import * as React from 'react';
import ExpandIcon from './ExpandIcon';
interface Props {
className?: string;
}
const EmptyExpandLink: React.FC<Props> = _props => {
return <a className='sidebar-expand-link'><ExpandIcon isVisible={false} isExpanded={false}/></a>;
const EmptyExpandLink: React.FC<Props> = props => {
return <a className={`sidebar-expand-link ${props.className ?? ''}`}><ExpandIcon isVisible={false} isExpanded={false}/></a>;
};
export default EmptyExpandLink;

View File

@ -23,11 +23,12 @@ const ExpandIcon: React.FC<ExpandIconProps> = props => {
return undefined;
}
if (props.isExpanded) {
return _('Collapse %s', props.targetTitle);
return _('Expanded, press space to collapse.');
}
return _('Expand %s', props.targetTitle);
return _('Collapsed, press space to expand.');
};
return <i className={classNames.join(' ')} aria-label={getLabel()}></i>;
const label = getLabel();
return <i className={classNames.join(' ')} aria-label={label} role='img'></i>;
};
export default ExpandIcon;

View File

@ -8,16 +8,17 @@ interface ExpandLinkProps {
folderTitle: string;
hasChildren: boolean;
isExpanded: boolean;
className: string;
onClick: MouseEventHandler<HTMLElement>;
}
const ExpandLink: React.FC<ExpandLinkProps> = props => {
return props.hasChildren ? (
<a className='sidebar-expand-link' href="#" data-folder-id={props.folderId} onClick={props.onClick}>
<a className={`sidebar-expand-link ${props.className}`} data-folder-id={props.folderId} onClick={props.onClick} role='button'>
<ExpandIcon isVisible={true} isExpanded={props.isExpanded} targetTitle={props.folderTitle}/>
</a>
) : (
<EmptyExpandLink/>
<EmptyExpandLink className={props.className}/>
);
};

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import ExpandLink from './ExpandLink';
import { StyledListItem, StyledListItemAnchor, StyledShareIcon, StyledSpanFix } from '../styles';
import { StyledListItemAnchor, StyledShareIcon, StyledSpanFix } from '../styles';
import { ItemClickListener, ItemContextMenuListener, ItemDragListener } from '../types';
import FolderIconBox from '../../FolderIconBox';
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash';
@ -10,6 +10,7 @@ import Folder from '@joplin/lib/models/Folder';
import { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale';
import NoteCount from './NoteCount';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) {
@ -26,6 +27,7 @@ const renderFolderIcon = (folderIcon: FolderIcon) => {
};
interface FolderItemProps {
anchorRef: ListItemRef;
hasChildren: boolean;
showFolderIcon: boolean;
isExpanded: boolean;
@ -43,7 +45,9 @@ interface FolderItemProps {
onFolderToggleClick_: ItemClickListener;
shareId: string;
selected: boolean;
anchorRef: React.Ref<HTMLElement>;
index: number;
itemCount: number;
}
function FolderItem(props: FolderItemProps) {
@ -63,29 +67,50 @@ function FolderItem(props: FolderItemProps) {
};
return (
<StyledListItem depth={depth} selected={selected} className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`} onDragStart={onFolderDragStart_} onDragOver={onFolderDragOver_} onDrop={onFolderDrop_} draggable={draggable} data-folder-id={folderId}>
<ExpandLink hasChildren={hasChildren} folderTitle={folderTitle} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/>
<StyledListItemAnchor
ref={props.anchorRef}
className="list-item"
isConflictFolder={folderId === Folder.conflictFolderId()}
href="#"
<ListItemWrapper
containerRef={props.anchorRef}
// Folders are contained within the "Notebooks" section (which has depth 0):
depth={depth + 1}
selected={selected}
aria-selected={selected}
shareId={shareId}
itemIndex={props.index}
itemCount={props.itemCount}
expanded={hasChildren ? props.isExpanded : undefined}
className={`list-item-container list-item-depth-${depth} ${selected ? 'selected' : ''}`}
highlightOnHover={true}
onDragStart={onFolderDragStart_}
onDragOver={onFolderDragOver_}
onDrop={onFolderDrop_}
onContextMenu={itemContextMenu}
draggable={draggable}
data-folder-id={folderId}
data-id={folderId}
data-type={ModelType.Folder}
onContextMenu={itemContextMenu}
data-folder-id={folderId}
>
<StyledListItemAnchor
className="list-item"
isConflictFolder={folderId === Folder.conflictFolderId()}
selected={selected}
shareId={shareId}
onDoubleClick={onFolderToggleClick_}
onClick={() => {
folderItem_click(folderId);
}}
onDoubleClick={onFolderToggleClick_}
>
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>
{shareIcon} <NoteCount count={noteCount}/>
</StyledListItemAnchor>
</StyledListItem>
<ExpandLink
// The ExpandLink is included after the title so that the screen reader reads the
// title first.
className='toggle'
hasChildren={hasChildren}
folderTitle={folderTitle}
folderId={folderId}
onClick={onFolderToggleClick_}
isExpanded={isExpanded}
/>
</ListItemWrapper>
);
}

View File

@ -1,12 +1,11 @@
import * as React from 'react';
import { useCallback } from 'react';
import { ButtonLevel } from '../../Button/Button';
import { StyledAddButton, StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
import { StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
import { HeaderId, HeaderListItem } from '../types';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../services/bridge';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@ -14,9 +13,12 @@ const menuUtils = new MenuUtils(CommandService.instance());
interface Props {
anchorRef: ListItemRef;
item: HeaderListItem;
isSelected: boolean;
onDrop: React.DragEventHandler|null;
anchorRef: React.Ref<HTMLElement>;
index: number;
itemCount: number;
}
const HeaderItem: React.FC<Props> = props => {
@ -42,30 +44,25 @@ const HeaderItem: React.FC<Props> = props => {
}
}, [itemId]);
const addButton = <StyledAddButton
iconLabel={_('New')}
onClick={item.onPlusButtonClick}
iconName='fas fa-plus'
level={ButtonLevel.SidebarSecondary}
/>;
return (
<div
<ListItemWrapper
containerRef={props.anchorRef}
selected={props.isSelected}
itemIndex={props.index}
itemCount={props.itemCount}
expanded={props.item.expanded}
onContextMenu={onContextMenu}
depth={0}
highlightOnHover={false}
className='sidebar-header-container'
{...item.extraProps}
onDrop={props.onDrop}
>
<StyledHeader
onContextMenu={onContextMenu}
onClick={onClick}
tabIndex={0}
ref={props.anchorRef}
>
<StyledHeaderIcon aria-label='' className={item.iconName}/>
<StyledHeader onClick={onClick}>
<StyledHeaderIcon aria-label='' role='img' className={item.iconName}/>
<StyledHeaderLabel>{item.label}</StyledHeaderLabel>
</StyledHeader>
{ item.onPlusButtonClick && addButton }
</div>
</ListItemWrapper>
);
};

View File

@ -0,0 +1,66 @@
import { ModelType } from '@joplin/lib/BaseModel';
import * as React from 'react';
import { useMemo } from 'react';
export type ListItemRef = React.Ref<HTMLDivElement>;
interface Props {
containerRef: ListItemRef;
selected: boolean;
itemIndex: number;
itemCount: number;
expanded?: boolean|undefined;
depth: number;
className?: string;
highlightOnHover: boolean;
children: (React.ReactNode[])|React.ReactNode;
onContextMenu?: React.MouseEventHandler;
onDrag?: React.DragEventHandler;
onDragStart?: React.DragEventHandler;
onDragOver?: React.DragEventHandler;
onDrop?: React.DragEventHandler;
draggable?: boolean;
'data-folder-id'?: string;
'data-id'?: string;
'data-type'?: ModelType;
}
const ListItemWrapper: React.FC<Props> = props => {
const style = useMemo(() => {
return {
'--depth': props.depth,
} as React.CSSProperties;
}, [props.depth]);
return (
<div
ref={props.containerRef}
aria-posinset={props.itemIndex + 1}
aria-setsize={props.itemCount}
aria-selected={props.selected}
aria-expanded={props.expanded}
// aria-level is 1-based, where depth is zero-based
aria-level={props.depth + 1}
tabIndex={props.selected ? 0 : -1}
onContextMenu={props.onContextMenu}
onDrag={props.onDrag}
onDragStart={props.onDragStart}
onDragOver={props.onDragOver}
onDrop={props.onDrop}
draggable={props.draggable}
role='treeitem'
className={`list-item-wrapper ${props.highlightOnHover ? '-highlight-on-hover' : ''} ${props.selected ? '-selected' : ''} ${props.className ?? ''}`}
style={style}
data-folder-id={props['data-folder-id']}
data-id={props['data-id']}
data-type={props['data-type']}
>
{props.children}
</div>
);
};
export default ListItemWrapper;

View File

@ -1,22 +1,26 @@
import Setting from '@joplin/lib/models/Setting';
import * as React from 'react';
import { useCallback } from 'react';
import { StyledListItem, StyledListItemAnchor, StyledSpanFix } from '../styles';
import { StyledListItemAnchor, StyledSpanFix } from '../styles';
import { TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import BaseModel from '@joplin/lib/BaseModel';
import NoteCount from './NoteCount';
import Tag from '@joplin/lib/models/Tag';
import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
export type TagLinkClickEvent = { tag: TagsWithNoteCountEntity|undefined };
interface Props {
anchorRef: ListItemRef;
selected: boolean;
anchorRef: React.Ref<HTMLElement>;
tag: TagsWithNoteCountEntity;
onTagDrop: React.DragEventHandler<HTMLElement>;
onContextMenu: React.MouseEventHandler<HTMLElement>;
onClick: (event: TagLinkClickEvent)=> void;
itemCount: number;
index: number;
}
const TagItem = (props: Props) => {
@ -33,18 +37,21 @@ const TagItem = (props: Props) => {
}, [props.onClick, tag]);
return (
<StyledListItem
<ListItemWrapper
containerRef={props.anchorRef}
selected={selected}
depth={1}
className={`list-item-container ${selected ? 'selected' : ''}`}
highlightOnHover={true}
onDrop={props.onTagDrop}
data-tag-id={tag.id}
aria-selected={selected}
itemIndex={props.index}
itemCount={props.itemCount}
>
<EmptyExpandLink/>
<StyledListItemAnchor
ref={props.anchorRef}
className="list-item"
href="#"
selected={selected}
data-id={tag.id}
data-type={BaseModel.TYPE_TAG}
@ -54,7 +61,7 @@ const TagItem = (props: Props) => {
<StyledSpanFix className="tag-label">{Tag.displayTitle(tag)}</StyledSpanFix>
{noteCount}
</StyledListItemAnchor>
</StyledListItem>
</ListItemWrapper>
);
};

View File

@ -1,6 +1,8 @@
@use 'styles/folder-and-tag-list.scss';
@use 'styles/list-item-wrapper.scss';
@use 'styles/note-count-label.scss';
@use 'styles/sidebar-expand-icon.scss';
@use 'styles/sidebar-expand-link.scss';
@use 'styles/sidebar-header-container.scss';
@use 'styles/sidebar-spacer-item.scss';
@use 'styles/new-folder-button.scss';

View File

@ -49,22 +49,6 @@ export const StyledHeaderLabel = styled.span`
font-weight: bold;
`;
export const StyledListItem = styled.div`
box-sizing: border-box;
height: 30px;
display: flex;
flex-direction: row;
align-items: center;
padding-left: ${(props: StyleProps) => props.theme.mainPadding + ('depth' in props ? props.depth : 0) * 16}px;
background: ${(props: StyleProps) => props.selected ? props.theme.selectedColor2 : 'none'};
/*text-transform: ${(props: StyleProps) => props.isSpecialItem ? 'uppercase' : 'none'};*/
transition: 0.1s;
&:hover {
background-color: ${(props: StyleProps) => props.theme.backgroundColorHover2};
}
`;
function listItemTextColor(props: StyleProps) {
if (props.isConflictFolder) return props.theme.colorError2;
if (props.isSpecialItem) return props.theme.colorFaded2;

View File

@ -0,0 +1,25 @@
.list-item-wrapper {
box-sizing: border-box;
height: 30px;
display: flex;
flex-direction: row;
align-items: center;
padding-left: calc(var(--joplin-main-padding) + (var(--depth) * 16px) - 16px);
background: none;
transition: 0.1s;
// Show the toggle button first, even if it's markup is included later for a better screen reader
// experience.
> .toggle {
order: -1;
}
&.-selected {
background: var(--joplin-selected-color2);
}
&.-highlight-on-hover:hover {
background-color: var(--joplin-background-color-hover2);
}
}

View File

@ -0,0 +1,25 @@
.new-folder-button {
position: absolute;
top: 0;
inset-inline-end: 0;
padding-inline-end: 15px;
padding-top: 4px;
height: 30px;
border: none;
background-color: transparent;
font-size: var(--joplin-toolbar-icon-size);
color: var(--joplin-color2);
&:hover {
color: var(--joplin-color-hover2);
background: none;
}
&:active {
color: var(--joplin-color-active2);
background: none;
}
}

View File

@ -5,6 +5,7 @@
opacity: 0.8;
text-decoration: none;
padding-right: 8px;
text-align: center;
display: flex;
align-items: center;
width: 16px;

View File

@ -21,10 +21,10 @@ interface BaseListItem {
export interface HeaderListItem extends BaseListItem {
kind: ListItemType.Header;
label: string;
expanded: boolean;
iconName: string;
id: HeaderId;
onClick: ((headerId: HeaderId, event: ReactMouseEvent<HTMLElement>)=> void)|null;
onPlusButtonClick: MouseEventHandler<HTMLElement>|null;
extraProps: Record<string, string>;
supportsFolderDrop: boolean;
}

View File

@ -19,7 +19,7 @@ export default class Sidebar {
const submitButton = this.mainScreen.dialog.getByRole('button', { name: 'OK' });
await submitButton.click();
return this.container.getByText(title);
return this.container.getByRole('treeitem', { name: title });
}
private async sortBy(electronApp: ElectronApplication, option: string) {

View File

@ -56,24 +56,24 @@ test.describe('sidebar', () => {
await sidebar.forceUpdateSorting(electronApp);
await expect(childFolderHeader).toBeVisible();
await childFolderHeader.dragTo(parentFolderHeader);
// Verify that it's now a child folder -- expand and collapse the parent
const collapseButton = sidebar.container.getByRole('link', { name: 'Collapse Parent folder' });
await expect(collapseButton).toBeVisible();
await collapseButton.click();
await expect(parentFolderHeader).toHaveJSProperty('ariaExpanded', 'true');
const toggleButton = parentFolderHeader.getByRole('button', { name: /^(Expand|Collapse)/ });
await toggleButton.click();
// Should be collapsed
await expect(childFolderHeader).not.toBeAttached();
await expect(parentFolderHeader).toHaveJSProperty('ariaExpanded', 'false');
const expandButton = sidebar.container.getByRole('link', { name: 'Expand Parent folder' });
await expandButton.click();
await toggleButton.click();
// Should be possible to move back to the root
const rootFolderHeader = sidebar.container.getByText('Notebooks');
await childFolderHeader.dragTo(rootFolderHeader);
await expect(collapseButton).not.toBeVisible();
await expect(expandButton).not.toBeVisible();
await expect(toggleButton).not.toBeVisible();
});
test('all notes section should list all notes', async ({ electronApp, mainWindow }) => {

View File

@ -12,12 +12,16 @@ enum ToggleFocusAction {
Blur = 'blur',
}
interface FocusOptions {
preventScroll: boolean;
}
interface FocusableElement {
focus: ()=> void;
focus: (options?: FocusOptions)=> void;
blur: ()=> void;
}
const toggleFocus = (source: string, element: FocusableElement, action: ToggleFocusAction) => {
const toggleFocus = (source: string, element: FocusableElement, action: ToggleFocusAction, options: FocusOptions|null) => {
if (!element) {
logger.warn(`Tried action "${action}" on an undefined element: ${source}`);
return;
@ -29,15 +33,19 @@ const toggleFocus = (source: string, element: FocusableElement, action: ToggleFo
}
logger.debug(`Action "${action}" from "${source}"`);
if (options) {
element[action](options);
} else {
element[action]();
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const focus = (source: string, element: any) => {
toggleFocus(source, element, ToggleFocusAction.Focus);
export const focus = (source: string, element: any, options: FocusOptions|null = null) => {
toggleFocus(source, element, ToggleFocusAction.Focus, options);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export const blur = (source: string, element: any) => {
toggleFocus(source, element, ToggleFocusAction.Blur);
toggleFocus(source, element, ToggleFocusAction.Blur, null);
};

View File

@ -132,3 +132,4 @@ Famegear
rcompare
tabindex
Backblaze
treeitem