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/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.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/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.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/AllNotesItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.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/NoteCount.js
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
packages/app-desktop/gui/Sidebar/styles/index.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/commands/index.js
packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.js
packages/app-desktop/gui/Sidebar/hooks/useOnRenderItem.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/useOnSidebarKeyDownHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js packages/app-desktop/gui/Sidebar/hooks/useSelectedSidebarIndex.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js packages/app-desktop/gui/Sidebar/hooks/useSidebarCommandHandler.js
packages/app-desktop/gui/Sidebar/hooks/useSidebarListData.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/AllNotesItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js packages/app-desktop/gui/Sidebar/listItemComponents/EmptyExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js packages/app-desktop/gui/Sidebar/listItemComponents/ExpandIcon.js
packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js packages/app-desktop/gui/Sidebar/listItemComponents/ExpandLink.js
packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js packages/app-desktop/gui/Sidebar/listItemComponents/FolderItem.js
packages/app-desktop/gui/Sidebar/listItemComponents/HeaderItem.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/NoteCount.js
packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js packages/app-desktop/gui/Sidebar/listItemComponents/TagItem.js
packages/app-desktop/gui/Sidebar/styles/index.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) { } else if (folderIcon.type === FolderIconType.DataUrl) {
return <img style={{ width, height, opacity }} src={folderIcon.dataUrl} />; return <img style={{ width, height, opacity }} src={folderIcon.dataUrl} />;
} else if (folderIcon.type === FolderIconType.FontAwesome) { } 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 { } else {
throw new Error(`Unsupported folder icon type: ${folderIcon.type}`); throw new Error(`Unsupported folder icon type: ${folderIcon.type}`);
} }

View File

@ -5,12 +5,18 @@ interface Props<ItemType> {
style: React.CSSProperties & { height: number }; style: React.CSSProperties & { height: number };
itemHeight: number; itemHeight: number;
items: ItemType[]; items: ItemType[];
disabled?: boolean; disabled?: boolean;
onKeyDown?: KeyboardEventHandler<HTMLElement>;
itemRenderer: (item: ItemType, index: number)=> React.JSX.Element;
className?: string; className?: string;
itemRenderer: (item: ItemType, index: number)=> React.JSX.Element;
renderContentWrapper?: (listItems: React.ReactNode[])=> React.ReactNode;
onKeyDown?: KeyboardEventHandler<HTMLElement>;
onItemDrop?: DragEventHandler<HTMLElement>; onItemDrop?: DragEventHandler<HTMLElement>;
selectedIndex?: number;
alwaysRenderSelection?: boolean;
id?: string; id?: string;
role?: string; role?: string;
'aria-label'?: string; 'aria-label'?: string;
@ -23,13 +29,13 @@ interface State {
class ItemList<ItemType> extends React.Component<Props<ItemType>, State> { class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
private scrollTop_: number; private lastScrollTop_: number;
private listRef: React.MutableRefObject<HTMLDivElement>; private listRef: React.MutableRefObject<HTMLDivElement>;
public constructor(props: Props<ItemType>) { public constructor(props: Props<ItemType>) {
super(props); super(props);
this.scrollTop_ = 0; this.lastScrollTop_ = 0;
this.listRef = React.createRef(); this.listRef = React.createRef();
@ -46,10 +52,10 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
public updateStateItemIndexes(props: Props<ItemType> = undefined) { public updateStateItemIndexes(props: Props<ItemType> = undefined) {
if (typeof props === 'undefined') props = this.props; 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); const visibleItemCount = this.visibleItemCount(props);
let bottomItemIndex = topItemIndex + (visibleItemCount - 1); let bottomItemIndex = topItemIndex + visibleItemCount;
if (bottomItemIndex >= props.items.length) bottomItemIndex = props.items.length - 1; if (bottomItemIndex >= props.items.length) bottomItemIndex = props.items.length - 1;
this.setState({ this.setState({
@ -63,7 +69,7 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
} }
public offsetScroll() { public offsetScroll() {
return this.scrollTop_; return this.container?.scrollTop ?? this.lastScrollTop_;
} }
public get container() { public get container() {
@ -79,7 +85,7 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
} }
public onScroll: UIEventHandler<HTMLDivElement> = event => { public onScroll: UIEventHandler<HTMLDivElement> = event => {
this.scrollTop_ = (event.target as HTMLElement).scrollTop; this.lastScrollTop_ = (event.target as HTMLElement).scrollTop;
this.updateStateItemIndexes(); this.updateStateItemIndexes();
}; };
@ -104,24 +110,29 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
} }
public makeItemIndexVisible(itemIndex: number) { 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; const currentScroll = this.offsetScroll();
let scrollTop = currentScroll;
let scrollTop = 0; if (itemIndex <= this.firstVisibleIndex) {
if (itemIndex < top) {
scrollTop = this.props.itemHeight * itemIndex; scrollTop = this.props.itemHeight * itemIndex;
} else { } else if (itemIndex >= this.lastVisibleIndex - 1) {
scrollTop = this.props.itemHeight * itemIndex - (this.visibleItemCount() - 1) * this.props.itemHeight; const scrollBottom = this.props.itemHeight * (itemIndex + 1);
scrollTop = scrollBottom - this.props.style.height;
} }
if (scrollTop < 0) scrollTop = 0; if (scrollTop < 0) scrollTop = 0;
this.scrollTop_ = scrollTop; if (currentScroll !== scrollTop) {
this.lastScrollTop_ = scrollTop;
this.listRef.current.scrollTop = scrollTop; this.listRef.current.scrollTop = scrollTop;
this.updateStateItemIndexes(); this.updateStateItemIndexes();
} }
}
// shouldComponentUpdate(nextProps, nextState) { // shouldComponentUpdate(nextProps, nextState) {
// for (const n in this.props) { // 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>; 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++) { if (this.props.alwaysRenderSelection && isFinite(this.props.selectedIndex)) {
const itemComp = this.props.itemRenderer(items[i], i); 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(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']; const classes = ['item-list'];
if (this.props.className) classes.push(this.props.className); if (this.props.className) classes.push(this.props.className);
const wrapContent = this.props.renderContentWrapper ?? ((children) => <>{children}</>);
return ( return (
<div <div
ref={this.listRef} ref={this.listRef}
@ -182,7 +217,7 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onDrop={this.onDrop} onDrop={this.onDrop}
> >
{itemComps} {wrapContent(itemComps)}
</div> </div>
); );
} }

View File

@ -14,6 +14,7 @@ import useFocusHandler from './hooks/useFocusHandler';
import useOnRenderItem from './hooks/useOnRenderItem'; import useOnRenderItem from './hooks/useOnRenderItem';
import { ListItem } from './types'; import { ListItem } from './types';
import useSidebarCommandHandler from './hooks/useSidebarCommandHandler'; import useSidebarCommandHandler from './hooks/useSidebarCommandHandler';
import useOnRenderListWrapper from './hooks/useOnRenderListWrapper';
interface Props { interface Props {
dispatch: Dispatch; dispatch: Dispatch;
@ -39,11 +40,12 @@ const FolderAndTagList: React.FC<Props> = props => {
listItems: listItems, listItems: listItems,
}); });
const [selectedListElement, setSelectedListElement] = useState<HTMLElement|null>(null); const listContainerRef = useRef<HTMLDivElement|null>(null);
const onRenderItem = useOnRenderItem({ const onRenderItem = useOnRenderItem({
...props, ...props,
selectedIndex, selectedIndex,
onSelectedElementShown: setSelectedListElement, listItems,
containerRef: listContainerRef,
}); });
const onKeyEventHandler = useOnSidebarKeyDownHandler({ const onKeyEventHandler = useOnSidebarKeyDownHandler({
@ -55,14 +57,17 @@ const FolderAndTagList: React.FC<Props> = props => {
}); });
const itemListRef = useRef<ItemList<ListItem>>(); const itemListRef = useRef<ItemList<ListItem>>();
const { focusSidebar } = useFocusHandler({ itemListRef, selectedListElement, selectedIndex, listItems }); const { focusSidebar } = useFocusHandler({ itemListRef, selectedIndex, listItems });
useSidebarCommandHandler({ focusSidebar }); useSidebarCommandHandler({ focusSidebar });
const [itemListContainer, setItemListContainer] = useState<HTMLDivElement|null>(null); const [itemListContainer, setItemListContainer] = useState<HTMLDivElement|null>(null);
listContainerRef.current = itemListContainer;
const listHeight = useElementHeight(itemListContainer); const listHeight = useElementHeight(itemListContainer);
const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]); const listStyle = useMemo(() => ({ height: listHeight }), [listHeight]);
const onRenderContentWrapper = useOnRenderListWrapper({ selectedIndex, onKeyDown: onKeyEventHandler });
return ( return (
<div <div
className='folder-and-tag-list' className='folder-and-tag-list'
@ -72,9 +77,15 @@ const FolderAndTagList: React.FC<Props> = props => {
className='items' className='items'
ref={itemListRef} ref={itemListRef}
style={listStyle} style={listStyle}
items={listItems} items={listItems}
itemRenderer={onRenderItem} 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} 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 { ListItem } from '../types';
import ItemList from '../../ItemList'; import ItemList from '../../ItemList';
import { focus } from '@joplin/lib/utils/focusHandler'; import { focus } from '@joplin/lib/utils/focusHandler';
interface Props { interface Props {
itemListRef: RefObject<ItemList<ListItem>>; itemListRef: RefObject<ItemList<ListItem>>;
selectedListElement: HTMLElement|null;
selectedIndex: number; selectedIndex: number;
listItems: ListItem[]; listItems: ListItem[];
} }
const useFocusAfterNextRenderHandler = ( const useScrollToSelectionHandler = (
shouldFocusAfterNextRender: MutableRefObject<boolean>,
selectedListElement: HTMLElement|null,
) => {
useEffect(() => {
if (!shouldFocusAfterNextRender.current || !selectedListElement) return;
focus('FolderAndTagList/useFocusHandler/afterRender', selectedListElement);
shouldFocusAfterNextRender.current = false;
}, [selectedListElement, shouldFocusAfterNextRender]);
};
const useRefocusOnSelectionChangeHandler = (
itemListRef: RefObject<ItemList<ListItem>>, itemListRef: RefObject<ItemList<ListItem>>,
shouldFocusAfterNextRender: MutableRefObject<boolean>,
listItems: ListItem[], listItems: ListItem[],
selectedIndex: number, selectedIndex: number,
) => { ) => {
@ -49,32 +36,33 @@ const useRefocusOnSelectionChangeHandler = (
useEffect(() => { useEffect(() => {
if (!itemListRef.current || !selectedItemKey) return; if (!itemListRef.current || !selectedItemKey) return;
const hasFocus = !!itemListRef.current.container.querySelector(':scope :focus'); const hasFocus = !!itemListRef.current.container.contains(document.activeElement);
shouldFocusAfterNextRender.current = hasFocus;
if (hasFocus) { if (hasFocus) {
itemListRef.current.makeItemIndexVisible(selectedIndexRef.current); itemListRef.current.makeItemIndexVisible(selectedIndexRef.current);
} }
}, [selectedItemKey, itemListRef, shouldFocusAfterNextRender]); }, [selectedItemKey, itemListRef]);
}; };
const useFocusHandler = (props: Props) => { 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. useScrollToSelectionHandler(itemListRef, listItems, selectedIndex);
const shouldFocusAfterNextRender = useRef(false);
useRefocusOnSelectionChangeHandler(itemListRef, shouldFocusAfterNextRender, listItems, selectedIndex);
useFocusAfterNextRenderHandler(shouldFocusAfterNextRender, selectedListElement);
const focusSidebar = useCallback(() => { const focusSidebar = useCallback(() => {
if (!selectedListElement || !itemListRef.current.isIndexVisible(selectedIndex)) { if (!itemListRef.current.isIndexVisible(selectedIndex)) {
itemListRef.current.makeItemIndexVisible(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 }; return { focusSidebar };
}; };

View File

@ -29,6 +29,8 @@ import Logger from '@joplin/utils/Logger';
import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop'; import onFolderDrop from '@joplin/lib/models/utils/onFolderDrop';
import HeaderItem from '../listItemComponents/HeaderItem'; import HeaderItem from '../listItemComponents/HeaderItem';
import AllNotesItem from '../listItemComponents/AllNotesItem'; import AllNotesItem from '../listItemComponents/AllNotesItem';
import ListItemWrapper from '../listItemComponents/ListItemWrapper';
import { focus } from '@joplin/lib/utils/focusHandler';
const Menu = bridge().Menu; const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem; const MenuItem = bridge().MenuItem;
@ -41,15 +43,27 @@ interface Props {
plugins: PluginStates; plugins: PluginStates;
folders: FolderEntity[]; folders: FolderEntity[];
collapsedFolderIds: string[]; collapsedFolderIds: string[];
containerRef: React.RefObject<HTMLDivElement>;
selectedIndex: number; selectedIndex: number;
onSelectedElementShown: (element: HTMLElement)=> void; listItems: ListItem[];
} }
type ItemContextMenuListener = MouseEventHandler<HTMLElement>; type ItemContextMenuListener = MouseEventHandler<HTMLElement>;
const menuUtils = new MenuUtils(CommandService.instance()); 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 useOnRenderItem = (props: Props) => {
const pluginsRef = useRef<PluginStates>(null); const pluginsRef = useRef<PluginStates>(null);
@ -326,26 +340,24 @@ const useOnRenderItem = (props: Props) => {
const selectedIndexRef = useRef(props.selectedIndex); const selectedIndexRef = useRef(props.selectedIndex);
selectedIndexRef.current = props.selectedIndex; selectedIndexRef.current = props.selectedIndex;
const itemCount = props.listItems.length;
return useCallback((item: ListItem, index: number) => { return useCallback((item: ListItem, index: number) => {
const selected = props.selectedIndex === index; const selected = props.selectedIndex === index;
const anchorRefCallback = selected ? ( const focusInList = document.hasFocus() && props.containerRef.current?.contains(document.activeElement);
(element: HTMLElement) => { const anchorRef = (focusInList && selected) ? focusListItem : noFocusListItem;
if (selectedIndexRef.current === index) {
props.onSelectedElementShown(element);
}
}
) : null;
if (item.kind === ListItemType.Tag) { if (item.kind === ListItemType.Tag) {
const tag = item.tag; const tag = item.tag;
return <TagItem return <TagItem
key={item.key} key={item.key}
anchorRef={anchorRefCallback} anchorRef={anchorRef}
selected={selected} selected={selected}
onClick={tagItem_click} onClick={tagItem_click}
onTagDrop={onTagDrop_} onTagDrop={onTagDrop_}
onContextMenu={onItemContextMenu} onContextMenu={onItemContextMenu}
tag={tag} tag={tag}
itemCount={itemCount}
index={index}
/>; />;
} else if (item.kind === ListItemType.Folder) { } else if (item.kind === ListItemType.Folder) {
const folder = item.folder; const folder = item.folder;
@ -368,7 +380,7 @@ const useOnRenderItem = (props: Props) => {
} }
return <FolderItem return <FolderItem
key={item.key} key={item.key}
anchorRef={anchorRefCallback} anchorRef={anchorRef}
selected={selected} selected={selected}
folderId={folder.id} folderId={folder.id}
folderTitle={Folder.displayTitle(folder)} folderTitle={Folder.displayTitle(folder)}
@ -386,23 +398,41 @@ const useOnRenderItem = (props: Props) => {
shareId={folder.share_id} shareId={folder.share_id}
parentId={folder.parent_id} parentId={folder.parent_id}
showFolderIcon={showFolderIcons} showFolderIcon={showFolderIcons}
index={index}
itemCount={itemCount}
/>; />;
} else if (item.kind === ListItemType.Header) { } else if (item.kind === ListItemType.Header) {
return <HeaderItem return <HeaderItem
key={item.id} key={item.id}
anchorRef={anchorRef}
item={item} item={item}
anchorRef={anchorRefCallback} isSelected={selected}
onDrop={item.supportsFolderDrop ? onFolderDrop_ : null} onDrop={item.supportsFolderDrop ? onFolderDrop_ : null}
index={index}
itemCount={itemCount}
/>; />;
} else if (item.kind === ListItemType.AllNotes) { } else if (item.kind === ListItemType.AllNotes) {
return <AllNotesItem return <AllNotesItem
key={item.key} key={item.key}
anchorRef={anchorRef}
selected={selected} selected={selected}
anchorRef={anchorRefCallback} index={index}
itemCount={itemCount}
/>; />;
} else if (item.kind === ListItemType.Spacer) { } else if (item.kind === ListItemType.Spacer) {
return ( 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 { } else {
const exhaustivenessCheck: never = item; const exhaustivenessCheck: never = item;
@ -421,7 +451,8 @@ const useOnRenderItem = (props: Props) => {
showFolderIcons, showFolderIcons,
tagItem_click, tagItem_click,
props.selectedIndex, 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 { Dispatch } from 'redux';
import { FolderListItem, ListItem, ListItemType, SetSelectedIndexCallback } from '../types'; import { ListItem, ListItemType, SetSelectedIndexCallback } from '../types';
import { KeyboardEventHandler, useCallback } from 'react'; import { KeyboardEventHandler, useCallback } from 'react';
import CommandService from '@joplin/lib/services/CommandService'; import CommandService from '@joplin/lib/services/CommandService';
import toggleHeader from './utils/toggleHeader';
interface Props { interface Props {
dispatch: Dispatch; 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)) { if (!['Space', 'ArrowLeft', 'ArrowRight'].includes(keyCode)) {
return false; return false;
} }
if (keyCode === 'Space') { if (keyCode === 'Space') {
return true; 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; return (keyCode === 'ArrowRight') === isCollapsed;
}; };
@ -29,21 +35,22 @@ const useOnSidebarKeyDownHandler = (props: Props) => {
return useCallback<KeyboardEventHandler<HTMLElement>>((event) => { return useCallback<KeyboardEventHandler<HTMLElement>>((event) => {
const selectedItem = listItems[selectedIndex]; 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(); event.preventDefault();
if (selectedItem.kind === ListItemType.Folder) {
dispatch({ dispatch({
type: 'FOLDER_TOGGLE', type: 'FOLDER_TOGGLE',
id: selectedItem.folder.id, id: selectedItem.folder.id,
}); });
} else if (selectedItem.kind === ListItemType.Header) {
toggleHeader(selectedItem.id);
} }
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
event.preventDefault(); event.preventDefault();
} } else if (event.code === 'ArrowUp') {
let indexChange = 0;
if (event.code === 'ArrowUp') {
indexChange = -1; indexChange = -1;
} else if (event.code === 'ArrowDown') { } else if (event.code === 'ArrowDown') {
indexChange = 1; 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 { FolderEntity, TagsWithNoteCountEntity } from '@joplin/lib/services/database/types';
import { buildFolderTree, renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared'; import { buildFolderTree, renderFolders, renderTags } from '@joplin/lib/components/shared/side-menu-shared';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import CommandService from '@joplin/lib/services/CommandService'; import toggleHeader from './utils/toggleHeader';
import Setting from '@joplin/lib/models/Setting';
interface Props { interface Props {
tags: TagsWithNoteCountEntity[]; tags: TagsWithNoteCountEntity[];
@ -14,16 +13,6 @@ interface Props {
tagHeaderIsExpanded: boolean; 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 useSidebarListData = (props: Props): ListItem[] => {
const tagItems = useMemo(() => { const tagItems = useMemo(() => {
return renderTags<ListItem>(props.tags, (tag): TagListItem => { return renderTags<ListItem>(props.tags, (tag): TagListItem => {
@ -60,10 +49,10 @@ const useSidebarListData = (props: Props): ListItem[] => {
kind: ListItemType.Header, kind: ListItemType.Header,
label: _('Notebooks'), label: _('Notebooks'),
iconName: 'icon-notebooks', iconName: 'icon-notebooks',
expanded: props.folderHeaderIsExpanded,
id: HeaderId.FolderHeader, id: HeaderId.FolderHeader,
key: HeaderId.FolderHeader, key: HeaderId.FolderHeader,
onClick: onHeaderClick, onClick: toggleHeader,
onPlusButtonClick: onAddFolderButtonClick,
extraProps: { extraProps: {
['data-folder-id']: '', ['data-folder-id']: '',
}, },
@ -79,10 +68,10 @@ const useSidebarListData = (props: Props): ListItem[] => {
kind: ListItemType.Header, kind: ListItemType.Header,
label: _('Tags'), label: _('Tags'),
iconName: 'icon-tags', iconName: 'icon-tags',
expanded: props.tagHeaderIsExpanded,
id: HeaderId.TagHeader, id: HeaderId.TagHeader,
key: HeaderId.TagHeader, key: HeaderId.TagHeader,
onClick: onHeaderClick, onClick: toggleHeader,
onPlusButtonClick: null,
extraProps: { }, extraProps: { },
supportsFolderDrop: false, 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 * as React from 'react';
import { StyledAllNotesIcon, StyledListItem, StyledListItemAnchor } from '../styles'; import { StyledAllNotesIcon, StyledListItemAnchor } from '../styles';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Dispatch } from 'redux'; import { Dispatch } from 'redux';
import bridge from '../../../services/bridge'; import bridge from '../../../services/bridge';
@ -10,6 +10,7 @@ import PerFolderSortOrderService from '../../../services/sortOrder/PerFolderSort
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import EmptyExpandLink from './EmptyExpandLink'; import EmptyExpandLink from './EmptyExpandLink';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids'); const { ALL_NOTES_FILTER_ID } = require('@joplin/lib/reserved-ids');
const Menu = bridge().Menu; const Menu = bridge().Menu;
@ -17,8 +18,10 @@ const MenuItem = bridge().MenuItem;
interface Props { interface Props {
dispatch: Dispatch; dispatch: Dispatch;
anchorRef: ListItemRef;
selected: boolean; selected: boolean;
anchorRef: React.Ref<HTMLAnchorElement>; index: number;
itemCount: number;
} }
const menuUtils = new MenuUtils(CommandService.instance()); const menuUtils = new MenuUtils(CommandService.instance());
@ -46,21 +49,28 @@ const AllNotesItem: React.FC<Props> = props => {
}, []); }, []);
return ( 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/> <EmptyExpandLink/>
<StyledAllNotesIcon className="icon-notes"/> <StyledAllNotesIcon aria-label='' role='img' className='icon-notes'/>
<StyledListItemAnchor <StyledListItemAnchor
ref={props.anchorRef}
className="list-item" className="list-item"
isSpecialItem={true} isSpecialItem={true}
href="#"
selected={props.selected} selected={props.selected}
onClick={onAllNotesClick_} onClick={onAllNotesClick_}
onContextMenu={toggleAllNotesContextMenu} onContextMenu={toggleAllNotesContextMenu}
> >
{_('All notes')} {_('All notes')}
</StyledListItemAnchor> </StyledListItemAnchor>
</StyledListItem> </ListItemWrapper>
); );
}; };

View File

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

View File

@ -23,11 +23,12 @@ const ExpandIcon: React.FC<ExpandIconProps> = props => {
return undefined; return undefined;
} }
if (props.isExpanded) { 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; export default ExpandIcon;

View File

@ -8,16 +8,17 @@ interface ExpandLinkProps {
folderTitle: string; folderTitle: string;
hasChildren: boolean; hasChildren: boolean;
isExpanded: boolean; isExpanded: boolean;
className: string;
onClick: MouseEventHandler<HTMLElement>; onClick: MouseEventHandler<HTMLElement>;
} }
const ExpandLink: React.FC<ExpandLinkProps> = props => { const ExpandLink: React.FC<ExpandLinkProps> = props => {
return props.hasChildren ? ( 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}/> <ExpandIcon isVisible={true} isExpanded={props.isExpanded} targetTitle={props.folderTitle}/>
</a> </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 { FolderIcon, FolderIconType } from '@joplin/lib/services/database/types';
import ExpandLink from './ExpandLink'; import ExpandLink from './ExpandLink';
import { StyledListItem, StyledListItemAnchor, StyledShareIcon, StyledSpanFix } from '../styles'; import { StyledListItemAnchor, StyledShareIcon, StyledSpanFix } from '../styles';
import { ItemClickListener, ItemContextMenuListener, ItemDragListener } from '../types'; import { ItemClickListener, ItemContextMenuListener, ItemDragListener } from '../types';
import FolderIconBox from '../../FolderIconBox'; import FolderIconBox from '../../FolderIconBox';
import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash'; 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 { ModelType } from '@joplin/lib/BaseModel';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import NoteCount from './NoteCount'; import NoteCount from './NoteCount';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const renderFolderIcon = (folderIcon: FolderIcon) => { const renderFolderIcon = (folderIcon: FolderIcon) => {
if (!folderIcon) { if (!folderIcon) {
@ -26,6 +27,7 @@ const renderFolderIcon = (folderIcon: FolderIcon) => {
}; };
interface FolderItemProps { interface FolderItemProps {
anchorRef: ListItemRef;
hasChildren: boolean; hasChildren: boolean;
showFolderIcon: boolean; showFolderIcon: boolean;
isExpanded: boolean; isExpanded: boolean;
@ -43,7 +45,9 @@ interface FolderItemProps {
onFolderToggleClick_: ItemClickListener; onFolderToggleClick_: ItemClickListener;
shareId: string; shareId: string;
selected: boolean; selected: boolean;
anchorRef: React.Ref<HTMLElement>;
index: number;
itemCount: number;
} }
function FolderItem(props: FolderItemProps) { function FolderItem(props: FolderItemProps) {
@ -63,29 +67,50 @@ function FolderItem(props: FolderItemProps) {
}; };
return ( 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}> <ListItemWrapper
<ExpandLink hasChildren={hasChildren} folderTitle={folderTitle} folderId={folderId} onClick={onFolderToggleClick_} isExpanded={isExpanded}/> containerRef={props.anchorRef}
<StyledListItemAnchor // Folders are contained within the "Notebooks" section (which has depth 0):
ref={props.anchorRef} depth={depth + 1}
className="list-item"
isConflictFolder={folderId === Folder.conflictFolderId()}
href="#"
selected={selected} selected={selected}
aria-selected={selected} itemIndex={props.index}
shareId={shareId} 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-id={folderId}
data-type={ModelType.Folder} 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={() => { onClick={() => {
folderItem_click(folderId); folderItem_click(folderId);
}} }}
onDoubleClick={onFolderToggleClick_}
> >
{doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix> {doRenderFolderIcon()}<StyledSpanFix className="title">{folderTitle}</StyledSpanFix>
{shareIcon} <NoteCount count={noteCount}/> {shareIcon} <NoteCount count={noteCount}/>
</StyledListItemAnchor> </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 * as React from 'react';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { ButtonLevel } from '../../Button/Button'; import { StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
import { StyledAddButton, StyledHeader, StyledHeaderIcon, StyledHeaderLabel } from '../styles';
import { HeaderId, HeaderListItem } from '../types'; import { HeaderId, HeaderListItem } from '../types';
import { _ } from '@joplin/lib/locale';
import bridge from '../../../services/bridge'; import bridge from '../../../services/bridge';
import MenuUtils from '@joplin/lib/services/commands/MenuUtils'; import MenuUtils from '@joplin/lib/services/commands/MenuUtils';
import CommandService from '@joplin/lib/services/CommandService'; import CommandService from '@joplin/lib/services/CommandService';
import ListItemWrapper, { ListItemRef } from './ListItemWrapper';
const Menu = bridge().Menu; const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem; const MenuItem = bridge().MenuItem;
@ -14,9 +13,12 @@ const menuUtils = new MenuUtils(CommandService.instance());
interface Props { interface Props {
anchorRef: ListItemRef;
item: HeaderListItem; item: HeaderListItem;
isSelected: boolean;
onDrop: React.DragEventHandler|null; onDrop: React.DragEventHandler|null;
anchorRef: React.Ref<HTMLElement>; index: number;
itemCount: number;
} }
const HeaderItem: React.FC<Props> = props => { const HeaderItem: React.FC<Props> = props => {
@ -42,30 +44,25 @@ const HeaderItem: React.FC<Props> = props => {
} }
}, [itemId]); }, [itemId]);
const addButton = <StyledAddButton
iconLabel={_('New')}
onClick={item.onPlusButtonClick}
iconName='fas fa-plus'
level={ButtonLevel.SidebarSecondary}
/>;
return ( 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' className='sidebar-header-container'
{...item.extraProps} {...item.extraProps}
onDrop={props.onDrop} onDrop={props.onDrop}
> >
<StyledHeader <StyledHeader onClick={onClick}>
onContextMenu={onContextMenu} <StyledHeaderIcon aria-label='' role='img' className={item.iconName}/>
onClick={onClick}
tabIndex={0}
ref={props.anchorRef}
>
<StyledHeaderIcon aria-label='' className={item.iconName}/>
<StyledHeaderLabel>{item.label}</StyledHeaderLabel> <StyledHeaderLabel>{item.label}</StyledHeaderLabel>
</StyledHeader> </StyledHeader>
{ item.onPlusButtonClick && addButton } </ListItemWrapper>
</div>
); );
}; };

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

View File

@ -1,6 +1,8 @@
@use 'styles/folder-and-tag-list.scss'; @use 'styles/folder-and-tag-list.scss';
@use 'styles/list-item-wrapper.scss';
@use 'styles/note-count-label.scss'; @use 'styles/note-count-label.scss';
@use 'styles/sidebar-expand-icon.scss'; @use 'styles/sidebar-expand-icon.scss';
@use 'styles/sidebar-expand-link.scss'; @use 'styles/sidebar-expand-link.scss';
@use 'styles/sidebar-header-container.scss'; @use 'styles/sidebar-header-container.scss';
@use 'styles/sidebar-spacer-item.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; 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) { function listItemTextColor(props: StyleProps) {
if (props.isConflictFolder) return props.theme.colorError2; if (props.isConflictFolder) return props.theme.colorError2;
if (props.isSpecialItem) return props.theme.colorFaded2; 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; opacity: 0.8;
text-decoration: none; text-decoration: none;
padding-right: 8px; padding-right: 8px;
text-align: center;
display: flex; display: flex;
align-items: center; align-items: center;
width: 16px; width: 16px;

View File

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

View File

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

View File

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

View File

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