import React from 'react'; import {cn} from '../../utils/cn'; import i18n from './i18n'; import {KEYBOARD_FOCUSED_ROW_CLASS_NAME, shouldHandleKeyboardNavigation} from './utils'; import './TableKeyboardNavigation.scss'; const tableKeyboardNavigationCn = cn('ydb-table-keyboard-navigation'); interface Scope { enabled: boolean; searches: Set; containerRef?: React.RefObject; adapters: Set; activeAdapter?: Adapter; listeners: Set<() => void>; announcementRef: React.RefObject; } interface Adapter { element: HTMLElement; scope: Scope; getFirst: () => number | undefined; getLast: () => number | undefined; getNext: (index: number, step: number) => number | undefined; getSelected: () => number | undefined; select: (index: number | undefined) => void; activate?: (index: number) => void; } const ScopeContext = React.createContext(null); export const TableKeyboardNavigationGroupContext = React.createContext( undefined, ); function isVisible(element: HTMLElement) { const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight; } function resetScope(scope: Scope) { scope.activeAdapter = undefined; scope.adapters.forEach((adapter) => adapter.select(undefined)); updateAnnouncement(scope, ''); } function updateAnnouncement(scope: Scope, text: string) { const announcement = scope.announcementRef.current; if (announcement) { announcement.textContent = text; } } function notifyScope(scope: Scope) { scope.listeners.forEach((listener) => listener()); } function isAvailable(adapter: Adapter) { const {scope, element} = adapter; return ( scope.enabled && element.isConnected && element.getClientRects().length > 0 && getComputedStyle(element).visibility === 'visible' && !element.closest('[inert], [hidden], [aria-hidden="true"]') && (!scope.containerRef?.current || scope.containerRef.current.contains(element)) ); } function resolveTable(scope: Scope, target: HTMLElement) { const boundary = target.closest('[data-table-keyboard-scope]'); if (boundary && boundary !== scope.containerRef?.current) { return undefined; } const {activeAdapter} = scope; const available = Array.from(scope.adapters).filter( (adapter) => isAvailable(adapter) && adapter.getFirst() !== undefined, ); const direct = available.find((adapter) => adapter.element.contains(target)); const fromSearch = scope.searches.has(target as HTMLInputElement); const fromDocument = target === document.body || target === document.documentElement; if (target.closest('input, [role="textbox"], [role="searchbox"]') && !fromSearch) { return undefined; } if (!direct && !fromSearch && !fromDocument) { return undefined; } if (fromDocument) { const searches = Array.from( document.querySelectorAll('[data-table-keyboard-search]'), ).filter((search) => !search.disabled && isVisible(search)); if (searches.length !== 1 || !scope.searches.has(searches[0])) { return undefined; } } const tables = available.sort((a, b) => { // eslint-disable-next-line no-bitwise return a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); const table = direct ?? (activeAdapter && tables.includes(activeAdapter) ? activeAdapter : (tables.find((adapter) => isVisible(adapter.element)) ?? tables[0])); if (!table) { return undefined; } return {table, tables}; } function handleKeyDown(scope: Scope, event: KeyboardEvent) { if ( !scope.enabled || !Array.from(scope.searches).some((search) => search.isConnected && !search.disabled) || event.defaultPrevented || event.isComposing || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || !['ArrowUp', 'ArrowDown', 'Enter'].includes(event.key) || !(event.target instanceof HTMLElement) || !shouldHandleKeyboardNavigation(event.target) ) { return; } const resolved = resolveTable(scope, event.target); if (!resolved) { if (event.key !== 'Enter' && scope.searches.has(event.target as HTMLInputElement)) { event.preventDefault(); } return; } const {table, tables} = resolved; if (event.key === 'Enter') { if (table.activate) { event.preventDefault(); const index = table.getSelected() ?? table.getFirst(); if (!event.repeat && index !== undefined) { table.activate(index); } } return; } event.preventDefault(); moveSelection(table, tables, event.key === 'ArrowDown' ? 1 : -1); } function moveSelection(table: Adapter, tables: Adapter[], step: number) { const selected = table.getSelected(); const initial = selected ?? table.getFirst(); if (initial === undefined) { return; } let destination = table; let index = table.getNext(initial, step); if (index === undefined && selected !== undefined) { const adjacent = tables[tables.indexOf(table) + step]; const adjacentIndex = step > 0 ? adjacent?.getFirst() : adjacent?.getLast(); if (adjacent && adjacentIndex !== undefined) { destination = adjacent; index = adjacentIndex; } } table.scope.activeAdapter = destination; for (const adapter of table.scope.adapters) { adapter.select(adapter === destination ? (index ?? initial) : undefined); } } export function TableKeyboardNavigationScope({ children, enabled = true, containerRef, inherit = false, resetKey, }: { children: React.ReactNode; enabled?: boolean; containerRef?: React.RefObject; inherit?: boolean; resetKey?: string; }) { const parent = React.useContext(ScopeContext); const own = React.useMemo( () => ({ enabled: enabled && parent?.enabled !== false, containerRef, searches: new Set(), adapters: new Set(), listeners: new Set(), announcementRef: React.createRef(), }), [enabled, parent, containerRef], ); const scope = inherit && enabled && parent ? parent : own; const previousResetKey = React.useRef(resetKey); React.useLayoutEffect(() => { if (previousResetKey.current !== resetKey) { previousResetKey.current = resetKey; resetScope(scope); } }, [scope, resetKey]); React.useEffect(() => { if (scope !== own) { return undefined; } const onKeyDown = (event: KeyboardEvent) => handleKeyDown(scope, event); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('keydown', onKeyDown); }; }, [scope, own, parent]); return ( {children} {scope === own && scope.enabled && (
)} ); } export function useTableNavigationEnabled() { const scope = React.useContext(ScopeContext); const subscribe = React.useCallback( (listener: () => void) => { scope?.listeners.add(listener); return () => { scope?.listeners.delete(listener); }; }, [scope], ); const getSnapshot = React.useCallback( () => Boolean(scope?.enabled && scope.searches.size), [scope], ); return React.useSyncExternalStore(subscribe, getSnapshot, () => false); } export function useTableSearch( ref: React.RefObject, enabled: boolean, value?: string, ) { const scope = React.useContext(ScopeContext); React.useEffect(() => { const input = ref.current; if (!scope || !scope.enabled || !enabled || !input) { return undefined; } scope.searches.add(input); input.dataset.tableKeyboardSearch = ''; notifyScope(scope); return () => { scope.searches.delete(input); delete input.dataset.tableKeyboardSearch; resetScope(scope); notifyScope(scope); }; }, [scope, ref, enabled]); React.useEffect(() => { if (enabled && scope) { resetScope(scope); } }, [scope, enabled, value]); } interface AdapterOptions { tableRef: React.RefObject; getFirst: Adapter['getFirst']; getLast: Adapter['getLast']; getNext: Adapter['getNext']; activate?: Adapter['activate']; resetDeps: React.DependencyList; isValidIndex?: (index: number) => boolean; getRowKey?: (index: number) => string | number | undefined; getRowLabel?: (index: number) => string | undefined; findRowIndex?: (key: string | number, previousIndex: number) => number | undefined; getRowLookupRevision?: (revision?: string) => string | undefined; isRowLookupPending?: (revision: string | undefined) => boolean; subscribe?: (listener: () => void) => () => void; } type RowSelection = { index: number; key?: string | number; lookupRevision?: string; pending?: boolean; }; type Selection = RowSelection | undefined; const subscribeToNothing = () => () => {}; export function useTableKeyboardAdapter(options: AdapterOptions) { const scope = React.useContext(ScopeContext); const group = React.useContext(TableKeyboardNavigationGroupContext); const [scrollRevision, setScrollRevision] = React.useState(0); const [hover, setHover] = React.useState(false); const selectedRef = React.useRef(); const snapshotRef = React.useRef(); const optionsRef = React.useRef(options); const [element, setElement] = React.useState(null); const ref = React.useCallback( (node: HTMLElement | null) => { (options.tableRef as React.MutableRefObject).current = node; setElement(node); }, [options.tableRef], ); optionsRef.current = options; const getSelection = React.useCallback((): Selection => { const current = selectedRef.current; let next = current; if (current) { const { findRowIndex, getRowKey, getRowLookupRevision, isRowLookupPending, isValidIndex, getLast, } = optionsRef.current; const matchedIndex = current.key !== undefined && getRowKey?.(current.index) !== current.key && findRowIndex ? findRowIndex(current.key, current.index) : current.index; if (matchedIndex === undefined && isRowLookupPending?.(current.lookupRevision)) { next = { ...current, lookupRevision: getRowLookupRevision?.(current.lookupRevision), pending: true, }; } else { const position = matchedIndex ?? current.index; const index = isValidIndex?.(position) === false ? getLast() : position; next = index === undefined ? undefined : { index, key: getRowKey?.(index), lookupRevision: getRowLookupRevision?.(current.lookupRevision), }; } } const previous = snapshotRef.current; if ( previous && next && previous.index === next.index && previous.key === next.key && previous.lookupRevision === next.lookupRevision && previous.pending === next.pending ) { return previous; } snapshotRef.current = next; return next; }, []); const selection = React.useSyncExternalStore( options.subscribe ?? subscribeToNothing, getSelection, getSelection, ); React.useLayoutEffect(() => { selectedRef.current = selection; }, [selection]); const select = React.useCallback((index: number | undefined) => { const next = index === undefined ? undefined : { index, key: optionsRef.current.getRowKey?.(index), lookupRevision: optionsRef.current.getRowLookupRevision?.(), }; const previous = selectedRef.current; selectedRef.current = next; if (previous?.index !== next?.index || previous?.key !== next?.key) { setScrollRevision((revision) => revision + 1); } setHover(false); }, []); const getSelected = React.useCallback(() => getSelection()?.index, [getSelection]); const previousResetDeps = React.useRef(options.resetDeps); React.useLayoutEffect(() => { const previous = previousResetDeps.current; previousResetDeps.current = options.resetDeps; if ( previous.length !== options.resetDeps.length || options.resetDeps.some((value, index) => !Object.is(value, previous[index])) ) { select(undefined); } }, [options.resetDeps, select]); React.useEffect(() => { if (!scope || !element) { return undefined; } const adapter: Adapter = { element, scope, getFirst: () => optionsRef.current.getFirst(), getLast: () => optionsRef.current.getLast(), getNext: (index, step) => optionsRef.current.getNext(index, step), getSelected, select, get activate() { const activate = optionsRef.current.activate; if (!activate || !getSelection()?.pending) { return activate; } return () => undefined; }, }; scope.adapters.add(adapter); return () => { scope.adapters.delete(adapter); if (scope.activeAdapter === adapter) { scope.activeAdapter = undefined; updateAnnouncement(scope, ''); } }; }, [scope, element, select, getSelected]); React.useEffect(() => { if (!scope || !element || scope.activeAdapter?.element !== element) { return undefined; } updateAnnouncement(scope, ''); const index = selection?.index; if (hover || selection?.pending || index === undefined) { return undefined; } const announceRow = () => { if ( scope.activeAdapter?.element !== element || scope.activeAdapter.getSelected() !== index ) { return false; } const row = element.querySelector( `tbody tr.${KEYBOARD_FOCUSED_ROW_CLASS_NAME}`, ); const {getRowLabel} = optionsRef.current; const text = ( getRowLabel ? (getRowLabel(index) ?? '') : (row?.innerText ?? row?.textContent ?? '') ) .replace(/\s+/g, ' ') .trim(); const lastIndex = optionsRef.current.getLast(); if (!text || lastIndex === undefined) { return false; } updateAnnouncement( scope, group ? i18n('value_selected-row-in-group', { group, position: index + 1, total: lastIndex + 1, text, }) : i18n('value_selected-row', { position: index + 1, total: lastIndex + 1, text, }), ); return true; }; if (announceRow()) { return undefined; } const observer = new MutationObserver(() => { if (announceRow()) { observer.disconnect(); } }); observer.observe(element, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'], }); return () => observer.disconnect(); }, [ scope, element, selection?.index, selection?.key, selection?.pending, scrollRevision, hover, group, ]); return { ref, scrollRevision, selected: selection?.index, focusedIndex: hover || selection?.pending ? undefined : selection?.index, onMouseMove: (event: React.MouseEvent) => { if (selectedRef.current && (event.target as HTMLElement).closest('tbody tr')) { setHover(true); } }, }; }