diff --git a/package.json b/package.json index 4585a7a..b676017 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,14 @@ "require": "./internal/analytics-metadata/utils.js", "default": "./mjs/internal/analytics-metadata/utils.js" }, + "./internal/table-role": { + "require": "./internal/table-role/index.js", + "default": "./mjs/internal/table-role/index.js" + }, + "./internal/async-store": { + "require": "./internal/async-store/index.js", + "default": "./mjs/internal/async-store/index.js" + }, "./package.json": "./package.json" }, "types": "./mjs/index.d.ts", diff --git a/src/internal/async-store/__tests__/async-store.test.ts b/src/internal/async-store/__tests__/async-store.test.ts new file mode 100644 index 0000000..8a03047 --- /dev/null +++ b/src/internal/async-store/__tests__/async-store.test.ts @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { act, renderHook } from './render-hook'; +import AsyncStore, { useReaction, useSelector } from '../index.js'; + +describe('AsyncStore', () => { + it('notifies listeners when selected state is updated', () => { + const store = new AsyncStore({ a: 1, b: 2 }); + + let a = store.get().a; + store.subscribe( + state => state.a, + newState => { + a = newState.a; + } + ); + + store.set(state => ({ ...state, a: state.a + 1 })); + store.set(state => ({ ...state, b: state.b + 1 })); + store.set(state => ({ ...state, a: state.a + 1 })); + + expect(store.get().a).toBe(3); + expect(store.get().b).toBe(3); + expect(a).toBe(3); + }); + + it('allows unsubscribing from updates', () => { + const store = new AsyncStore({ a: 1, b: 2 }); + + let a = store.get().a; + const unsubscribeA = store.subscribe( + state => state.a, + newState => { + a = newState.a; + } + ); + + store.set(state => ({ ...state, a: state.a + 1 })); + unsubscribeA(); + store.set(state => ({ ...state, a: state.a + 1 })); + + expect(store.get().a).toBe(3); + expect(a).toBe(2); + }); + + it('can be used with useReaction to describe effects', () => { + const store = new AsyncStore({ a: 1, b: 2 }); + + const aIncrements: number[] = []; + renderHook(() => + useReaction( + store, + s => s.a, + a => { + aIncrements.push(a); + } + ) + ); + + act(() => store.set(state => ({ ...state, a: state.a + 1 }))); + act(() => store.set(state => ({ ...state, a: state.a + 1 }))); + + expect(aIncrements).toEqual([2, 3]); + }); + + it('can be used with useSelector to make state from selected properties', () => { + const store = new AsyncStore({ a: 1, b: 2 }); + + const { result } = renderHook(() => useSelector(store, s => s.a)); + expect(result.current).toEqual(1); + + act(() => store.set(state => ({ ...state, a: state.a + 1 }))); + expect(result.current).toEqual(2); + + act(() => store.set(state => ({ ...state, a: state.a + 1 }))); + expect(result.current).toEqual(3); + }); +}); diff --git a/src/internal/async-store/__tests__/render-hook.tsx b/src/internal/async-store/__tests__/render-hook.tsx new file mode 100644 index 0000000..a6a272d --- /dev/null +++ b/src/internal/async-store/__tests__/render-hook.tsx @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React from 'react'; +import { render } from '@testing-library/react'; + +export { act } from '@testing-library/react'; + +/** + * Minimal port of `renderHook` from @testing-library/react (native version needs v13+). + * Supports the no-options usage exercised by the async-store tests. + */ +export function renderHook(renderCallback: () => Result) { + const result = React.createRef() as React.MutableRefObject; + + function TestComponent() { + const pendingResult = renderCallback(); + + React.useEffect(() => { + result.current = pendingResult; + }); + + return null; + } + + const { rerender, unmount } = render(); + + return { result, rerender, unmount }; +} diff --git a/src/internal/async-store/index.ts b/src/internal/async-store/index.ts new file mode 100644 index 0000000..316a220 --- /dev/null +++ b/src/internal/async-store/index.ts @@ -0,0 +1,91 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useLayoutEffect, useState } from 'react'; +import { unstable_batchedUpdates } from 'react-dom'; + +import { usePrevious } from './use-previous.js'; + +type Selector = (state: S) => R; +type Listener = (state: S, prevState: S) => void; + +export interface ReadonlyAsyncStore { + get(): S; + subscribe(selector: Selector, listener: Listener): () => void; + unsubscribe(listener: Listener): void; +} + +export default class AsyncStore implements ReadonlyAsyncStore { + _state: S; + _listeners: [Selector, Listener][] = []; + + constructor(state: S) { + this._state = state; + } + + get(): S { + return this._state; + } + + set(cb: (state: S) => S): void { + const prevState = this._state; + const newState = cb(prevState); + + this._state = newState; + + unstable_batchedUpdates(() => { + for (const [selector, listener] of this._listeners) { + if (selector(prevState) !== selector(newState)) { + listener(newState, prevState); + } + } + }); + } + + subscribe(selector: Selector, listener: Listener): () => void { + this._listeners.push([selector, listener]); + + return () => this.unsubscribe(listener); + } + + unsubscribe(listener: Listener): void { + for (let index = 0; index < this._listeners.length; index++) { + const [, storedListener] = this._listeners[index]; + + if (storedListener === listener) { + this._listeners.splice(index, 1); + break; + } + } + } +} + +export function useReaction(store: ReadonlyAsyncStore, selector: Selector, effect: Listener): void { + useLayoutEffect( + () => { + const unsubscribe = store.subscribe(selector, (newState, prevState) => + effect(selector(newState), selector(prevState)) + ); + return unsubscribe; + }, + // ignoring selector and effect as they are expected to stay constant + // eslint-disable-next-line react-hooks/exhaustive-deps + [store] + ); +} + +export function useSelector(store: ReadonlyAsyncStore, selector: Selector): R { + const [state, setState] = useState(selector(store.get())); + + useReaction(store, selector, newState => { + setState(newState); + }); + + // When store changes we need the state to be updated synchronously to avoid inconsistencies. + const prevStore = usePrevious(store); + if (prevStore !== null && prevStore !== store) { + return selector(store.get()); + } + + return state; +} diff --git a/src/internal/async-store/use-previous.ts b/src/internal/async-store/use-previous.ts new file mode 100644 index 0000000..388dcc8 --- /dev/null +++ b/src/internal/async-store/use-previous.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useRef } from 'react'; + +/** + * This hook gives the value of any variable from the previous render invocation + */ +export const usePrevious = (value: T) => { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +}; diff --git a/src/internal/table-role/__tests__/grid-navigation-processor.test.tsx b/src/internal/table-role/__tests__/grid-navigation-processor.test.tsx new file mode 100644 index 0000000..9038071 --- /dev/null +++ b/src/internal/table-role/__tests__/grid-navigation-processor.test.tsx @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { GridNavigationProcessor } from '../grid-navigation.js'; + +describe('GridNavigationProcessor', () => { + test('does not throw when not initialized', () => { + const navigation = new GridNavigationProcessor({ + current: { + updateFocusTarget: () => {}, + getFocusTarget: () => null, + isRegistered: () => false, + }, + }); + expect(() => navigation.getNextFocusTarget()).not.toThrow(); + expect(() => navigation.isElementSuppressed(null)).not.toThrow(); + expect(() => navigation.isElementSuppressed(document.createElement('div'))).not.toThrow(); + expect(() => navigation.onRegisterFocusable(document.createElement('div'))).not.toThrow(); + expect(() => navigation.onUnregisterActive()).not.toThrow(); + expect(() => navigation.refresh()).not.toThrow(); + expect(() => navigation.update({ pageSize: 10 })).not.toThrow(); + expect(() => navigation.cleanup()).not.toThrow(); + }); +}); diff --git a/src/internal/table-role/__tests__/grid-navigation.test.tsx b/src/internal/table-role/__tests__/grid-navigation.test.tsx new file mode 100644 index 0000000..28f0f65 --- /dev/null +++ b/src/internal/table-role/__tests__/grid-navigation.test.tsx @@ -0,0 +1,399 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useRef } from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; + +import { KeyCode } from '../../keycode.js'; +import { GridNavigationProvider } from '../index.js'; +import { actionsColumn, Button, Cell, idColumn, items, nameColumn, TestTable, valueColumn } from './stubs'; + +function readActiveElement() { + return document.activeElement ? formatElement(document.activeElement) : null; +} + +function readFocusableElements() { + return Array.from(document.querySelectorAll('[tabIndex="0"]')).map(formatElement); +} + +function formatElement(element: Element) { + const tagName = element.tagName.toUpperCase(); + const text = element.textContent || element.getAttribute('aria-label'); + return `${tagName}[${text}]`; +} + +test('updates interactive elements tab indices', async () => { + render(); + await waitFor(() => expect(readFocusableElements()).toEqual(['BUTTON[Sort by name]'])); +}); + +test.each([0, 5])('supports arrow keys navigation for startIndex=%s', startIndex => { + const { container, rerender } = render( + + ); + const table = container.querySelector('table')!; + + container.querySelector('th')!.focus(); + expect(readActiveElement()).toBe('TH[ID]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.right }); + expect(readActiveElement()).toBe('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.down }); + expect(readActiveElement()).toBe('TD[First]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.left }); + expect(readActiveElement()).toBe('TD[id1]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.up }); + expect(readActiveElement()).toBe('TH[ID]'); + + rerender(); + + fireEvent.keyDown(table, { keyCode: KeyCode.right }); + expect(readActiveElement()).toBe('TH[Actions]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.down }); + expect(readActiveElement()).toBe('BUTTON[Delete item id1]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.right }); + expect(readActiveElement()).toBe('BUTTON[Copy item id1]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.down }); + expect(readActiveElement()).toBe('BUTTON[Copy item id2]'); +}); + +test('supports key combination navigation', () => { + const { container } = render(); + const table = container.querySelector('table')!; + + container.querySelector('button')!.focus(); + expect(readActiveElement()).toBe('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.pageDown }); + expect(readActiveElement()).toBe('TD[Second]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.pageUp }); + expect(readActiveElement()).toBe('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.end }); + expect(readActiveElement()).toBe('TH[Actions]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.home }); + expect(readActiveElement()).toBe('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.end, ctrlKey: true }); + expect(readActiveElement()).toBe('BUTTON[Copy item id4]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.home, ctrlKey: true }); + expect(readActiveElement()).toBe('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.backspace }); // Unsupported key + expect(readActiveElement()).toBe('BUTTON[Sort by name]'); +}); + +test('suppresses grid navigation when focusing on dialog element', async () => { + const { container } = render(); + const table = container.querySelector('table')!; + + (container.querySelector('button[aria-label="Sort by value"]') as HTMLElement).focus(); + expect(readFocusableElements()).toEqual(['BUTTON[Sort by value]']); + expect(readActiveElement()).toEqual('BUTTON[Sort by value]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.down }); + expect(readFocusableElements()).toEqual(['BUTTON[Edit value 1]']); + expect(readActiveElement()).toEqual('BUTTON[Edit value 1]'); + + (document.activeElement as HTMLElement).click(); + expect(readFocusableElements().length).toBeGreaterThanOrEqual(3); + expect(readActiveElement()).toEqual('INPUT[Value input]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.down }); + expect(readFocusableElements().length).toBeGreaterThanOrEqual(3); + expect(readActiveElement()).toEqual('INPUT[Value input]'); + + (container.querySelector('button[aria-label="Save"]') as HTMLElement).click(); + await waitFor(() => { + expect(readFocusableElements()).toEqual(['BUTTON[Edit value 1]']); + expect(readActiveElement()).toEqual('BUTTON[Edit value 1]'); + }); +}); + +test('updates page size', () => { + const { container, rerender } = render(); + const table = container.querySelector('table')!; + + container.querySelector('th')!.focus(); + expect(readActiveElement()).toEqual('TH[ID]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.pageDown }); + expect(readActiveElement()).toEqual('TD[id3]'); + + rerender(); + + fireEvent.keyDown(table, { keyCode: KeyCode.pageUp }); + expect(readActiveElement()).toEqual('TD[id2]'); +}); + +test('does not throw errors if table is null', () => { + function TestTable() { + return ( + null}> + {null} + + ); + } + expect(() => render()).not.toThrow(); +}); + +test('ignores keydown modifiers other than ctrl', () => { + const { container } = render(); + const table = container.querySelector('table')!; + + container.querySelector('button')!.focus(); + expect(readActiveElement()).toEqual('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.down, altKey: true }); + expect(readActiveElement()).toEqual('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.down, shiftKey: true }); + expect(readActiveElement()).toEqual('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.down, metaKey: true }); + expect(readActiveElement()).toEqual('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.down, ctrlKey: true }); + expect(readActiveElement()).toEqual('BUTTON[Sort by name]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.end, ctrlKey: true }); + expect(readActiveElement()).toEqual('BUTTON[Edit value 4]'); +}); + +test('cell navigation works when the table is mutated between commands', () => { + const { container } = render(); + const table = container.querySelector('table')!; + + const button = (container.querySelectorAll('button') as NodeListOf)[0]; + button.focus(); + + Array.from(table.querySelectorAll('[aria-colindex="3"]')).forEach(node => node.remove()); + + fireEvent.keyDown(table, { keyCode: KeyCode.right }); + expect(button).toHaveFocus(); + + Array.from(table.querySelectorAll('[aria-rowIndex="2"]')).forEach(node => node.remove()); + + fireEvent.keyDown(table, { keyCode: KeyCode.down }); + expect(button).toHaveFocus(); + + Array.from(table.querySelectorAll('th')).forEach(node => node.remove()); + + fireEvent.keyDown(table, { keyCode: KeyCode.down }); + expect(document.body).toHaveFocus(); + + Array.from(table.querySelectorAll('[aria-rowIndex="1"]')).forEach(node => node.remove()); + + fireEvent.keyDown(table, { keyCode: KeyCode.down }); + expect(document.body).toHaveFocus(); +}); + +test('throws no error when focusing on incorrect target', () => { + function TestComponent() { + const tableRef = useRef(null); + return ( + tableRef.current}> + + + + + + + + + + graphic + + +
cell-1-1cell-1-2cell-1-3
+
+ ); + } + + const { container } = render(); + const button = container.querySelector('button')!; + const g = container.querySelector('g')!; + + button.focus(); + expect(button).toHaveFocus(); + + g.focus(); + expect(g).toHaveFocus(); +}); + +test('restores focus when the node gets removed', async () => { + const { container } = render(); + + const editButton = container.querySelector('button[aria-label="Edit value 1"]') as HTMLElement; + const editButtonCell = editButton.closest('td'); + + editButton.focus(); + expect(editButton).toHaveFocus(); + + editButton.blur(); + editButton.remove(); + await waitFor(() => expect(editButtonCell).toHaveFocus()); +}); + +test('all elements focus is restored if table changes role after being rendered as grid', async () => { + const { rerender } = render(); + + await waitFor(() => expect(readFocusableElements()).toEqual(['BUTTON[Sort by value]'])); + + rerender(); + + expect(readFocusableElements()).toEqual([ + 'BUTTON[Sort by value]', + 'BUTTON[Edit value 1]', + 'BUTTON[Edit value 2]', + 'BUTTON[Edit value 3]', + 'BUTTON[Edit value 4]', + ]); +}); + +test('ignores disabled elements', () => { + function TestComponent() { + const tableRef = useRef(null); + return ( + tableRef.current}> + + + + + Cell + + + + + + + + + +
+
+ ); + } + + const { container } = render(); + const table = container.querySelector('table')!; + const cell = container.querySelector('td')!; + + cell.focus(); + expect(readActiveElement()).toEqual('TD[Cell]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.right }); + expect(readActiveElement()).toEqual('BUTTON[Active]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.right }); + expect(readActiveElement()).toEqual('TD[Inactive]'); +}); + +test('respects element order when navigating between extremes', () => { + function TestComponent() { + const tableRef = useRef(null); + return ( + tableRef.current}> + + + + + + + + + + + + + + + + +
+
+ ); + } + + const { container } = render(); + const table = container.querySelector('table')!; + const cell = container.querySelector('td')!; + + cell.focus(); + expect(readActiveElement()).toEqual('BUTTON[1]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.end }); + expect(readActiveElement()).toBe('BUTTON[5]'); + + fireEvent.keyDown(table, { keyCode: KeyCode.home }); + expect(readActiveElement()).toBe('BUTTON[1]'); +}); + +test.each(['td', 'th'] as const)('focuses on the element registered inside focused table %s', tag => { + function TestTable({ contentType }: { contentType: 'text' | 'button' }) { + const tableRef = useRef(null); + return ( + tableRef.current}> + + + + + {contentType === 'text' ? 'text' : } + + + +
+
+ ); + } + const { container, rerender } = render(); + const cell = container.querySelector('td,th') as HTMLElement; + + cell.focus(); + expect(readActiveElement()).toEqual(`${tag.toUpperCase()}[text]`); + + rerender(); + expect(readActiveElement()).toEqual('BUTTON[action]'); +}); + +test('does not focus re-registered element if the focus is not within the table anymore', () => { + const { container, rerender } = render( +
+ + +
+ ); + const firstCell = container.querySelector('td')!; + const outsideButton = container.querySelector('[data-testid="outside-focus-target"]') as HTMLButtonElement; + + firstCell.focus(); + expect(firstCell).toHaveFocus(); + + rerender( +
+ + +
+ ); + expect(firstCell).toHaveFocus(); + + outsideButton.focus(); + expect(outsideButton).toHaveFocus(); + + rerender( +
+ + +
+ ); + expect(outsideButton).toHaveFocus(); +}); diff --git a/src/internal/table-role/__tests__/stubs.tsx b/src/internal/table-role/__tests__/stubs.tsx new file mode 100644 index 0000000..a8d9f71 --- /dev/null +++ b/src/internal/table-role/__tests__/stubs.tsx @@ -0,0 +1,127 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useRef, useState } from 'react'; + +import { useSingleTabStopNavigation } from '../../single-tab-stop/index.js'; + +import { GridNavigationProvider } from '../index.js'; + +export interface Item { + id: string; + name: string; + value: number; +} + +export const items: Item[] = [ + { id: 'id1', name: 'First', value: 1 }, + { id: 'id2', name: 'Second', value: 2 }, + { id: 'id3', name: 'Third', value: 3 }, + { id: 'id4', name: 'Fourth', value: 4 }, +]; + +export const idColumn = { header: 'ID', cell: (item: Item) => item.id }; +export const nameColumn = { + header: ( + + Name + + + +``` diff --git a/src/internal/table-role/grid-navigation.tsx b/src/internal/table-role/grid-navigation.tsx new file mode 100644 index 0000000..c898838 --- /dev/null +++ b/src/internal/table-role/grid-navigation.tsx @@ -0,0 +1,365 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useRef } from 'react'; +import { useEffect, useMemo } from 'react'; + +import { useStableCallback } from '../stable-callback/index.js'; +import { SingleTabStopNavigationAPI, SingleTabStopNavigationProvider } from '../single-tab-stop/index.js'; + +import { getAllFocusables } from '../focus-lock-utils/utils.js'; +import { KeyCode } from '../keycode.js'; +import handleKey, { isEventLike } from '../utils/handle-key.js'; +import nodeBelongs from '../../dom/node-belongs.js'; +import { FocusedCell, GridNavigationProps } from './interfaces.js'; +import { + defaultIsSuppressed, + findNextCell, + findTableRowByAriaRowIndex, + focusNextElement, + getClosestCell, + isElementDisabled, + isTableCell, +} from './utils.js'; + +/** + * Makes table navigable with keyboard commands. + * See grid-navigation.md + */ +export function GridNavigationProvider({ + keyboardNavigation, + pageSize, + getTable, + children, +}: GridNavigationProps): React.ReactElement { + const navigationAPI = useRef(null); + const gridNavigation = useMemo(() => new GridNavigationProcessor(navigationAPI), []); + + const getTableStable = useStableCallback(getTable); + + // Initialize the processor with the table container assuming it is mounted synchronously and only once. + useEffect(() => { + if (keyboardNavigation) { + const table = getTableStable(); + if (table) { + gridNavigation.init(table); + return gridNavigation.cleanup; + } + } + }, [keyboardNavigation, gridNavigation, getTableStable]); + + // Notify the processor of the props change. + useEffect(() => { + gridNavigation.update({ pageSize }); + }, [gridNavigation, pageSize]); + + // Notify the processor of the new render. + useEffect(() => { + if (keyboardNavigation) { + gridNavigation.refresh(); + } + }); + + return ( + + {children} + + ); +} + +/** + * This helper encapsulates the grid navigation behaviors which are: + * 1. Responding to keyboard commands and moving the focus accordingly; + * 2. Muting table interactive elements for only one to be user-focusable at a time; + * 3. Suppressing the above behaviors when focusing an element inside a dialog or when instructed explicitly. + */ +export class GridNavigationProcessor { + // Props + private _pageSize = 0; + private _table: null | HTMLTableElement = null; + private _navigationAPI: { current: null | SingleTabStopNavigationAPI }; + + // State + private focusedCell: null | FocusedCell = null; + private focusInside = false; + private keepUserIndex = false; + + constructor(navigationAPI: { current: null | SingleTabStopNavigationAPI }) { + this._navigationAPI = navigationAPI; + } + + public init(table: HTMLTableElement) { + this._table = table; + const controller = new AbortController(); + + table.addEventListener('focusin', this.onFocusin, { signal: controller.signal }); + table.addEventListener('focusout', this.onFocusout, { signal: controller.signal }); + table.addEventListener('keydown', this.onKeydown, { signal: controller.signal }); + + this.cleanup = () => { + controller.abort(); + }; + } + + public cleanup = () => { + // Do nothing before initialized. + }; + + public update({ pageSize }: { pageSize: number }) { + this._pageSize = pageSize; + } + + public refresh() { + // Timeout ensures the newly rendered content elements are registered. + setTimeout(() => { + if (this._table) { + // Update focused cell indices in case table rows, columns, or firstIndex change. + this.updateFocusedCell(this.focusedCell?.element); + this._navigationAPI.current?.updateFocusTarget(); + } + }, 0); + } + + public onRegisterFocusable = (focusableElement: HTMLElement) => { + if (!this.focusInside) { + return; + } + // When newly registered element belongs to the focused cell the focus must transition to it. + const focusedElement = this.focusedCell?.element; + if (focusedElement && isTableCell(focusedElement) && focusedElement.contains(focusableElement)) { + // Scroll is unnecessary when moving focus from a cell to element within the cell. + focusableElement.focus({ preventScroll: true }); + } + }; + + public onUnregisterActive = () => { + // If the focused cell appears to be no longer attached to the table we need to re-apply + // focus to a cell with the same or closest position. + if (this.focusedCell && !nodeBelongs(this.table, this.focusedCell.element)) { + this.moveFocusBy(this.focusedCell, { x: 0, y: 0 }); + } + }; + + public getNextFocusTarget = () => { + if (!this.table) { + return null; + } + + const cell = this.focusedCell; + const firstTableCell = this.table.querySelector('td,th') as null | HTMLTableCellElement; + + // A single element of the table is made user-focusable. + // It defaults to the first interactive element of the first cell or the first cell itself otherwise. + let focusTarget: null | HTMLElement = + (firstTableCell && this.getFocusablesFrom(firstTableCell)[0]) ?? firstTableCell; + + // When a navigation-focused element is present in the table it is used for user-navigation instead. + if (cell) { + focusTarget = this.getNextFocusable(cell, { x: 0, y: 0 }); + } + + return focusTarget; + }; + + public isElementSuppressed = (element: null | Element) => { + // Omit calculation as irrelevant until the table receives focus. + if (!this.focusedCell) { + return false; + } + return !element || defaultIsSuppressed(element); + }; + + private get pageSize() { + return this._pageSize; + } + + private get table(): null | HTMLTableElement { + return this._table; + } + + private onFocusin = (event: FocusEvent) => { + this.focusInside = true; + + if (!(event.target instanceof HTMLElement)) { + return; + } + + this.updateFocusedCell(event.target); + if (!this.focusedCell) { + return; + } + + this._navigationAPI.current?.updateFocusTarget(); + + // Focusing on cell is not eligible when it contains focusable elements in the content. + // If content focusables are available - move the focus to the first one. + const focusedElement = this.focusedCell.element; + const nextTarget = isTableCell(focusedElement) ? this.getFocusablesFrom(focusedElement)[0] : null; + if (nextTarget) { + // Scroll is unnecessary when moving focus from a cell to element within the cell. + nextTarget.focus({ preventScroll: true }); + } else { + this.keepUserIndex = false; + } + }; + + private onFocusout = () => { + this.focusInside = false; + }; + + private onKeydown = (event: KeyboardEvent) => { + if (!this.focusedCell) { + return; + } + + const keys = [ + KeyCode.up, + KeyCode.down, + KeyCode.left, + KeyCode.right, + KeyCode.pageUp, + KeyCode.pageDown, + KeyCode.home, + KeyCode.end, + ]; + const ctrlKey = event.ctrlKey ? 1 : 0; + const altKey = event.altKey ? 1 : 0; + const shiftKey = event.shiftKey ? 1 : 0; + const metaKey = event.metaKey ? 1 : 0; + const modifiersPressed = ctrlKey + altKey + shiftKey + metaKey; + const invalidModifierCombination = + (modifiersPressed && !event.ctrlKey) || + (event.ctrlKey && event.keyCode !== KeyCode.home && event.keyCode !== KeyCode.end); + + if ( + invalidModifierCombination || + this.isElementSuppressed(document.activeElement) || + !this.isRegistered(document.activeElement) || + keys.indexOf(event.keyCode) === -1 + ) { + return; + } + + const from = this.focusedCell; + event.preventDefault(); + + if (isEventLike(event)) { + handleKey(event, { + onBlockStart: () => this.moveFocusBy(from, { y: -1, x: 0 }), + onBlockEnd: () => this.moveFocusBy(from, { y: 1, x: 0 }), + onInlineStart: () => this.moveFocusBy(from, { y: 0, x: -1 }), + onInlineEnd: () => this.moveFocusBy(from, { y: 0, x: 1 }), + onPageUp: () => this.moveFocusBy(from, { y: -this.pageSize, x: 0 }), + onPageDown: () => this.moveFocusBy(from, { y: this.pageSize, x: 0 }), + onHome: () => + event.ctrlKey + ? this.moveFocusBy(from, { y: -Infinity, x: -Infinity }) + : this.moveFocusBy(from, { y: 0, x: -Infinity }), + onEnd: () => + event.ctrlKey + ? this.moveFocusBy(from, { y: Infinity, x: Infinity }) + : this.moveFocusBy(from, { y: 0, x: Infinity }), + }); + } + }; + + private moveFocusBy(cell: FocusedCell, delta: { x: number; y: number }) { + // For vertical moves preserve column- and element indices set by user. + // It allows keeping indices while moving over disabled actions or cells with colspan > 1. + if (delta.y !== 0 && delta.x === 0) { + this.keepUserIndex = true; + } + focusNextElement(this.getNextFocusable(cell, delta)); + } + + private isRegistered(element: null | Element): boolean { + return !element || (this._navigationAPI.current?.isRegistered(element) ?? false); + } + + private updateFocusedCell(focusedElement?: HTMLElement): void { + if (!focusedElement) { + return; + } + + const cellElement = getClosestCell(focusedElement); + const rowElement = cellElement?.closest('tr'); + if (!cellElement || !rowElement) { + return; + } + + const colIndex = parseInt(cellElement.getAttribute('aria-colindex') ?? ''); + const rowIndex = parseInt(rowElement.getAttribute('aria-rowindex') ?? ''); + if (isNaN(colIndex) || isNaN(rowIndex)) { + return; + } + + const cellFocusables = this.getFocusablesFrom(cellElement); + const elementIndex = cellFocusables.indexOf(focusedElement); + + const prevColIndex = this.focusedCell?.colIndex ?? -1; + const prevElementIndex = this.focusedCell?.elementIndex ?? -1; + this.focusedCell = { + rowIndex, + colIndex: this.keepUserIndex && prevColIndex !== -1 ? prevColIndex : colIndex, + elementIndex: this.keepUserIndex && prevElementIndex !== -1 ? prevElementIndex : elementIndex, + element: focusedElement, + }; + } + + private getNextFocusable(from: FocusedCell, delta: { y: number; x: number }) { + // Find next row to move focus into (can be null if the top/bottom is reached). + const targetAriaRowIndex = from.rowIndex + delta.y; + const targetRow = findTableRowByAriaRowIndex(this.table, targetAriaRowIndex, delta.y); + if (!targetRow) { + return null; + } + + // Return next interactive cell content element if available. + const cellElement = getClosestCell(from.element); + const cellFocusables = cellElement ? this.getFocusablesFrom(cellElement) : []; + const nextElementIndex = from.elementIndex + delta.x; + const isValidDirection = !!delta.x; + const isValidIndex = from.elementIndex !== -1 && 0 <= nextElementIndex && nextElementIndex < cellFocusables.length; + const isTargetDifferent = from.element !== cellFocusables[nextElementIndex]; + if (isValidDirection && isValidIndex && isTargetDifferent) { + return cellFocusables[nextElementIndex]; + } + + // Find next cell to focus or move focus into. + const targetAriaColIndex = from.colIndex + delta.x; + + const targetCell = this.table + ? findNextCell(this.table, targetRow, targetAriaColIndex, delta, cellElement as HTMLTableCellElement | null) + : null; + if (!targetCell) { + return null; + } + + const targetCellFocusables = this.getFocusablesFrom(targetCell); + + // When delta.x = 0 keep element index if possible. + let focusIndex = from.elementIndex; + // Use first element index when moving to the right or to extreme left. + if ((isFinite(delta.x) && delta.x > 0) || delta.x === -Infinity) { + focusIndex = 0; + } + // Use last element index when moving to the left or to extreme right. + if ((isFinite(delta.x) && delta.x < 0) || delta.x === Infinity) { + focusIndex = targetCellFocusables.length - 1; + } + + return targetCellFocusables[focusIndex] ?? targetCell; + } + + private getFocusablesFrom(target: HTMLElement) { + const isElementRegistered = (element: Element) => this._navigationAPI.current?.isRegistered(element); + return getAllFocusables(target).filter(el => isElementRegistered(el) && !isElementDisabled(el)); + } +} diff --git a/src/internal/table-role/index.ts b/src/internal/table-role/index.ts new file mode 100644 index 0000000..6c59656 --- /dev/null +++ b/src/internal/table-role/index.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type { TableRole } from './interfaces.js'; + +export { + getTableCellRoleProps, + getTableColHeaderRoleProps, + getTableHeaderRowRoleProps, + getTableRoleProps, + getTableRowRoleProps, + getTableWrapperRoleProps, +} from './table-role-helper.js'; + +export { GridNavigationProvider } from './grid-navigation.js'; diff --git a/src/internal/table-role/interfaces.ts b/src/internal/table-role/interfaces.ts new file mode 100644 index 0000000..c3a02a9 --- /dev/null +++ b/src/internal/table-role/interfaces.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type React from 'react'; + +export type TableRole = 'table' | 'grid' | 'treegrid' | 'grid-default'; + +export interface GridNavigationProps { + keyboardNavigation: boolean; + pageSize: number; + getTable: () => null | HTMLTableElement; + children: React.ReactNode; +} + +export interface FocusedCell { + rowIndex: number; + colIndex: number; + elementIndex: number; + element: HTMLElement; +} diff --git a/src/internal/table-role/table-role-helper.ts b/src/internal/table-role/table-role-helper.ts new file mode 100644 index 0000000..06e2c9c --- /dev/null +++ b/src/internal/table-role/table-role-helper.ts @@ -0,0 +1,151 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type React from 'react'; + +import type { TableRole } from './interfaces.js'; + +type SortingStatus = 'sortable' | 'ascending' | 'descending'; + +const stateToAriaSort = { + sortable: 'none', + ascending: 'ascending', + descending: 'descending', +} as const; +const getAriaSort = (sortingState: SortingStatus) => stateToAriaSort[sortingState]; + +// Depending on its content the table can have different semantic representation which includes the +// ARIA role of the table component ("table", "grid", "treegrid") but also roles and other semantic attributes +// of the child elements. The TableRole helper encapsulates table's semantic structure. + +export function getTableRoleProps(options: { + tableRole: TableRole; + ariaLabel?: string; + ariaLabelledby?: string; + totalItemsCount?: number; + totalColumnsCount?: number; + headerRowCount?: number; +}): React.TableHTMLAttributes { + const nativeProps: React.TableHTMLAttributes = {}; + + // Browsers have weird mechanism to guess whether it's a data table or a layout table. + // If we state explicitly, they get it always correctly even with low number of rows. + nativeProps.role = options.tableRole === 'grid-default' ? 'grid' : options.tableRole; + + nativeProps['aria-label'] = options.ariaLabel; + nativeProps['aria-labelledby'] = options.ariaLabelledby; + + // Incrementing the total count by one to account for the header row. + const headerRows = options.headerRowCount ?? 1; + if (typeof options.totalItemsCount === 'number' && options.totalItemsCount > 0) { + nativeProps['aria-rowcount'] = options.totalItemsCount + headerRows; + } + + if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { + nativeProps['aria-colcount'] = options.totalColumnsCount; + } + + // Make table component programmatically focusable to attach focusin/focusout for keyboard navigation. + if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { + nativeProps.tabIndex = -1; + } + + return nativeProps; +} + +export function getTableWrapperRoleProps(options: { + tableRole: TableRole; + isScrollable: boolean; + ariaLabel?: string; + ariaLabelledby?: string; +}) { + const nativeProps: React.HTMLAttributes = {}; + + // When the table is scrollable, the wrapper is made focusable so that keyboard users can scroll it horizontally with arrow keys. + if (options.isScrollable) { + nativeProps.role = 'region'; + nativeProps.tabIndex = 0; + nativeProps['aria-label'] = options.ariaLabel; + nativeProps['aria-labelledby'] = options.ariaLabelledby; + } + + return nativeProps; +} + +export function getTableHeaderRowRoleProps(options: { tableRole: TableRole; rowIndex?: number }) { + const nativeProps: React.HTMLAttributes = {}; + + // For grids headers are treated similar to data rows and are indexed accordingly. + if (options.tableRole === 'grid' || options.tableRole === 'grid-default' || options.tableRole === 'treegrid') { + nativeProps['aria-rowindex'] = (options.rowIndex ?? 0) + 1; + } + + return nativeProps; +} + +export function getTableRowRoleProps(options: { + tableRole: TableRole; + rowIndex: number; + firstIndex?: number; + headerRowCount?: number; + level?: number; + setSize?: number; + posInSet?: number; +}) { + const nativeProps: React.HTMLAttributes = {}; + + // The data cell indices are incremented by 1 to account for the header cells. + const headerRows = options.headerRowCount ?? 1; + if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { + nativeProps['aria-rowindex'] = (options.firstIndex || 1) + options.rowIndex + headerRows; + } + // For tables indices are only added when the first index is not 0 (not the first page/frame). + else if (options.firstIndex !== undefined) { + nativeProps['aria-rowindex'] = options.firstIndex + options.rowIndex + headerRows; + } + if (options.tableRole === 'treegrid' && options.level && options.level !== 0) { + nativeProps['aria-level'] = options.level; + } + if (options.tableRole === 'treegrid' && options.setSize) { + nativeProps['aria-setsize'] = options.setSize; + } + if (options.tableRole === 'treegrid' && options.posInSet) { + nativeProps['aria-posinset'] = options.posInSet; + } + + return nativeProps; +} + +export function getTableColHeaderRoleProps(options: { + tableRole: TableRole; + colIndex: number; + sortingStatus?: SortingStatus; +}) { + const nativeProps: React.ThHTMLAttributes = {}; + + nativeProps.scope = 'col'; + + if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { + nativeProps['aria-colindex'] = options.colIndex + 1; + } + + if (options.sortingStatus) { + nativeProps['aria-sort'] = getAriaSort(options.sortingStatus); + } + + return nativeProps; +} + +export function getTableCellRoleProps(options: { tableRole: TableRole; colIndex: number; isRowHeader?: boolean }) { + const nativeProps: React.TdHTMLAttributes = {}; + + if (options.tableRole === 'grid' || options.tableRole === 'treegrid') { + nativeProps['aria-colindex'] = options.colIndex + 1; + } + + if (options.isRowHeader) { + nativeProps.scope = 'row'; + } + + return nativeProps; +} diff --git a/src/internal/table-role/utils.ts b/src/internal/table-role/utils.ts new file mode 100644 index 0000000..5902e0f --- /dev/null +++ b/src/internal/table-role/utils.ts @@ -0,0 +1,206 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function getClosestCell(element: Element) { + return element.closest('td,th') as null | HTMLTableCellElement; +} + +export function isElementDisabled(element: HTMLElement) { + if (element instanceof HTMLInputElement || element instanceof HTMLButtonElement) { + return element.disabled; + } + return false; +} + +/** + * Returns true if the target element or one of its parents is a dialog or is marked with data-awsui-table-suppress-navigation attribute. + * This is used to suppress navigation for interactive content without a need to use a custom suppression check. + */ +export function defaultIsSuppressed(target: Element) { + let current: null | Element = target; + while (current) { + // Stop checking for parents upon reaching the cell element as the function only aims at the cell content. + if (isTableCell(current)) { + return false; + } + if ( + current.getAttribute('role') === 'dialog' || + current.getAttribute('data-awsui-table-suppress-navigation') === 'true' + ) { + return true; + } + current = current.parentElement; + } + return false; +} + +/** + * Finds the closest row to the targetAriaRowIndex+delta in the direction of delta. + */ +export function findTableRowByAriaRowIndex(table: null | HTMLTableElement, targetAriaRowIndex: number, delta: number) { + let targetRow: null | HTMLTableRowElement = null; + const rowElements = Array.from(table?.querySelectorAll('tr[aria-rowindex]') ?? []); + if (delta < 0) { + rowElements.reverse(); + } + for (const element of rowElements) { + const rowIndex = parseInt(element.getAttribute('aria-rowindex') ?? ''); + targetRow = element as HTMLTableRowElement; + + if (rowIndex === targetAriaRowIndex) { + break; + } + if (delta >= 0 && rowIndex > targetAriaRowIndex) { + break; + } + if (delta < 0 && rowIndex < targetAriaRowIndex) { + break; + } + } + return targetRow; +} + +/** + * Finds the closest column to the targetAriaColIndex+delta in the direction of delta. + */ +export function findTableRowCellByAriaColIndex( + tableRow: HTMLTableRowElement, + targetAriaColIndex: number, + delta: number +) { + const cellElements = Array.from( + tableRow.querySelectorAll('td[aria-colindex],th[aria-colindex]') + ); + return findClosestCellByAriaColIndex(cellElements, targetAriaColIndex, delta); +} + +/** + * Collects all cells visually present in a row, including cells from earlier rows + * that span into this row via rowspan. This is needed because cells with rowspan > 1 + * are only in one in the DOM but visually occupy multiple rows. + */ +export function getAllCellsInRow(table: HTMLTableElement, targetAriaRowIndex: number): HTMLTableCellElement[] { + const cells: HTMLTableCellElement[] = []; + const rows = table.querySelectorAll('tr[aria-rowindex]'); + + for (const row of Array.from(rows)) { + const rowIndex = parseInt(row.getAttribute('aria-rowindex') ?? ''); + if (isNaN(rowIndex) || rowIndex > targetAriaRowIndex) { + continue; + } + + const rowCells = row.querySelectorAll('td[aria-colindex],th[aria-colindex]'); + for (const cell of Array.from(rowCells)) { + const rowspan = cell.rowSpan || 1; + // Cell is visible in target row if: rowIndex <= targetAriaRowIndex < rowIndex + rowspan + if (rowIndex + rowspan > targetAriaRowIndex) { + cells.push(cell); + } + } + } + + return cells; +} + +/** + * From a list of cell elements, find the closest one to targetAriaColIndex in the direction of delta. + * Accounts for colspan: a cell with colindex=2 and colspan=4 covers columns 2,3,4,5. + */ +export function findClosestCellByAriaColIndex( + cellElements: HTMLTableCellElement[], + targetAriaColIndex: number, + delta: number +): HTMLTableCellElement | null { + // First check if any cell's colspan range covers the target exactly. + for (const element of cellElements) { + const colIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); + const colspan = element.colSpan || 1; + if (colIndex <= targetAriaColIndex && targetAriaColIndex < colIndex + colspan) { + return element; + } + } + + // Otherwise find the closest cell in the direction of delta. + let targetCell: null | HTMLTableCellElement = null; + const sorted = [...cellElements].sort((a, b) => { + const aIdx = parseInt(a.getAttribute('aria-colindex') ?? '0'); + const bIdx = parseInt(b.getAttribute('aria-colindex') ?? '0'); + return aIdx - bIdx; + }); + if (delta < 0) { + sorted.reverse(); + } + for (const element of sorted) { + const columnIndex = parseInt(element.getAttribute('aria-colindex') ?? ''); + targetCell = element; + + if (delta >= 0 && columnIndex > targetAriaColIndex) { + break; + } + if (delta < 0 && columnIndex < targetAriaColIndex) { + break; + } + } + return targetCell; +} + +/** + * Finds the next cell to navigate to, handling colspan and rowspan for grouped columns. + * Skips past the current cell when movement lands on it due to span attributes. + */ +export function findNextCell( + table: HTMLTableElement, + targetRow: HTMLTableRowElement, + targetAriaColIndex: number, + delta: { x: number; y: number }, + currentCell: HTMLTableCellElement | null +): HTMLTableCellElement | null { + const targetRowAriaIndex = parseInt(targetRow.getAttribute('aria-rowindex') ?? ''); + let allVisibleCells = getAllCellsInRow(table, targetRowAriaIndex); + let targetCell = findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x); + + // When vertical movement lands on the same cell (due to rowspan), skip past it. + if (targetCell === currentCell && delta.y !== 0 && currentCell) { + const cellRow = currentCell.closest('tr'); + const cellRowIndex = parseInt(cellRow?.getAttribute('aria-rowindex') ?? '0'); + const cellRowSpan = currentCell.rowSpan || 1; + const skipToRowIndex = delta.y > 0 ? cellRowIndex + cellRowSpan : cellRowIndex - 1; + const skipRow = findTableRowByAriaRowIndex(table, skipToRowIndex, delta.y); + if (!skipRow) { + return null; + } + const skipRowAriaIndex = parseInt(skipRow.getAttribute('aria-rowindex') ?? ''); + allVisibleCells = getAllCellsInRow(table, skipRowAriaIndex); + targetCell = findClosestCellByAriaColIndex(allVisibleCells, targetAriaColIndex, delta.x); + } + + // When horizontal movement lands on the same cell (due to colspan), skip past it. + if (targetCell === currentCell && delta.x !== 0 && currentCell) { + const cellColIndex = parseInt(currentCell.getAttribute('aria-colindex') ?? '0'); + const cellColSpan = currentCell.colSpan || 1; + const skipToColIndex = delta.x > 0 ? cellColIndex + cellColSpan : cellColIndex - 1; + targetCell = findClosestCellByAriaColIndex(allVisibleCells, skipToColIndex, delta.x); + if (!targetCell || targetCell === currentCell) { + return null; + } + } + + return targetCell; +} + +export function isTableCell(element: Element) { + return element.tagName === 'TD' || element.tagName === 'TH'; +} + +export function focusNextElement(element: null | HTMLElement) { + if (element) { + // Table cells are not focusable by default (tabIndex=undefined) so cell.focus() is ignored. + // To force focusing we have to imperatively set tabIndex to -1. This tabIndex is then to be + // overridden by the single tab stop context to be 0 or undefined. + // We cannot make cells have tabIndex=-1 by default due to an associated bug with text selection, see: PR 2158. + if (isTableCell(element) && element.tabIndex !== 0) { + element.tabIndex = -1; + } + element.focus(); + } +} diff --git a/tsconfig.unit.json b/tsconfig.unit.json index 08dac40..5114270 100644 --- a/tsconfig.unit.json +++ b/tsconfig.unit.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "lib": ["es2021", "dom", "dom.iterable"], "types": ["node", "jest", "@testing-library/jest-dom"], "downlevelIteration": true, "noEmit": true,