Skip to content

Commit

Permalink
chore: Table grid navigation register (#1856)
Browse files Browse the repository at this point in the history
  • Loading branch information
pan-kot authored Jan 16, 2024
1 parent 3a07a76 commit 1bbe2c9
Show file tree
Hide file tree
Showing 13 changed files with 269 additions and 184 deletions.
28 changes: 22 additions & 6 deletions pages/table-fragments/grid-navigation-custom.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
getTableWrapperRoleProps,
GridNavigationProvider,
} from '~components/table/table-role';
import { useSingleTabStopNavigation } from '~components/internal/context/single-tab-stop-navigation-context';
import { orderBy, range } from 'lodash';
import appLayoutLabels from '../app-layout/utils/labels';
import { stateToStatusIndicator } from '../table/shared-configs';
Expand Down Expand Up @@ -266,7 +267,8 @@ export default function Page() {
<thead>
<tr {...getTableHeaderRowRoleProps({ tableRole })}>
{visibleColumnDefinitions.map((column, colIndex) => (
<th
<Cell
tag="th"
key={column.key}
className={styles['custom-table-cell']}
{...getTableColHeaderRoleProps({ tableRole, colIndex })}
Expand All @@ -284,21 +286,22 @@ export default function Page() {
}
}}
/>
</th>
</Cell>
))}
</tr>
</thead>
<tbody>
{sortedItems.map((item, rowIndex) => (
<tr key={item.id} {...getTableRowRoleProps({ tableRole, rowIndex, firstIndex: 0 })}>
{visibleColumnDefinitions.map((column, colIndex) => (
<td
<Cell
tag="td"
key={column.key}
className={styles['custom-table-cell']}
{...getTableCellRoleProps({ tableRole, colIndex })}
>
{column.render(item)}
</td>
</Cell>
))}
</tr>
))}
Expand All @@ -313,6 +316,12 @@ export default function Page() {
);
}

function Cell({ tag: Tag, ...rest }: React.HTMLAttributes<HTMLTableCellElement> & { tag: 'th' | 'td' }) {
const cellRef = useRef<HTMLTableCellElement>(null);
const { tabIndex } = useSingleTabStopNavigation(cellRef);
return <Tag {...rest} ref={cellRef} tabIndex={tabIndex} />;
}

function SortingHeader({
column,
sortingKey,
Expand All @@ -324,9 +333,11 @@ function SortingHeader({
sortingDirection: -1 | 1;
onClick: () => void;
}) {
const buttonRef = useRef<HTMLButtonElement>(null);
const { tabIndex } = useSingleTabStopNavigation(buttonRef);
return (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
<button className={styles['custom-table-sorting-header']} onClick={onClick}>
<button ref={buttonRef} tabIndex={tabIndex} className={styles['custom-table-sorting-header']} onClick={onClick}>
{column.label}
</button>
{sortingKey === column.key && sortingDirection === -1 && <Icon name="angle-down" />}
Expand Down Expand Up @@ -416,11 +427,16 @@ function ItemActionsCell({
function DnsEditCell({ item }: { item: Instance }) {
const [active, setActive] = useState(false);
const [value, setValue] = useState(item.dnsName ?? '');

const triggerRef = useRef<HTMLDivElement>(null);
const { tabIndex } = useSingleTabStopNavigation(triggerRef);
const dialogRef = useRef<HTMLDivElement>(null);

return !active ? (
<div
ref={triggerRef}
role="button"
tabIndex={0}
tabIndex={tabIndex}
aria-label="Edit DNS name"
onClick={() => setActive(true)}
onKeyDown={event => {
Expand Down
4 changes: 2 additions & 2 deletions src/button/__tests__/button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -578,7 +578,7 @@ describe('table grid navigation support', () => {
}

test('does not override tab index when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<Button id="button" />);
renderWithSingleTabStopNavigation(<Button id="button" />, { navigationActive: false });
expect(getButton('#button')).not.toHaveAttribute('tabIndex');
});

Expand All @@ -603,6 +603,6 @@ describe('table grid navigation support', () => {
);
setCurrentTarget(getButton('#button1'));
expect(getButton('#button1')).toHaveAttribute('tabIndex', '-2');
expect(getButton('#button2')).toHaveAttribute('tabIndex', '-1');
expect(getButton('#button2')).toHaveAttribute('tabIndex', '-2');
});
});
2 changes: 1 addition & 1 deletion src/checkbox/__tests__/checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ describe('table grid navigation support', () => {
}

test('does not override tab index when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<Checkbox id="checkbox" checked={false} />);
renderWithSingleTabStopNavigation(<Checkbox id="checkbox" checked={false} />, { navigationActive: false });
expect(getCheckboxInput('#checkbox')).not.toHaveAttribute('tabIndex');
});

Expand Down
8 changes: 5 additions & 3 deletions src/internal/components/focus-lock/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ const tabbables = [
'[autofocus]',
].join(',');

export function getAllFocusables(container: HTMLElement): HTMLElement[] {
return Array.prototype.slice.call(container.querySelectorAll(tabbables));
}

export function getFocusables(container: HTMLElement): HTMLElement[] {
return Array.prototype.slice
.call(container.querySelectorAll(tabbables))
.filter((element: HTMLElement) => element.tabIndex !== -1);
return getAllFocusables(container).filter((element: HTMLElement) => element.tabIndex !== -1);
}

export function getFirstFocusable(container: HTMLElement): null | HTMLElement {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,33 @@ function Button(props: React.HTMLAttributes<HTMLButtonElement>) {
}

test('does not override tab index when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<Button id="button" />);
renderWithSingleTabStopNavigation(<Button id="button" />, { navigationActive: false });
expect(document.querySelector('#button')).not.toHaveAttribute('tabIndex');
});

test('does not override tab index for suppressed elements', () => {
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
<div>
<Button id="button1" />
<Button id="button2" />
<Button id="button3" tabIndex={-1} />
<Button id="button4" />
<Button id="button5" tabIndex={-1} />
</div>,
{ navigationActive: true }
);
setCurrentTarget(document.querySelector('#button1'), [
document.querySelector('#button1'),
document.querySelector('#button2'),
document.querySelector('#button3'),
]);
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '0');
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '0');
expect(document.querySelector('#button3')).toHaveAttribute('tabIndex', '-1');
expect(document.querySelector('#button4')).toHaveAttribute('tabIndex', '-1');
expect(document.querySelector('#button5')).toHaveAttribute('tabIndex', '-1');
});

test('overrides tab index when keyboard navigation is active', () => {
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
<div>
Expand All @@ -41,28 +64,25 @@ test('does not override explicit tab index with 0', () => {
);
setCurrentTarget(document.querySelector('#button1'));
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '-2');
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-1');
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-2');
});

test('propagates keyboard navigation state', () => {
test('propagates and suppresses navigation active state', () => {
function Component() {
const { navigationActive } = useSingleTabStopNavigation(null);
return <div>{String(navigationActive)}</div>;
}
function Test({ navigationActive }: { navigationActive: boolean }) {
return (
<SingleTabStopNavigationContext.Provider value={{ navigationActive, registerFocusable: () => () => {} }}>
<Component />
</SingleTabStopNavigationContext.Provider>
);
}

const { rerender } = render(
<SingleTabStopNavigationContext.Provider value={{ navigationActive: true, focusTarget: null }}>
<Component />
</SingleTabStopNavigationContext.Provider>
);

const { rerender } = render(<Test navigationActive={true} />);
expect(document.querySelector('div')).toHaveTextContent('true');

rerender(
<SingleTabStopNavigationContext.Provider value={{ navigationActive: false, focusTarget: null }}>
<Component />
</SingleTabStopNavigationContext.Provider>
);

rerender(<Test navigationActive={false} />);
expect(document.querySelector('div')).toHaveTextContent('false');
});
51 changes: 40 additions & 11 deletions src/internal/context/__tests__/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,70 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React, { createRef, forwardRef, useImperativeHandle, useState } from 'react';
import React, { createRef, forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { render } from '@testing-library/react';
import { SingleTabStopNavigationContext } from '../../../../lib/components/internal/context/single-tab-stop-navigation-context';
import {
FocusableChangeHandler,
FocusableDefinition,
SingleTabStopNavigationContext,
} from '../../../../lib/components/internal/context/single-tab-stop-navigation-context';

interface ProviderRef {
setCurrentTarget(element: null | Element): void;
setCurrentTarget(focusTarget: null | Element, suppressed?: (null | Element)[]): void;
}

const FakeSingleTabStopNavigationProvider = forwardRef(
({ children }: { children: React.ReactNode }, ref: React.Ref<ProviderRef>) => {
const [focusTarget, setFocusTarget] = useState<null | Element>(null);
(
{ children, navigationActive }: { children: React.ReactNode; navigationActive: boolean },
ref: React.Ref<ProviderRef>
) => {
const focusablesRef = useRef(new Set<FocusableDefinition>());
const focusHandlersRef = useRef(new Map<FocusableDefinition, FocusableChangeHandler>());
const registerFocusable = useCallback((focusable: FocusableDefinition, changeHandler: FocusableChangeHandler) => {
focusablesRef.current.add(focusable);
focusHandlersRef.current.set(focusable, changeHandler);
return () => {
focusablesRef.current.delete(focusable);
focusHandlersRef.current.delete(focusable);
};
}, []);

useImperativeHandle(ref, () => ({ setCurrentTarget: setFocusTarget }));
useImperativeHandle(ref, () => ({
setCurrentTarget: (focusTarget: null | Element, suppressed: (null | Element)[] = []) => {
focusablesRef.current.forEach(focusable => {
const element = focusable.current;
const handler = focusHandlersRef.current.get(focusable)!;
handler(focusTarget, element ? suppressed.includes(element) : false);
});
},
}));

return (
<SingleTabStopNavigationContext.Provider value={{ focusTarget, navigationActive: !!focusTarget }}>
<SingleTabStopNavigationContext.Provider value={{ registerFocusable, navigationActive }}>
{children}
</SingleTabStopNavigationContext.Provider>
);
}
);

export function renderWithSingleTabStopNavigation(ui: React.ReactNode) {
export function renderWithSingleTabStopNavigation(
ui: React.ReactNode,
{ navigationActive = true }: { navigationActive?: boolean } = {}
) {
const providerRef = createRef<ProviderRef>();
const { container, rerender } = render(
<FakeSingleTabStopNavigationProvider ref={providerRef}>{ui}</FakeSingleTabStopNavigationProvider>
<FakeSingleTabStopNavigationProvider ref={providerRef} navigationActive={navigationActive}>
{ui}
</FakeSingleTabStopNavigationProvider>
);
return {
container,
rerender,
setCurrentTarget: (element: null | Element) => {
setCurrentTarget: (focusTarget: null | Element, suppressed: (null | Element)[] = []) => {
if (!providerRef.current) {
throw new Error('Provider is not ready');
}
providerRef.current.setCurrentTarget(element);
providerRef.current.setCurrentTarget(focusTarget, suppressed);
},
};
}
43 changes: 32 additions & 11 deletions src/internal/context/single-tab-stop-navigation-context.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,52 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { createContext, useContext } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';

export type FocusableDefinition = React.RefObject<Element>;

export type FocusableChangeHandler = (focusTarget: null | Element, suppressed: boolean) => void;

export interface SingleTabStopNavigationOptions {
tabIndex?: number;
}

/**
* Single tab stop navigation context is used together with keyboard navigation that requires a single tab stop.
* It instructs interactive elements to override tab indices for just a single one to remain user-focusable.
*/
export const SingleTabStopNavigationContext = createContext<{
focusTarget: null | Element;
navigationActive: boolean;
registerFocusable(focusable: FocusableDefinition, handler: FocusableChangeHandler): () => void;
}>({
focusTarget: null,
navigationActive: false,
registerFocusable: () => () => {},
});

export function useSingleTabStopNavigation(
focusable: null | React.RefObject<HTMLElement>,
options?: { tabIndex?: number }
) {
const { focusTarget, navigationActive } = useContext(SingleTabStopNavigationContext);
export function useSingleTabStopNavigation(focusable: null | FocusableDefinition, options?: { tabIndex?: number }) {
const { navigationActive: contextNavigationActive, registerFocusable: contextRegisterFocusable } =
useContext(SingleTabStopNavigationContext);
const [focusTargetActive, setFocusTargetActive] = useState(false);

const navigationActive = contextNavigationActive && (!options?.tabIndex || options?.tabIndex >= 0);
const registerFocusable = useCallback(
(focusable: FocusableDefinition, changeHandler: FocusableChangeHandler) =>
navigationActive ? contextRegisterFocusable(focusable, changeHandler) : () => {},
[navigationActive, contextRegisterFocusable]
);

const focusTargetActive = Boolean(focusable && focusable.current === focusTarget);
useEffect(() => {
if (focusable) {
const changeHandler = (element: null | Element, suppressed: boolean) =>
setFocusTargetActive(focusable.current === element || suppressed);
const unregister = registerFocusable(focusable, changeHandler);
return () => unregister();
}
}, [focusable, registerFocusable]);

let tabIndex: undefined | number = options?.tabIndex;
let tabIndex = options?.tabIndex;
if (navigationActive) {
tabIndex = !focusTargetActive ? -1 : tabIndex ?? 0;
tabIndex = !focusTargetActive ? -1 : options?.tabIndex ?? 0;
}

return { navigationActive, tabIndex };
Expand Down
4 changes: 2 additions & 2 deletions src/link/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,12 +282,12 @@ describe('table grid navigation support', () => {
}

test('does not override tab index for button link when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<Link id="link" />);
renderWithSingleTabStopNavigation(<Link id="link" />, { navigationActive: false });
expect(getLink('#link')).toHaveAttribute('tabIndex', '0');
});

test('does not override tab index for anchor link when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<Link id="link" href="#" />);
renderWithSingleTabStopNavigation(<Link id="link" href="#" />, { navigationActive: false });
expect(getLink('#link')).not.toHaveAttribute('tabIndex');
});

Expand Down
4 changes: 3 additions & 1 deletion src/radio-group/__tests__/radio-group.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,9 @@ describe('table grid navigation support', () => {
}

test('does not override tab index when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<RadioGroup id="radio" value={null} items={[{ value: '1', label: 'One' }]} />);
renderWithSingleTabStopNavigation(<RadioGroup id="radio" value={null} items={[{ value: '1', label: 'One' }]} />, {
navigationActive: false,
});
expect(getRadioInput('#radio')).not.toHaveAttribute('tabIndex');
});

Expand Down
2 changes: 1 addition & 1 deletion src/table/table-role/__tests__/grid-navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ test('all elements focus is restored if table changes role after being rendered

expect(readFocusableElements()).toEqual(['BUTTON[Sort by value]']);

rerender(<TestTable keyboardNavigation={false} columns={[idColumn, valueColumn]} items={items} />);
rerender(<TestTable keyboardNavigation={false} columns={[valueColumn, idColumn]} items={items} />);

expect(readFocusableElements()).toEqual([
'BUTTON[Sort by value]',
Expand Down
Loading

0 comments on commit 1bbe2c9

Please sign in to comment.