You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop: Accessibility: Add ARIA information to the sidebar's notebook and tag list (#11196)
This commit is contained in:
		| @@ -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/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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user