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:
parent
609ee3e227
commit
38be0e81a9
@ -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
3
.gitignore
vendored
@ -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
|
||||
|
@ -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}`);
|
||||
}
|
||||
|
@ -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,23 +110,28 @@ 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;
|
||||
this.listRef.current.scrollTop = scrollTop;
|
||||
if (currentScroll !== scrollTop) {
|
||||
this.lastScrollTop_ = scrollTop;
|
||||
this.listRef.current.scrollTop = scrollTop;
|
||||
|
||||
this.updateStateItemIndexes();
|
||||
this.updateStateItemIndexes();
|
||||
}
|
||||
}
|
||||
|
||||
// shouldComponentUpdate(nextProps, nextState) {
|
||||
@ -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);
|
||||
itemComps.push(itemComp);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
itemComps.push(blankItem('bottom', (items.length - this.state.bottomItemIndex - 1) * this.props.itemHeight));
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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 };
|
||||
};
|
||||
|
@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -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)) {
|
||||
event.preventDefault();
|
||||
|
||||
dispatch({
|
||||
type: 'FOLDER_TOGGLE',
|
||||
id: selectedItem.folder.id,
|
||||
});
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
let indexChange = 0;
|
||||
if (event.code === 'ArrowUp') {
|
||||
|
||||
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);
|
||||
}
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.code === 'KeyA') { // ctrl+a or cmd+a
|
||||
event.preventDefault();
|
||||
} else if (event.code === 'ArrowUp') {
|
||||
indexChange = -1;
|
||||
} else if (event.code === 'ArrowDown') {
|
||||
indexChange = 1;
|
||||
|
@ -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,
|
||||
};
|
||||
|
10
packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.ts
Normal file
10
packages/app-desktop/gui/Sidebar/hooks/utils/toggleHeader.ts
Normal 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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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}/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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}/>
|
||||
<ListItemWrapper
|
||||
containerRef={props.anchorRef}
|
||||
// Folders are contained within the "Notebooks" section (which has depth 0):
|
||||
depth={depth + 1}
|
||||
selected={selected}
|
||||
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}
|
||||
>
|
||||
<StyledListItemAnchor
|
||||
ref={props.anchorRef}
|
||||
className="list-item"
|
||||
isConflictFolder={folderId === Folder.conflictFolderId()}
|
||||
href="#"
|
||||
selected={selected}
|
||||
aria-selected={selected}
|
||||
shareId={shareId}
|
||||
data-id={folderId}
|
||||
data-type={ModelType.Folder}
|
||||
onContextMenu={itemContextMenu}
|
||||
data-folder-id={folderId}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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';
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
padding-right: 8px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 16px;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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}"`);
|
||||
element[action]();
|
||||
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);
|
||||
};
|
||||
|
@ -132,3 +132,4 @@ Famegear
|
||||
rcompare
|
||||
tabindex
|
||||
Backblaze
|
||||
treeitem
|
||||
|
Loading…
Reference in New Issue
Block a user