diff --git a/src/area-chart/async-store/__tests__/async-store.test.ts b/src/area-chart/async-store/__tests__/async-store.test.ts deleted file mode 100644 index 51eaa617a9..0000000000 --- a/src/area-chart/async-store/__tests__/async-store.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -// 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/area-chart/async-store/index.ts b/src/area-chart/async-store/index.ts index 6e7328e848..82dd3d04cb 100644 --- a/src/area-chart/async-store/index.ts +++ b/src/area-chart/async-store/index.ts @@ -1,90 +1,6 @@ // 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; -} +// Moved to @cloudscape-design/component-toolkit (cloudscape-design/component-toolkit#242). +export { default } from '@cloudscape-design/component-toolkit/internal/async-store'; +export * from '@cloudscape-design/component-toolkit/internal/async-store'; diff --git a/src/table/table-role/__integ__/grid-navigation.test.ts b/src/table/table-role/__integ__/grid-navigation.test.ts deleted file mode 100644 index d0d4c89b5e..0000000000 --- a/src/table/table-role/__integ__/grid-navigation.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import range from 'lodash/range'; - -import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; - -import createWrapper from '../../../../lib/components/test-utils/selectors'; -import { GridNavigationPageObject } from './page-object'; - -interface Options { - actionsMode?: 'dropdown' | 'inline'; - visibleColumns?: string[]; -} - -const setupTest = ( - { actionsMode = 'dropdown', visibleColumns = ['id', 'actions', 'state', 'imageId', 'dnsName', 'type'] }: Options, - testFn: (page: GridNavigationPageObject) => Promise -) => { - return useBrowser(async browser => { - const page = new GridNavigationPageObject(browser); - const query = new URLSearchParams({ actionsMode, visibleColumns: visibleColumns.join(',') }); - await browser.url(`#/light/table-fragments/grid-navigation-custom/?${query.toString()}`); - await page.waitForVisible('table'); - await testFn(page); - }); -}; - -test( - 'cell action remains focused when row re-renders', - setupTest({ actionsMode: 'inline' }, async page => { - await page.click('button[aria-label="Update item"]:not([disabled])'); - await expect(page.isFocused('button[aria-label="Update item"]:not([disabled])')).resolves.toBe(true); - }) -); - -test( - 'cell focus stays in the same position when row gets removed', - setupTest({ actionsMode: 'inline' }, async page => { - await page.click('button[aria-label="Delete item"]'); - await expect(page.isFocused('button[aria-label="Delete item"]')).resolves.toBe(true); - }) -); - -test( - 'cell focus stays in the same position when row gets removed with auto-refresh', - setupTest({}, async page => { - const firstButtonDropdown = createWrapper().findButtonDropdown(); - await page.click(firstButtonDropdown.toSelector()); - await page.click(firstButtonDropdown.findHighlightedItem().toSelector()); - await expect(page.isFocused(firstButtonDropdown.findNativeButton().toSelector())).resolves.toBe(true); - - await page.refreshItems(); - await expect(page.isFocused(firstButtonDropdown.findNativeButton().toSelector())).resolves.toBe(true); - }) -); - -test( - 'keeps last focused cell position when row gets removed from outside table', - setupTest({}, async page => { - const firstButtonDropdown = createWrapper().findButtonDropdown(); - await page.click(firstButtonDropdown.toSelector()); - await page.click('button[aria-label="Refresh"]'); - await expect(page.isFocused('button[aria-label="Refresh"]')).resolves.toBe(true); - - await page.keys(['Tab', 'Tab']); - await expect(page.isFocused(firstButtonDropdown.findNativeButton().toSelector())).resolves.toBe(true); - }) -); - -test( - 'retains cell focus when existing inline edit', - setupTest({}, async page => { - await page.click('[aria-label="Edit DNS name"]'); - await page.click('button[aria-label="Save"]'); - await expect(page.isFocused('[aria-label="Edit DNS name"]')).resolves.toBe(true); - }) -); - -test( - 'table has a single tab stop', - setupTest({}, async page => { - await page.click('[data-testid="link-before"]'); - await page.keys('Tab'); - await expect(page.isFocused('tr[aria-rowindex="1"] > th[aria-colindex="1"] button')).resolves.toBe(true); - - await page.keys('Tab'); - await expect(page.isFocused('[data-testid="link-after"]')).resolves.toBe(true); - - await page.keys(['Shift', 'Tab', 'Null']); - await expect(page.isFocused('tr[aria-rowindex="1"] > th[aria-colindex="1"] button')).resolves.toBe(true); - - await page.keys(['ArrowRight', 'ArrowDown']); - await expect(page.isFocused('tr[aria-rowindex="2"] [aria-label="Item actions"]')).resolves.toBe(true); - - await page.keys('Tab'); - await expect(page.isFocused('[data-testid="link-after"]')).resolves.toBe(true); - - await page.keys(['Shift', 'Tab', 'Null']); - await expect(page.isFocused('tr[aria-rowindex="2"] [aria-label="Item actions"]')).resolves.toBe(true); - - await page.keys(['Shift', 'Tab', 'Null']); - await expect(page.isFocused('[data-testid="link-before"]')).resolves.toBe(true); - }) -); - -test( - 'element index is preserved when moving cursor down', - setupTest({ actionsMode: 'inline' }, async page => { - await page.click('[data-testid="link-before"]'); - await page.keys('Tab'); - await page.keys(['ArrowRight', 'ArrowDown', 'ArrowDown', 'ArrowRight', 'ArrowRight']); - await expect(page.isFocused('tr[aria-rowindex="3"] button[aria-label="Update item"]')).resolves.toBe(true); - - await page.keys(['ArrowDown', 'ArrowDown', 'ArrowDown', 'ArrowDown']); - await expect(page.isFocused('tr[aria-rowindex="7"] button[aria-label="Update item"]')).resolves.toBe(true); - }) -); - -test( - 'column index is preserved when moving cursor down', - setupTest({ actionsMode: 'inline' }, async page => { - await page.click('[data-testid="link-before"]'); - await page.keys('Tab'); - await page.keys(['ArrowRight', 'ArrowDown', 'ArrowDown', 'ArrowRight']); - await expect(page.isFocused('tr[aria-rowindex="3"] button[aria-label="Duplicate item"]')).resolves.toBe(true); - - await page.keys(range(0, 10).map(() => 'ArrowDown')); - await expect(page.getFocusedElementText()).resolves.toBe('Summary row'); - - await page.keys(['ArrowDown']); - await expect(page.isFocused('tr[aria-rowindex="14"] button[aria-label="Duplicate item"]')).resolves.toBe(true); - }) -); diff --git a/src/table/table-role/__integ__/page-object.ts b/src/table/table-role/__integ__/page-object.ts deleted file mode 100644 index 76f2d470e6..0000000000 --- a/src/table/table-role/__integ__/page-object.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; - -interface ExtendedWindow extends Window { - refreshItems: () => void; -} -declare const window: ExtendedWindow; - -export class GridNavigationPageObject extends BasePageObject { - async refreshItems() { - await this.browser.execute(() => window.refreshItems()); - } -} diff --git a/src/table/table-role/__tests__/grid-navigation-processor.test.tsx b/src/table/table-role/__tests__/grid-navigation-processor.test.tsx deleted file mode 100644 index f773f98c6e..0000000000 --- a/src/table/table-role/__tests__/grid-navigation-processor.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// 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/table/table-role/__tests__/grid-navigation.test.tsx b/src/table/table-role/__tests__/grid-navigation.test.tsx deleted file mode 100644 index 791b52033b..0000000000 --- a/src/table/table-role/__tests__/grid-navigation.test.tsx +++ /dev/null @@ -1,399 +0,0 @@ -// 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/table/table-role/__tests__/stubs.tsx b/src/table/table-role/__tests__/stubs.tsx deleted file mode 100644 index ab6eef4d71..0000000000 --- a/src/table/table-role/__tests__/stubs.tsx +++ /dev/null @@ -1,127 +0,0 @@ -// 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/table/table-role/grid-navigation.tsx b/src/table/table-role/grid-navigation.tsx deleted file mode 100644 index e419d2e3fd..0000000000 --- a/src/table/table-role/grid-navigation.tsx +++ /dev/null @@ -1,363 +0,0 @@ -// 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/table/table-role/index.ts b/src/table/table-role/index.ts index ece6852c87..35fdaf3f05 100644 --- a/src/table/table-role/index.ts +++ b/src/table/table-role/index.ts @@ -1,15 +1,5 @@ // 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'; +// Moved to @cloudscape-design/component-toolkit (cloudscape-design/component-toolkit#242). +export * from '@cloudscape-design/component-toolkit/internal/table-role'; diff --git a/src/table/table-role/interfaces.ts b/src/table/table-role/interfaces.ts deleted file mode 100644 index 23df4cfabc..0000000000 --- a/src/table/table-role/interfaces.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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/table/table-role/table-role-helper.ts b/src/table/table-role/table-role-helper.ts deleted file mode 100644 index ac0748421b..0000000000 --- a/src/table/table-role/table-role-helper.ts +++ /dev/null @@ -1,149 +0,0 @@ -// 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/table/table-role/utils.ts b/src/table/table-role/utils.ts deleted file mode 100644 index 5902e0f04a..0000000000 --- a/src/table/table-role/utils.ts +++ /dev/null @@ -1,206 +0,0 @@ -// 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(); - } -}