diff --git a/CHANGELOG.md b/CHANGELOG.md index 4505fe6..2cfe19c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p - Allow to configure `SearchResults` utility component with `isItemDisabled` and `multiSelection` props: * Remove `singleSelectOnClick` mode from `SearchResults` as it mostly superseded by `multiSelection`. - Extend `ListElementView` utility component to accept any other additional HTML props. +- Export `AccessibleList` component as base for [accessible](https://www.w3.org/TR/wai-aria/) list-like container. - Always display ungroup buttons on `StandardGroup` when the element is single-selected. ## [0.34.1] - 2026-03-30 diff --git a/src/coreUtils/hooks.ts b/src/coreUtils/hooks.ts index 276955e..9ef64a3 100644 --- a/src/coreUtils/hooks.ts +++ b/src/coreUtils/hooks.ts @@ -168,14 +168,11 @@ export function useAsync(params: { load: (input: I, options: { signal: AbortSignal }) => Promise | undefined; }): UseAsyncResult { const {input, load} = params; - const latestLoad = React.useRef(load); + const latestLoad = useLatest(load); const [result, setResult] = React.useState>({ data: undefined, status: 'loading', }); - React.useEffect(() => { - latestLoad.current = load; - }); React.useEffect(() => { const controller = new AbortController(); const signal = controller.signal; @@ -201,3 +198,11 @@ export function useAsync(params: { }, input); return result; } + +export function useLatest(value: T): { readonly current: T } { + const ref = React.useRef(value); + React.useEffect(() => { + ref.current = value; + }); + return ref; +} diff --git a/src/widgets/classTree/classTreeResults.tsx b/src/widgets/classTree/classTreeResults.tsx index d2d85a6..08122b7 100644 --- a/src/widgets/classTree/classTreeResults.tsx +++ b/src/widgets/classTree/classTreeResults.tsx @@ -6,11 +6,11 @@ import { useTranslation } from '../../coreUtils/i18n'; import { ElementTypeIri, ElementTypeModel } from '../../data/model'; import { useWorkspace } from '../../workspace/workspaceContext'; -import { highlightSubstring } from '../utility/listElementView'; import { - TreeList, type TreeListModel, type TreeListRenderItem, type TreeListFocusProps, - TreeListState, type TreeListUpPath, treeListPathToDown, -} from '../utility/treeList'; + AccessibleTree, type TreeModel, type TreeRenderItem, TreeState, type TreeUpPath, + TreeFocusableProps, treePathToDown, +} from '../utility/accessibleTree'; +import { highlightSubstring } from '../utility/listElementView'; export interface ClassTreeResultsProps extends ClassTreeProvidedContext { nodes: ReadonlyArray; @@ -28,12 +28,12 @@ export interface ClassTreeProvidedContext { export interface ClassTreeSelection { readonly node: TreeNode; - readonly selection: TreeListState; + readonly selection: TreeState; } interface ClassTreeContext extends ClassTreeProvidedContext { - readonly onExpand: (path: TreeListUpPath) => void; - readonly onSelect: (node: TreeNode, path: TreeListUpPath) => void; + readonly onExpand: (path: TreeUpPath) => void; + readonly onSelect: (node: TreeNode, path: TreeUpPath) => void; } const ClassTreeContext = React.createContext(null); @@ -46,7 +46,7 @@ export function ClassTreeResults(props: ClassTreeResultsProps) { onClickCreate, onDragCreate, draggableItems, } = props; - const renderItem = React.useCallback>( + const renderItem = React.useCallback>( ({item, path, focusProps, expanded, selected}) => ( >(); - const onExpand = React.useCallback((path: TreeListUpPath) => { + const [expanded, setExpanded] = React.useState>(); + const onExpand = React.useCallback((path: TreeUpPath) => { setExpanded(previous => - (previous ?? new TreeListState()) - .setAt(treeListPathToDown(path), itemExpanded => !(itemExpanded ?? defaultExpanded)) + (previous ?? new TreeState()) + .setAt(treePathToDown(path), itemExpanded => !(itemExpanded ?? defaultExpanded)) ); }, [defaultExpanded]); React.useEffect(() => setExpanded(undefined), [searchText]); @@ -90,8 +90,8 @@ export function ClassTreeResults(props: ClassTreeResultsProps) { onExpand, onSelect: (node, path) => onSelect({ node, - selection: new TreeListState().setAt( - treeListPathToDown(path), + selection: new TreeState().setAt( + treePathToDown(path), () => node ), }), @@ -109,14 +109,14 @@ export function ClassTreeResults(props: ClassTreeResultsProps) { return ( - setExpanded(previous => ( - (previous ?? new TreeListState()).setAt(path, () => expand) + (previous ?? new TreeState()).setAt(path, () => expand) ))} selected={selection?.selection} rootProps={rootProps} @@ -138,7 +138,7 @@ export const TreeNode = { setDerived: (node: TreeNode, derived: ReadonlyArray): TreeNode => ({...node, derived}), }; -const ClassTreeModel: TreeListModel = { +const ClassTreeModel: TreeModel = { getKey: item => item.iri, getChildren: item => item.derived, getDefaultSelected: (item, selected) => undefined, @@ -147,8 +147,8 @@ const ClassTreeModel: TreeListModel = { function Leaf(props: { node: TreeNode; - path: TreeListUpPath; - focusProps: TreeListFocusProps; + path: TreeUpPath; + focusProps: TreeFocusableProps; expanded: boolean; selected?: TreeNode; }) { diff --git a/src/widgets/connectionsMenu/connectionList.tsx b/src/widgets/connectionsMenu/connectionList.tsx index 1af003b..51ad5d1 100644 --- a/src/widgets/connectionsMenu/connectionList.tsx +++ b/src/widgets/connectionsMenu/connectionList.tsx @@ -7,10 +7,10 @@ import type { LinkTypeModel } from '../../data/model'; import { generate128BitID, makeCaseInsensitiveFilter } from '../../data/utils'; import { WithFetchStatus } from '../../editor/withFetchStatus'; -import { highlightSubstring } from '../utility/listElementView'; import { - TreeList, type TreeListModel, type TreeListRenderItem, type TreeListFocusProps, -} from '../utility/treeList'; + AccessibleList, type ListRenderItem, type ListFocusableProps, +} from '../utility/accessibleList'; +import { highlightSubstring } from '../utility/listElementView'; import { useWorkspace } from '../../workspace/workspaceContext'; @@ -93,7 +93,7 @@ export function ConnectionsList(props: { } } - const renderItem = React.useCallback>( + const renderItem = React.useCallback>( ({item, focusProps}) => { if (item.type === 'link') { return ( @@ -128,7 +128,6 @@ export function ConnectionsList(props: { className: `${CLASS_NAME}__links-root`, role: 'list', }), []); - const forestProps = React.useMemo((): React.HTMLProps => ({}), []); const itemProps = React.useMemo((): React.HTMLProps => ({ className: `${CLASS_NAME}__links-item`, role: 'listitem', @@ -143,12 +142,12 @@ export function ConnectionsList(props: { )} tabIndex={-1} > - {entries.length === 0 ? ( @@ -160,13 +159,6 @@ export function ConnectionsList(props: { ); } -const ConnectionListModel: TreeListModel = { - getKey: item => item.type === 'link' ? item.key : item.type, - getChildren: item => undefined, - getDefaultSelected: (item, selected) => undefined, - isActive: item => item.type === 'link', -}; - type ConnectionEntry = ConnectionEntryLink | ConnectionEntrySeparator; interface ConnectionEntrySeparator { @@ -183,6 +175,14 @@ interface ConnectionEntryLink { readonly probability?: number; } +function getEntryKey(entry: ConnectionEntry): string { + return entry.type === 'link' ? entry.key : entry.type; +} + +function isEntryActive(entry: ConnectionEntry): boolean { + return entry.type === 'link'; +} + function getConnectionLinks(links: LinkTypeModel[], options: { counts: ConnectionsData['counts']; scores: ConnectionSuggestions['scores']; @@ -201,11 +201,10 @@ function getConnectionLinks(links: LinkTypeModel[], options: { return; } - const postfix = probable ? '-probable' : ''; const score = scores.get(link.id); entries.push({ type: 'link', - key: `${direction}-${link.id}-${postfix}`, + key: `${direction}-${probable ? 'probable' : ''}-${link.id}`, linkType: link, direction, count: inexact && count > 0 ? 'some' : count, @@ -253,7 +252,7 @@ function ConnectionLink(props: { onExpandLink: (linkDataChunk: LinkDataChunk) => void; onMoveToFilter: ((linkDataChunk: LinkDataChunk) => void) | undefined; probability?: number; - focusProps?: TreeListFocusProps; + focusProps?: ListFocusableProps; }) { const { link, filterKey, direction, count, onExpandLink, onMoveToFilter, probability = 0, focusProps, diff --git a/src/widgets/utility/accessibleList.tsx b/src/widgets/utility/accessibleList.tsx new file mode 100644 index 0000000..e59a3b4 --- /dev/null +++ b/src/widgets/utility/accessibleList.tsx @@ -0,0 +1,199 @@ +import * as React from 'react'; + +import { useLatest } from '../../coreUtils/hooks'; + +import { + AccessibleTree, type TreeModel, type TreeRenderItem, TreeState, type TreeItemState, +} from './accessibleTree'; + +/** + * Function to render content for each item in an {@link AccessibleList}. + */ +export type ListRenderItem = (props: { + /** + * Item data. + */ + item: T; + /** + * Props to set on a sub-element to make it focusable. + * + * Can be applied to multiple sub-elements to move focus + * between them with `Tab` key. + */ + focusProps: ListFocusableProps; + /** + * Selected state for the item. + * + * Item is considered selected when the value is different from `undefined`. + */ + selected: S | undefined; +}) => React.ReactElement | null; + +/** + * Props for a focusable DOM-element within an {@link AccessibleList}. + */ +export interface ListFocusableProps { + /** + * See [tabIndex](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex). + */ + readonly tabIndex: number; +} + +const DEFAULT_ROOT_PROPS: React.HTMLProps = { + role: 'list', +}; + +const DEFAULT_ITEM_PROPS: React.HTMLProps = { + role: 'listitem', +}; + +/** + * Utility component as base for [accessible](https://www.w3.org/TR/wai-aria/) + * list-like container. + * + * The container component acts as [focus group](https://www.w3.org/TR/wai-aria/#managingfocus) + * with keyboard navigation via arrow keys between items and `Tab` key to move focus + * to/from container or within currently focused item (if it has multiple focusable children). + * + * @category Components + */ +export function AccessibleList(props: { + /** + * Item data to render in the list. + */ + items: readonly T[]; + /** + * Pure function to get unique key for an item. + */ + getItemKey: (item: T) => string; + /** + * Pure function to determine whether an item is active (`true`) or disabled (`false`). + * + * Disabled items cannot be focused on. + */ + isItemActive?: (item: T) => boolean; + /** + * Pure function to render an item. + */ + renderItem: ListRenderItem; + /** + * Selection state for the list items. + */ + selection?: ListSelection; + /** + * Props for the top-level container element. + * + * **Default**: + * ``` + * {role: 'list'} + * ``` + */ + rootProps?: React.HTMLProps; + /** + * Props for each element wrapper around rendered item. + * + * **Default**: + * ``` + * {role: 'listitem'} + * ``` + */ + itemProps?: React.HTMLProps; +}) { + const {items, getItemKey, isItemActive, renderItem, selection, rootProps, itemProps} = props; + + const renderTreeItem = React.useCallback>( + ({item, focusProps, selected}) => renderItem({item, focusProps, selected}), + [renderItem] + ); + + const latestGetKey = useLatest(getItemKey); + const latestIsActive = useLatest(isItemActive); + const model = React.useMemo((): TreeModel => ({ + getKey: item => latestGetKey.current(item), + getChildren: item => undefined, + getDefaultSelected: (item, selected) => undefined, + isActive: item => latestIsActive.current?.(item) ?? true, + }), [latestGetKey, latestIsActive]); + + const forestProps = React.useMemo((): React.HTMLProps => ({}), []); + + return ( + + ); +} + +/** + * Represents a selection state in an {@link AccessibleList}. + * + * Item is considered to be selected if associated value is different from `undefined`. + */ +export class ListSelection implements Iterable { + private static readonly _empty = new ListSelection( + new TreeState() + ); + + /** @hidden */ + readonly _state: TreeState; + + private constructor(state: TreeState) { + this._state = state; + } + + /** + * Gets a empty selection state. + */ + static empty(): ListSelection { + return ListSelection._empty as ListSelection; + } + + /** + * Creates a selection state from a sequence of `[item, state]` pairs. + */ + static fromEntries(entries: Iterable): ListSelection { + const states = new Map>(); + for (const [key, state] of entries) { + states.set(key, {value: state}); + } + return new ListSelection(new TreeState(states)); + } + + [Symbol.iterator](): Iterator { + return this.entries(); + } + + /** + * Gets an iterator over selected `[item, state]` pairs. + */ + *entries(): Iterator { + for (const [key, state] of this._state) { + if (state.value !== undefined) { + yield [key, state.value]; + } + } + } + + /** + * Gets a selection state for the item with the specified `key`. + */ + get(key: string): S | undefined { + return this._state.get(key)?.value; + } + + /** + * Updates a selection state for the item with the specified `key`. + */ + update(key: string, updater: (previous: S | undefined) => S | undefined): ListSelection { + const nextState = this._state.setAt({key, child: undefined}, updater); + if (nextState === this._state) { + return this; + } + return new ListSelection(nextState); + } +} diff --git a/src/widgets/utility/treeList.tsx b/src/widgets/utility/accessibleTree.tsx similarity index 83% rename from src/widgets/utility/treeList.tsx rename to src/widgets/utility/accessibleTree.tsx index d04f6ea..bb3cb59 100644 --- a/src/widgets/utility/treeList.tsx +++ b/src/widgets/utility/accessibleTree.tsx @@ -2,17 +2,17 @@ import * as React from 'react'; import { findNextWithin, findPreviousWithin } from '../../coreUtils/dom'; -export interface TreeListProps { - model: TreeListModel; +export interface AccessibleTreeProps { + model: TreeModel; items: readonly T[]; - renderItem: TreeListRenderItem; - expanded?: TreeListState; + renderItem: TreeRenderItem; + expanded?: TreeState; /** * @default false */ defaultExpanded?: boolean; - onSetExpanded?: (item: T, path: TreeListDownPath, expand: boolean) => void; - selected?: TreeListState; + onSetExpanded?: (item: T, path: TreeDownPath, expand: boolean) => void; + selected?: TreeState; /** * @default undefined */ @@ -22,34 +22,33 @@ export interface TreeListProps { itemProps: React.HTMLProps; } -export interface TreeListModel { +export interface TreeModel { readonly getKey: (item: T) => string; readonly getChildren: (item: T) => readonly T[] | undefined; readonly getDefaultSelected: (item: T, selected: S | undefined) => S | undefined; readonly isActive: (item: T) => boolean; } -export type TreeListRenderItem = (props: { +export type TreeRenderItem = (props: { item: T; - path: TreeListUpPath; - focusProps: TreeListFocusProps; + path: TreeUpPath; + focusProps: TreeFocusableProps; expanded: boolean; selected: S | undefined; }) => React.ReactElement | null; -export interface TreeListFocusProps { - tabIndex: number; - 'data-tree-focusable': true; -}; +export interface TreeFocusableProps { + readonly tabIndex: number; +} -export function TreeList(props: TreeListProps) { +export function AccessibleTree(props: AccessibleTreeProps) { const { model, items, renderItem, expanded, defaultExpanded, onSetExpanded, selected, defaultSelected, rootProps, forestProps, itemProps, } = props; const getTotalChildCount = React.useMemo(() => makeGetTotalChildCount(model), [model]); - const treeContext = React.useMemo((): TreeListContext => ({ + const treeContext = React.useMemo((): TreeContext => ({ model, getTotalChildCount, renderItem, @@ -61,7 +60,7 @@ export function TreeList(props: TreeListProps) { const [focusIndex, setFocusIndex] = React.useState(0); const tryFocusOnItemElement = (target: Element | undefined) => { if (target) { - const focusable = target.querySelector('[data-tree-focusable]'); + const focusable = target.querySelector('[tabIndex]'); if (focusable instanceof HTMLElement) { focusable.focus(); } @@ -128,7 +127,10 @@ export function TreeList(props: TreeListProps) { } const currentIndex = Number(current.getAttribute('data-tree-index')); if (Number.isFinite(currentIndex)) { - if (e.key === 'ArrowLeft' && current.getAttribute('aria-expanded') === 'false') { + if (e.key === 'ArrowLeft' && ( + !current.hasAttribute('aria-expanded') || + current.getAttribute('aria-expanded') === 'false' + )) { // Focus on parent item const parent = current.parentElement ? findTreeIndexedAt(current.parentElement, e.currentTarget) @@ -147,6 +149,8 @@ export function TreeList(props: TreeListProps) { } } } + } else { + rootProps?.onKeyDown?.(e); } }, }} @@ -154,23 +158,23 @@ export function TreeList(props: TreeListProps) { ); } -interface TreeListContext { - readonly model: TreeListModel; +interface TreeContext { + readonly model: TreeModel; readonly getTotalChildCount: (item: T) => number; - readonly renderItem: TreeListRenderItem; + readonly renderItem: TreeRenderItem; readonly forestProps: React.HTMLProps; readonly itemProps: React.HTMLProps; } function Forest(props: { - treeContext: TreeListContext; + treeContext: TreeContext; items: readonly T[]; index: number; - parentPath?: TreeListUpPath; + parentPath?: TreeUpPath; focusIndex: number; - expanded: TreeListState | undefined; + expanded: TreeState | undefined; defaultExpanded: boolean; - selected: TreeListState | undefined; + selected: TreeState | undefined; defaultSelected: S | undefined; rootProps?: React.HTMLProps; }) { @@ -186,7 +190,7 @@ function Forest(props: { {items.map(item => { const itemIndex = nextIndex; nextIndex += 1 + getTotalChildCount(item); - const path: TreeListUpPath = { + const path: TreeUpPath = { parent: parentPath, key: model.getKey(item), }; @@ -209,14 +213,14 @@ function Forest(props: { } function Item(props: { - treeContext: TreeListContext; + treeContext: TreeContext; item: T; index: number; - path: TreeListUpPath; + path: TreeUpPath; focusIndex: number; - expanded: TreeListItemState | undefined; + expanded: TreeItemState | undefined; defaultExpanded: boolean; - selected: TreeListItemState | undefined; + selected: TreeItemState | undefined; defaultSelected: S | undefined; }) { const { @@ -240,7 +244,6 @@ function Item(props: { path, focusProps: { tabIndex: index === focusIndex ? 0 : -1, - 'data-tree-focusable': true, }, expanded: leafExpanded, selected: leafSelected, @@ -261,31 +264,35 @@ function Item(props: { ); } -export interface TreeListUpPath { - readonly parent: TreeListUpPath | undefined; +export interface TreeUpPath { + readonly parent: TreeUpPath | undefined; readonly key: string; } -export interface TreeListDownPath { - readonly child: TreeListDownPath | undefined; +export interface TreeDownPath { + readonly child: TreeDownPath | undefined; readonly key: string; } -export class TreeListState { +export class TreeState implements Iterable]> { constructor( - private readonly states = new Map>() + private readonly states = new Map>() ) {} - get(key: string): TreeListItemState | undefined { + [Symbol.iterator](): Iterator]> { + return this.states[Symbol.iterator](); + } + + get(key: string): TreeItemState | undefined { return this.states.get(key); } setAt( - path: TreeListDownPath, + path: TreeDownPath, updater: (previous: S | undefined) => S | undefined - ): TreeListState { + ): TreeState { const itemState = this.states.get(path.key); - const nextState = TreeListState.setAtItem( + const nextState = TreeState.setAtItem( itemState, path.child, updater ); if (nextState === itemState) { @@ -297,21 +304,21 @@ export class TreeListState { } else { nextStates.delete(path.key); } - return new TreeListState(nextStates); + return new TreeState(nextStates); } private static setAtItem( - itemState: TreeListItemState | undefined, - path: TreeListDownPath | undefined, + itemState: TreeItemState | undefined, + path: TreeDownPath | undefined, updater: (previous: S | undefined) => S | undefined - ): TreeListItemState | undefined { + ): TreeItemState | undefined { if (path) { if (itemState && itemState.level) { const nextLevel = itemState.level.setAt(path, updater); return nextLevel === itemState.level ? itemState : {...itemState, level: nextLevel}; } else { - const nextItem = TreeListState.setAtItem( + const nextItem = TreeState.setAtItem( undefined, path.child, updater ); if (!nextItem || nextItem === itemState) { @@ -319,7 +326,7 @@ export class TreeListState { } return { value: itemState?.value, - level: new TreeListState(new Map([[path.key, nextItem]])), + level: new TreeState(new Map([[path.key, nextItem]])), }; } } @@ -336,14 +343,14 @@ export class TreeListState { } } -export interface TreeListItemState { +export interface TreeItemState { readonly value: S | undefined; - readonly level?: TreeListState | undefined; + readonly level?: TreeState | undefined; } -export function treeListPathToDown(upPath: TreeListUpPath): TreeListDownPath { +export function treePathToDown(upPath: TreeUpPath): TreeDownPath { let current = upPath.parent; - let downward: TreeListDownPath = { + let downward: TreeDownPath = { key: upPath.key, child: undefined, }; @@ -358,7 +365,7 @@ export function treeListPathToDown(upPath: TreeListUpPath): TreeListDownPath { } function makeGetTotalChildCount( - model: TreeListModel + model: TreeModel ): (item: T) => number { const totalChildCount = new WeakMap(); const getTotalChildCount = (item: T): number => { @@ -380,7 +387,7 @@ function makeGetTotalChildCount( } function findItem( - model: TreeListModel, + model: TreeModel, items: readonly T[], isMatch: (item: T) => boolean ): [T, number] | undefined { @@ -411,12 +418,12 @@ function findItem( } function findItemAtIndex( - model: TreeListModel, + model: TreeModel, getTotalChildCount: (item: T) => number, items: readonly T[], firstIndex: number, targetIndex: number -): [T, TreeListDownPath] | undefined { +): [T, TreeDownPath] | undefined { let current = firstIndex; for (const item of items) { if (current === targetIndex) { diff --git a/src/widgets/utility/draggableHandle.tsx b/src/widgets/utility/draggableHandle.tsx index 1ea009e..1df6517 100644 --- a/src/widgets/utility/draggableHandle.tsx +++ b/src/widgets/utility/draggableHandle.tsx @@ -1,6 +1,8 @@ import cx from 'clsx'; import * as React from 'react'; +import { useLatest } from '../../coreUtils/hooks'; + import type { DockDirection } from './viewportDock'; /** @@ -132,9 +134,3 @@ export function DraggableHandle(props: DraggableHandleProps) { ); } - -function useLatest(value: T): { readonly current: T } { - const ref = React.useRef(value); - ref.current = value; - return ref; -} diff --git a/src/widgets/utility/searchResults.tsx b/src/widgets/utility/searchResults.tsx index 164a604..a48bff8 100644 --- a/src/widgets/utility/searchResults.tsx +++ b/src/widgets/utility/searchResults.tsx @@ -1,18 +1,17 @@ import * as React from 'react'; import { - neverSyncStore, useEventStore, useFrameDebouncedStore, useSyncStore, + neverSyncStore, useEventStore, useFrameDebouncedStore, useSyncStore, useLatest, } from '../../coreUtils/hooks'; import { ElementModel, ElementIri } from '../../data/model'; import { getAllPresentEntities } from '../../editor/dataDiagramModel'; import { useWorkspace } from '../../workspace/workspaceContext'; -import { ListElementView, startDragElements } from './listElementView'; import { - TreeList, TreeListState, type TreeListModel, type TreeListRenderItem, type TreeListFocusProps, - type TreeListUpPath, -} from './treeList'; + AccessibleList, type ListRenderItem, ListSelection, type ListFocusableProps, +} from './accessibleList'; +import { ListElementView, startDragElements } from './listElementView'; const CLASS_NAME = 'reactodia-search-results'; @@ -76,9 +75,9 @@ export function SearchResults(props: SearchResultsProps) { useDragAndDrop = true, multiSelection = true, footer, } = props; - const renderItem = React.useCallback>( - ({item, path, focusProps, selected}) => ( - + const renderItem = React.useCallback>( + ({item, focusProps, selected}) => ( + ), [] ); @@ -87,7 +86,6 @@ export function SearchResults(props: SearchResultsProps) { role: 'list', 'aria-multiselectable': true, }), []); - const forestProps = React.useMemo((): React.HTMLProps => ({}), []); const itemProps = React.useMemo((): React.HTMLProps => ({ className: `${CLASS_NAME}__item`, role: 'listitem', @@ -99,10 +97,7 @@ export function SearchResults(props: SearchResultsProps) { active: !computeIsItemDisabled(data), })), [items, computeIsItemDisabled]); - const latestItems = React.useRef(items); - React.useEffect(() => { - latestItems.current = items; - }); + const latestItems = useLatest(items); const lastSelected = React.useRef(); const searchResultsContext = React.useMemo( @@ -150,13 +145,11 @@ export function SearchResults(props: SearchResultsProps) { ] ); - const selected = React.useMemo((): TreeListState | undefined => { + const selected = React.useMemo((): ListSelection | undefined => { if (selection.size === 0) { return undefined; } - return new TreeListState( - new Map(Array.from(selection, iri => [iri, {value: true}])) - ); + return ListSelection.fromEntries(Array.from(selection, iri => [iri, true])); }, [selection]); React.useEffect(() => { @@ -176,13 +169,13 @@ export function SearchResults(props: SearchResultsProps) { return (
- {footer} @@ -216,12 +209,13 @@ interface ElementItem { readonly active: boolean; } -const SearchResultsModel: TreeListModel = { - getKey: item => item.data.id, - getChildren: item => undefined, - getDefaultSelected: (item, selected) => undefined, - isActive: item => item.active, -}; +function getElementItemKey(item: ElementItem): string { + return item.data.id; +} + +function isElementItemActive(item: ElementItem): boolean { + return item.active; +} interface SearchResultsContext { readonly highlightText: string | undefined; @@ -246,8 +240,7 @@ function useSearchResultsContext(): SearchResultsContext { function ResultItem(props: { item: ElementItem; - path: TreeListUpPath; - focusProps: TreeListFocusProps; + focusProps: ListFocusableProps; selected: boolean | undefined; }) { const {item, focusProps, selected} = props; diff --git a/src/workspace.ts b/src/workspace.ts index c1eabc3..1001256 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -204,6 +204,9 @@ export { export { AnnotationSupport, type AnnotationSupportProps, type AnnotationCommands, } from './widgets/annotation'; +export { + AccessibleList, type ListFocusableProps, type ListRenderItem, ListSelection, +} from './widgets/utility/accessibleList'; export { DraggableHandle, type DraggableHandleProps } from './widgets/utility/draggableHandle'; export { DropdownMenu, type DropdownMenuProps, DropdownMenuItem, type DropdownMenuItemProps,