Skip to content

Commit

Permalink
Desktop: Accessibility: Add ARIA information to the sidebar's noteboo…
Browse files Browse the repository at this point in the history
…k and tag list (#11196)
  • Loading branch information
personalizedrefrigerator authored Oct 15, 2024
1 parent 609ee3e commit 38be0e8
Show file tree
Hide file tree
Showing 29 changed files with 466 additions and 189 deletions.
3 changes: 3 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/app-desktop/gui/FolderIconBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
83 changes: 59 additions & 24 deletions packages/app-desktop/gui/ItemList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();

Expand All @@ -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({
Expand All @@ -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() {
Expand All @@ -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();
};

Expand All @@ -104,23 +110,28 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
}

public makeItemIndexVisible(itemIndex: number) {
if (this.isIndexVisible(itemIndex)) return;

const top = this.firstVisibleIndex;
// 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;
}

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) {
Expand Down Expand Up @@ -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}
Expand All @@ -182,7 +217,7 @@ class ItemList<ItemType> extends React.Component<Props<ItemType>, State> {
onKeyDown={this.onKeyDown}
onDrop={this.onDrop}
>
{itemComps}
{wrapContent(itemComps)}
</div>
);
}
Expand Down
19 changes: 15 additions & 4 deletions packages/app-desktop/gui/Sidebar/FolderAndTagList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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({
Expand All @@ -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'
Expand All @@ -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}
/>
Expand Down
46 changes: 17 additions & 29 deletions packages/app-desktop/gui/Sidebar/hooks/useFocusHandler.ts
Original file line number Diff line number Diff line change
@@ -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,
) => {
Expand All @@ -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 };
};
Expand Down
Loading

0 comments on commit 38be0e8

Please # to comment.