From 1783e9c23c036ed8a305201eeaf615a9a34097e5 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 29 Jul 2026 14:11:42 +0000 Subject: [PATCH 1/6] Copy table-role and async-store verbatim from cloudscape-design/components@main Pure relocation of src/table/table-role/* and src/area-chart/async-store/* (+ use-previous dep and render-hook test helper), original content and import paths (does not yet satisfy component-toolkit lint by design). --- .../async-store/__tests__/async-store.test.ts | 78 ++++ .../async-store/__tests__/render-hook.tsx | 44 ++ src/internal/async-store/index.ts | 90 ++++ src/internal/async-store/use-previous.ts | 14 + .../grid-navigation-processor.test.tsx | 24 ++ .../__tests__/grid-navigation.test.tsx | 399 ++++++++++++++++++ src/internal/table-role/__tests__/stubs.tsx | 127 ++++++ .../__tests__/table-role-helper.test.ts | 119 ++++++ .../table-role/__tests__/utils.test.ts | 150 +++++++ src/internal/table-role/grid-navigation.md | 46 ++ src/internal/table-role/grid-navigation.tsx | 363 ++++++++++++++++ src/internal/table-role/index.ts | 15 + src/internal/table-role/interfaces.ts | 18 + src/internal/table-role/table-role-helper.ts | 149 +++++++ src/internal/table-role/utils.ts | 206 +++++++++ 15 files changed, 1842 insertions(+) create mode 100644 src/internal/async-store/__tests__/async-store.test.ts create mode 100644 src/internal/async-store/__tests__/render-hook.tsx create mode 100644 src/internal/async-store/index.ts create mode 100644 src/internal/async-store/use-previous.ts create mode 100644 src/internal/table-role/__tests__/grid-navigation-processor.test.tsx create mode 100644 src/internal/table-role/__tests__/grid-navigation.test.tsx create mode 100644 src/internal/table-role/__tests__/stubs.tsx create mode 100644 src/internal/table-role/__tests__/table-role-helper.test.ts create mode 100644 src/internal/table-role/__tests__/utils.test.ts create mode 100644 src/internal/table-role/grid-navigation.md create mode 100644 src/internal/table-role/grid-navigation.tsx create mode 100644 src/internal/table-role/index.ts create mode 100644 src/internal/table-role/interfaces.ts create mode 100644 src/internal/table-role/table-role-helper.ts create mode 100644 src/internal/table-role/utils.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..51eaa61 --- /dev/null +++ b/src/internal/async-store/__tests__/async-store.test.ts @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { act, renderHook } from '../../../__tests__/render-hook'; +import AsyncStore, { useReaction, useSelector } from '../index'; + +describe('AreaChart 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..ebeb184 --- /dev/null +++ b/src/internal/async-store/__tests__/render-hook.tsx @@ -0,0 +1,44 @@ +// 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'; + +/** + * This file is a port of `renderHook` from @testing-library/react lib + * The API is only available in v13+, which also requires React v18 + */ + +export interface RenderHookOptions { + initialProps?: Props; + wrapper?: React.JSXElementConstructor<{ children: React.ReactElement }>; +} + +export function renderHook( + renderCallback: (initialProps: Props) => Result, + options: RenderHookOptions = {} +) { + const { initialProps, wrapper } = options; + const result = React.createRef() as React.MutableRefObject; + + function TestComponent({ renderCallbackProps }: { renderCallbackProps: Props }) { + const pendingResult = renderCallback(renderCallbackProps); + + React.useEffect(() => { + result.current = pendingResult; + }); + + return null; + } + + const { rerender: baseRerender, unmount } = render(, { + wrapper, + }); + + function rerender(rerenderCallbackProps: Props) { + return baseRerender(); + } + + 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..6e7328e --- /dev/null +++ b/src/internal/async-store/index.ts @@ -0,0 +1,90 @@ +// 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 '../../internal/hooks/use-previous'; + +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..bd3b763 --- /dev/null +++ b/src/internal/async-store/use-previous.ts @@ -0,0 +1,14 @@ +// 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..f773f98 --- /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 '../../../../lib/components/table/table-role/grid-navigation'; + +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..791b520 --- /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 '../../../../lib/components/internal/keycode'; +import { GridNavigationProvider } from '../../../../lib/components/table/table-role'; +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..ab6eef4 --- /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 '@cloudscape-design/component-toolkit/internal'; + +import { GridNavigationProvider } from '../../../../lib/components/table/table-role'; + +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..e419d2e --- /dev/null +++ b/src/internal/table-role/grid-navigation.tsx @@ -0,0 +1,363 @@ +// 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 '@cloudscape-design/component-toolkit/internal'; +import { + SingleTabStopNavigationAPI, + SingleTabStopNavigationProvider, +} from '@cloudscape-design/component-toolkit/internal'; + +import { getAllFocusables } from '../../internal/components/focus-lock/utils'; +import { KeyCode } from '../../internal/keycode'; +import handleKey, { isEventLike } from '../../internal/utils/handle-key'; +import { nodeBelongs } from '../../internal/utils/node-belongs'; +import { FocusedCell, GridNavigationProps } from './interfaces'; +import { + defaultIsSuppressed, + findNextCell, + findTableRowByAriaRowIndex, + focusNextElement, + getClosestCell, + isElementDisabled, + isTableCell, +} from './utils'; + +/** + * Makes table navigable with keyboard commands. + * See grid-navigation.md + */ +export function GridNavigationProvider({ keyboardNavigation, pageSize, getTable, children }: GridNavigationProps) { + 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..ece6852 --- /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 { TableRole } from './interfaces'; + +export { + getTableCellRoleProps, + getTableColHeaderRoleProps, + getTableHeaderRowRoleProps, + getTableRoleProps, + getTableRowRoleProps, + getTableWrapperRoleProps, +} from './table-role-helper'; + +export { GridNavigationProvider } from './grid-navigation'; diff --git a/src/internal/table-role/interfaces.ts b/src/internal/table-role/interfaces.ts new file mode 100644 index 0000000..23df4cf --- /dev/null +++ b/src/internal/table-role/interfaces.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +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..ac07484 --- /dev/null +++ b/src/internal/table-role/table-role-helper.ts @@ -0,0 +1,149 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { TableRole } from './interfaces'; + +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(); + } +} From 72a67f9988364566396beb15fe1742f887747950 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 29 Jul 2026 14:11:42 +0000 Subject: [PATCH 2/6] Adapt for component-toolkit: rewrite imports, add exports, lint conformance --- package.json | 8 +++++ .../async-store/__tests__/async-store.test.ts | 5 ++-- .../async-store/__tests__/render-hook.tsx | 29 +++++-------------- src/internal/async-store/index.ts | 3 +- src/internal/async-store/use-previous.ts | 1 + .../grid-navigation-processor.test.tsx | 2 +- .../__tests__/grid-navigation.test.tsx | 4 +-- src/internal/table-role/__tests__/stubs.tsx | 4 +-- .../__tests__/table-role-helper.test.ts | 2 +- .../table-role/__tests__/utils.test.ts | 3 +- src/internal/table-role/grid-navigation.tsx | 28 +++++++++--------- src/internal/table-role/index.ts | 6 ++-- src/internal/table-role/table-role-helper.ts | 2 +- tsconfig.unit.json | 1 + 14 files changed, 49 insertions(+), 49 deletions(-) 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 index 51eaa61..8205217 100644 --- a/src/internal/async-store/__tests__/async-store.test.ts +++ b/src/internal/async-store/__tests__/async-store.test.ts @@ -1,7 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { act, renderHook } from '../../../__tests__/render-hook'; -import AsyncStore, { useReaction, useSelector } from '../index'; + +import { act, renderHook } from './render-hook'; +import AsyncStore, { useReaction, useSelector } from '../index.js'; describe('AreaChart AsyncStore', () => { it('notifies listeners when selected state is updated', () => { diff --git a/src/internal/async-store/__tests__/render-hook.tsx b/src/internal/async-store/__tests__/render-hook.tsx index ebeb184..a6a272d 100644 --- a/src/internal/async-store/__tests__/render-hook.tsx +++ b/src/internal/async-store/__tests__/render-hook.tsx @@ -1,29 +1,20 @@ // 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'; /** - * This file is a port of `renderHook` from @testing-library/react lib - * The API is only available in v13+, which also requires React v18 + * Minimal port of `renderHook` from @testing-library/react (native version needs v13+). + * Supports the no-options usage exercised by the async-store tests. */ - -export interface RenderHookOptions { - initialProps?: Props; - wrapper?: React.JSXElementConstructor<{ children: React.ReactElement }>; -} - -export function renderHook( - renderCallback: (initialProps: Props) => Result, - options: RenderHookOptions = {} -) { - const { initialProps, wrapper } = options; +export function renderHook(renderCallback: () => Result) { const result = React.createRef() as React.MutableRefObject; - function TestComponent({ renderCallbackProps }: { renderCallbackProps: Props }) { - const pendingResult = renderCallback(renderCallbackProps); + function TestComponent() { + const pendingResult = renderCallback(); React.useEffect(() => { result.current = pendingResult; @@ -32,13 +23,7 @@ export function renderHook( return null; } - const { rerender: baseRerender, unmount } = render(, { - wrapper, - }); - - function rerender(rerenderCallbackProps: Props) { - return baseRerender(); - } + const { rerender, unmount } = render(); return { result, rerender, unmount }; } diff --git a/src/internal/async-store/index.ts b/src/internal/async-store/index.ts index 6e7328e..316a220 100644 --- a/src/internal/async-store/index.ts +++ b/src/internal/async-store/index.ts @@ -1,9 +1,10 @@ // 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 '../../internal/hooks/use-previous'; +import { usePrevious } from './use-previous.js'; type Selector = (state: S) => R; type Listener = (state: S, prevState: S) => void; diff --git a/src/internal/async-store/use-previous.ts b/src/internal/async-store/use-previous.ts index bd3b763..388dcc8 100644 --- a/src/internal/async-store/use-previous.ts +++ b/src/internal/async-store/use-previous.ts @@ -1,5 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + import { useEffect, useRef } from 'react'; /** diff --git a/src/internal/table-role/__tests__/grid-navigation-processor.test.tsx b/src/internal/table-role/__tests__/grid-navigation-processor.test.tsx index f773f98..9038071 100644 --- a/src/internal/table-role/__tests__/grid-navigation-processor.test.tsx +++ b/src/internal/table-role/__tests__/grid-navigation-processor.test.tsx @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { GridNavigationProcessor } from '../../../../lib/components/table/table-role/grid-navigation'; +import { GridNavigationProcessor } from '../grid-navigation.js'; describe('GridNavigationProcessor', () => { test('does not throw when not initialized', () => { diff --git a/src/internal/table-role/__tests__/grid-navigation.test.tsx b/src/internal/table-role/__tests__/grid-navigation.test.tsx index 791b520..28f0f65 100644 --- a/src/internal/table-role/__tests__/grid-navigation.test.tsx +++ b/src/internal/table-role/__tests__/grid-navigation.test.tsx @@ -4,8 +4,8 @@ import React, { useRef } from 'react'; import { fireEvent, render, waitFor } from '@testing-library/react'; -import { KeyCode } from '../../../../lib/components/internal/keycode'; -import { GridNavigationProvider } from '../../../../lib/components/table/table-role'; +import { KeyCode } from '../../keycode.js'; +import { GridNavigationProvider } from '../index.js'; import { actionsColumn, Button, Cell, idColumn, items, nameColumn, TestTable, valueColumn } from './stubs'; function readActiveElement() { diff --git a/src/internal/table-role/__tests__/stubs.tsx b/src/internal/table-role/__tests__/stubs.tsx index ab6eef4..a8d9f71 100644 --- a/src/internal/table-role/__tests__/stubs.tsx +++ b/src/internal/table-role/__tests__/stubs.tsx @@ -3,9 +3,9 @@ import React, { useRef, useState } from 'react'; -import { useSingleTabStopNavigation } from '@cloudscape-design/component-toolkit/internal'; +import { useSingleTabStopNavigation } from '../../single-tab-stop/index.js'; -import { GridNavigationProvider } from '../../../../lib/components/table/table-role'; +import { GridNavigationProvider } from '../index.js'; export interface Item { id: string; diff --git a/src/internal/table-role/__tests__/table-role-helper.test.ts b/src/internal/table-role/__tests__/table-role-helper.test.ts index 16937d3..7ae40a4 100644 --- a/src/internal/table-role/__tests__/table-role-helper.test.ts +++ b/src/internal/table-role/__tests__/table-role-helper.test.ts @@ -8,7 +8,7 @@ import { getTableRoleProps, getTableRowRoleProps, getTableWrapperRoleProps, -} from '../../../../lib/components/table/table-role'; +} from '../index.js'; test('non-scrollable table props', () => { const tableRole = 'table'; diff --git a/src/internal/table-role/__tests__/utils.test.ts b/src/internal/table-role/__tests__/utils.test.ts index 268dcbe..e15a3bf 100644 --- a/src/internal/table-role/__tests__/utils.test.ts +++ b/src/internal/table-role/__tests__/utils.test.ts @@ -1,11 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + import { findClosestCellByAriaColIndex, findNextCell, findTableRowCellByAriaColIndex, getAllCellsInRow, -} from '../utils'; +} from '../utils.js'; function createCell(colIndex: number, colspan = 1, rowspan = 1): HTMLTableCellElement { const cell = document.createElement('td'); diff --git a/src/internal/table-role/grid-navigation.tsx b/src/internal/table-role/grid-navigation.tsx index e419d2e..c898838 100644 --- a/src/internal/table-role/grid-navigation.tsx +++ b/src/internal/table-role/grid-navigation.tsx @@ -4,17 +4,14 @@ import React, { useRef } from 'react'; import { useEffect, useMemo } from 'react'; -import { useStableCallback } from '@cloudscape-design/component-toolkit/internal'; -import { - SingleTabStopNavigationAPI, - SingleTabStopNavigationProvider, -} from '@cloudscape-design/component-toolkit/internal'; - -import { getAllFocusables } from '../../internal/components/focus-lock/utils'; -import { KeyCode } from '../../internal/keycode'; -import handleKey, { isEventLike } from '../../internal/utils/handle-key'; -import { nodeBelongs } from '../../internal/utils/node-belongs'; -import { FocusedCell, GridNavigationProps } from './interfaces'; +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, @@ -23,13 +20,18 @@ import { getClosestCell, isElementDisabled, isTableCell, -} from './utils'; +} from './utils.js'; /** * Makes table navigable with keyboard commands. * See grid-navigation.md */ -export function GridNavigationProvider({ keyboardNavigation, pageSize, getTable, children }: GridNavigationProps) { +export function GridNavigationProvider({ + keyboardNavigation, + pageSize, + getTable, + children, +}: GridNavigationProps): React.ReactElement { const navigationAPI = useRef(null); const gridNavigation = useMemo(() => new GridNavigationProcessor(navigationAPI), []); diff --git a/src/internal/table-role/index.ts b/src/internal/table-role/index.ts index ece6852..1b960e6 100644 --- a/src/internal/table-role/index.ts +++ b/src/internal/table-role/index.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { TableRole } from './interfaces'; +export { TableRole } from './interfaces.js'; export { getTableCellRoleProps, @@ -10,6 +10,6 @@ export { getTableRoleProps, getTableRowRoleProps, getTableWrapperRoleProps, -} from './table-role-helper'; +} from './table-role-helper.js'; -export { GridNavigationProvider } from './grid-navigation'; +export { GridNavigationProvider } from './grid-navigation.js'; diff --git a/src/internal/table-role/table-role-helper.ts b/src/internal/table-role/table-role-helper.ts index ac07484..87b9c92 100644 --- a/src/internal/table-role/table-role-helper.ts +++ b/src/internal/table-role/table-role-helper.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { TableRole } from './interfaces'; +import { TableRole } from './interfaces.js'; type SortingStatus = 'sortable' | 'ascending' | 'descending'; 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, From 1bef45efab79db250adc7c3a50f41378c92719cb Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 29 Jul 2026 14:43:24 +0000 Subject: [PATCH 3/6] test: cover windowed aria-rowindex branch (firstIndex) in table-role-helper --- .../table-role/__tests__/table-role-helper.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/internal/table-role/__tests__/table-role-helper.test.ts b/src/internal/table-role/__tests__/table-role-helper.test.ts index 7ae40a4..cbfcdde 100644 --- a/src/internal/table-role/__tests__/table-role-helper.test.ts +++ b/src/internal/table-role/__tests__/table-role-helper.test.ts @@ -90,6 +90,14 @@ test('table row and cell props', () => { expect(bodyCell2).toEqual({}); }); +test('table body row includes aria-rowindex only when windowed (firstIndex set)', () => { + const tableRole = 'table'; + // First frame (no firstIndex): plain tables omit aria-rowindex. + expect(getTableRowRoleProps({ tableRole, rowIndex: 0 })).toEqual({}); + // Windowed frame: aria-rowindex = firstIndex + rowIndex + headerRows(1). + expect(getTableRowRoleProps({ tableRole, rowIndex: 2, firstIndex: 100 })).toEqual({ 'aria-rowindex': 103 }); +}); + test.each(['grid', 'treegrid'] as const)('%s row and cell props', tableRole => { const headerRow = getTableHeaderRowRoleProps({ tableRole }); const headerCell1 = getTableColHeaderRoleProps({ tableRole, colIndex: 0, sortingStatus: 'ascending' }); From 73fd42d2f6f6f21a394dff75be615d332d454f56 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 30 Jul 2026 09:13:04 +0000 Subject: [PATCH 4/6] chore: address Copilot review (type-only TableRole export + Page Up doc typo) --- src/internal/table-role/grid-navigation.md | 2 +- src/internal/table-role/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internal/table-role/grid-navigation.md b/src/internal/table-role/grid-navigation.md index 65b2e0e..a826793 100644 --- a/src/internal/table-role/grid-navigation.md +++ b/src/internal/table-role/grid-navigation.md @@ -13,7 +13,7 @@ The list of supported navigation commands is: - `Down Arrow`: Moves focus one cell down. The focus does not move if it is already on the bottom row. When the focus is on the nth element inside a cell the element index is maintained (when possible) when moving down. - `Up Arrow`: Moves focus one cell up. The focus does not move if it is already on the top row. When the focus is on the nth element inside a cell the element index is maintained (when possible) when moving up. - `Page Down`: Moves focus down by one page (the page size is set as 10). The focus does not move if it is already on the bottom row. When the focus is on the nth element inside a cell the element index is maintained (when possible) when moving down. -- `Page Up`: Moves focus up by one page (the page size is set as 10). The focus does not move if it is already on the top row. When the focus is on the nth element inside a cell the element index is maintained (when possible) when moving down. +- `Page Up`: Moves focus up by one page (the page size is set as 10). The focus does not move if it is already on the top row. When the focus is on the nth element inside a cell the element index is maintained (when possible) when moving up. - `Home`: moves focus to the first cell or element in the row. - `End`: moves focus to the last cell or element in the row. - `Control + Home`: moves focus to the first cell or element in the first row. diff --git a/src/internal/table-role/index.ts b/src/internal/table-role/index.ts index 1b960e6..6c59656 100644 --- a/src/internal/table-role/index.ts +++ b/src/internal/table-role/index.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { TableRole } from './interfaces.js'; +export type { TableRole } from './interfaces.js'; export { getTableCellRoleProps, From e45a6ae4a8c91b91dea7842584415ea349f371a0 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 30 Jul 2026 09:49:44 +0000 Subject: [PATCH 5/6] chore: add type-only React imports to table-role (Copilot review) --- src/internal/table-role/interfaces.ts | 2 ++ src/internal/table-role/table-role-helper.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/internal/table-role/interfaces.ts b/src/internal/table-role/interfaces.ts index 23df4cf..c3a02a9 100644 --- a/src/internal/table-role/interfaces.ts +++ b/src/internal/table-role/interfaces.ts @@ -1,6 +1,8 @@ // 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 { diff --git a/src/internal/table-role/table-role-helper.ts b/src/internal/table-role/table-role-helper.ts index 87b9c92..06e2c9c 100644 --- a/src/internal/table-role/table-role-helper.ts +++ b/src/internal/table-role/table-role-helper.ts @@ -1,7 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { TableRole } from './interfaces.js'; +import type React from 'react'; + +import type { TableRole } from './interfaces.js'; type SortingStatus = 'sortable' | 'ascending' | 'descending'; From b98e5a2061f3d6a0e09e708a7605c07c15cd33d4 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 30 Jul 2026 10:35:02 +0000 Subject: [PATCH 6/6] chore: rename async-store test describe (drop AreaChart reference) --- src/internal/async-store/__tests__/async-store.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/internal/async-store/__tests__/async-store.test.ts b/src/internal/async-store/__tests__/async-store.test.ts index 8205217..8a03047 100644 --- a/src/internal/async-store/__tests__/async-store.test.ts +++ b/src/internal/async-store/__tests__/async-store.test.ts @@ -4,7 +4,7 @@ import { act, renderHook } from './render-hook'; import AsyncStore, { useReaction, useSelector } from '../index.js'; -describe('AreaChart AsyncStore', () => { +describe('AsyncStore', () => { it('notifies listeners when selected state is updated', () => { const store = new AsyncStore({ a: 1, b: 2 });