diff --git a/pages/table-fragments/grid-navigation-custom.page.tsx b/pages/table-fragments/grid-navigation-custom.page.tsx
index 46c55760e9..c564ea387b 100644
--- a/pages/table-fragments/grid-navigation-custom.page.tsx
+++ b/pages/table-fragments/grid-navigation-custom.page.tsx
@@ -7,6 +7,7 @@ import {
AppLayout,
Button,
ButtonDropdown,
+ ButtonGroup,
Checkbox,
CollectionPreferences,
ColumnLayout,
@@ -60,13 +61,14 @@ type PageContext = React.Context<
}>
>;
-type ActionsMode = 'dropdown' | 'inline';
+type ActionsMode = 'dropdown' | 'inline' | 'button-group';
const tableRoleOptions = [{ value: 'table' }, { value: 'grid' }, { value: 'grid-default' }];
const actionsModeOptions = [
{ value: 'dropdown', label: 'Dropdown' },
- { value: 'inline', label: 'Inline (anti-pattern)' },
+ { value: 'inline', label: 'Inline' },
+ { value: 'button-group', label: 'Button group' },
];
export default function Page() {
@@ -458,19 +460,47 @@ function ItemActionsCell({
);
}
- return (
-
-
-
-
-
- );
+ if (mode === 'inline') {
+ return (
+
+
+
+
+
+ );
+ }
+ if (mode === 'button-group') {
+ return (
+
+ {
+ switch (event.detail.id) {
+ case 'delete':
+ return onDelete();
+ case 'duplicate':
+ return onDuplicate();
+ case 'update':
+ return onUpdate();
+ }
+ }}
+ />
+
+ );
+ }
+ return null;
}
function DnsEditCell({ item }: { item: Instance }) {
diff --git a/src/internal/context/__tests__/single-tab-stop-navigation-context.test.tsx b/src/internal/context/__tests__/single-tab-stop-navigation-context.test.tsx
index 7f167aa7f9..c8d6440323 100644
--- a/src/internal/context/__tests__/single-tab-stop-navigation-context.test.tsx
+++ b/src/internal/context/__tests__/single-tab-stop-navigation-context.test.tsx
@@ -1,21 +1,59 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import React, { useRef } from 'react';
+import React, { useContext, useEffect, useRef } from 'react';
import { render } from '@testing-library/react';
import {
+ SingleTabStopNavigationAPI,
SingleTabStopNavigationContext,
+ SingleTabStopNavigationProvider,
useSingleTabStopNavigation,
} from '../../../../lib/components/internal/context/single-tab-stop-navigation-context';
import { renderWithSingleTabStopNavigation } from './utils';
+// Simple STSN subscriber component
function Button(props: React.HTMLAttributes) {
const buttonRef = useRef(null);
const { tabIndex } = useSingleTabStopNavigation(buttonRef, { tabIndex: props.tabIndex });
return ;
}
+// Simple STSN provider component
+function Group({
+ id,
+ navigationActive,
+ children,
+}: {
+ id: string;
+ navigationActive: boolean;
+ children: React.ReactNode;
+}) {
+ const navigationAPI = useRef(null);
+
+ useEffect(() => {
+ navigationAPI.current?.updateFocusTarget();
+ });
+
+ return (
+ document.querySelector(`#${id}`)!.querySelectorAll('button')[0] as HTMLElement}
+ >
+
+
+
+ {children}
+
+
+ );
+}
+
+function findGroupButton(groupId: string, buttonIndex: number) {
+ return document.querySelector(`#${groupId}`)!.querySelectorAll('button')[buttonIndex] as HTMLElement;
+}
+
test('does not override tab index when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(, { navigationActive: false });
expect(document.querySelector('#button')).not.toHaveAttribute('tabIndex');
@@ -75,7 +113,9 @@ test('propagates and suppresses navigation active state', () => {
}
function Test({ navigationActive }: { navigationActive: boolean }) {
return (
- () => {} }}>
+ () => {}, resetFocusTarget: () => {} }}
+ >
);
@@ -87,3 +127,124 @@ test('propagates and suppresses navigation active state', () => {
rerender();
expect(document.querySelector('div')).toHaveTextContent('false');
});
+
+test('subscriber components can be used without provider', () => {
+ function TestComponent(props: React.HTMLAttributes) {
+ const ref = useRef(null);
+ const contextResult = useContext(SingleTabStopNavigationContext);
+ const hookResult = useSingleTabStopNavigation(ref, { tabIndex: props.tabIndex });
+ useEffect(() => {
+ contextResult.registerFocusable(ref.current!, () => {});
+ contextResult.resetFocusTarget();
+ });
+ return (
+
+ Context: {`${contextResult.navigationActive}`}, Hook: {`${hookResult.navigationActive}:${hookResult.tabIndex}`}
+
+ );
+ }
+ const { container } = render();
+ expect(container.textContent).toBe('Context: false, Hook: false:undefined');
+});
+
+describe('nested contexts', () => {
+ test('tab indices are distributed correctly when switching contexts from inner to outer', () => {
+ const { rerender } = render(
+
+
+
+ {null}
+
+
+
+ );
+ expect(findGroupButton('outer-most', 0)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer-most', 1)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer', 0)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer', 1)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '0');
+ expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
+
+ rerender(
+
+
+
+ {null}
+
+
+
+ );
+ expect(findGroupButton('outer-most', 0)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer-most', 1)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer', 0)).toHaveAttribute('tabindex', '0');
+ expect(findGroupButton('outer', 1)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
+
+ rerender(
+
+
+
+ {null}
+
+
+
+ );
+ expect(findGroupButton('outer-most', 0)).toHaveAttribute('tabindex', '0');
+ expect(findGroupButton('outer-most', 1)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('outer', 0)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('outer', 1)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
+ });
+
+ test('tab indices are distributed correctly when switching contexts from outer to inner', () => {
+ const { rerender } = render(
+
+
+
+ {null}
+
+
+
+ );
+ expect(findGroupButton('outer-most', 0)).toHaveAttribute('tabindex', '0');
+ expect(findGroupButton('outer-most', 1)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('outer', 0)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('outer', 1)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
+
+ rerender(
+
+
+
+ {null}
+
+
+
+ );
+ expect(findGroupButton('outer-most', 0)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer-most', 1)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer', 0)).toHaveAttribute('tabindex', '0');
+ expect(findGroupButton('outer', 1)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '-1');
+ expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
+
+ rerender(
+
+
+
+ {null}
+
+
+
+ );
+ expect(findGroupButton('outer-most', 0)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer-most', 1)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer', 0)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('outer', 1)).not.toHaveAttribute('tabindex');
+ expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '0');
+ expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
+ });
+});
diff --git a/src/internal/context/__tests__/utils.tsx b/src/internal/context/__tests__/utils.tsx
index 9ca76940fc..8e6c2447e2 100644
--- a/src/internal/context/__tests__/utils.tsx
+++ b/src/internal/context/__tests__/utils.tsx
@@ -39,7 +39,9 @@ const FakeSingleTabStopNavigationProvider = forwardRef(
}));
return (
-
+ {} }}
+ >
{children}
);
diff --git a/src/internal/context/single-tab-stop-navigation-context.tsx b/src/internal/context/single-tab-stop-navigation-context.tsx
index dd5fd0f628..fc7fd7a820 100644
--- a/src/internal/context/single-tab-stop-navigation-context.tsx
+++ b/src/internal/context/single-tab-stop-navigation-context.tsx
@@ -11,6 +11,7 @@ import React, {
useState,
} from 'react';
+import { useEffectOnUpdate } from '../hooks/use-effect-on-update';
import { nodeBelongs } from '../utils/node-belongs';
export type FocusableChangeHandler = (isFocusable: boolean) => void;
@@ -18,9 +19,11 @@ export type FocusableChangeHandler = (isFocusable: boolean) => void;
export const defaultValue: {
navigationActive: boolean;
registerFocusable(focusable: HTMLElement, handler: FocusableChangeHandler): () => void;
+ resetFocusTarget(): void;
} = {
navigationActive: false,
registerFocusable: () => () => {},
+ resetFocusTarget: () => {},
};
/**
@@ -99,7 +102,11 @@ export const SingleTabStopNavigationProvider = forwardRef(
// Register a focusable element to allow navigating into it.
// The focusable element tabIndex is only set to 0 if the element matches the focus target.
- function registerFocusable(focusableElement: Element, changeHandler: FocusableChangeHandler) {
+ function registerFocusable(focusableElement: HTMLElement, changeHandler: FocusableChangeHandler) {
+ // In case the contexts are nested, we must that the components register to all of them,
+ // so that switching between contexts dynamically is possible.
+ const parentUnregister = parentContext.registerFocusable(focusableElement, changeHandler);
+
focusables.current.add(focusableElement);
focusHandlers.current.set(focusableElement, changeHandler);
const isFocusable = !!focusablesState.current.get(focusableElement);
@@ -109,7 +116,11 @@ export const SingleTabStopNavigationProvider = forwardRef(
changeHandler(newIsFocusable);
}
onRegisterFocusable?.(focusableElement);
- return () => unregisterFocusable(focusableElement);
+
+ return () => {
+ parentUnregister();
+ unregisterFocusable(focusableElement);
+ };
}
function unregisterFocusable(focusableElement: Element) {
focusables.current.delete(focusableElement);
@@ -118,32 +129,49 @@ export const SingleTabStopNavigationProvider = forwardRef(
}
// Update focus target with next single focusable element and notify all registered focusables of a change.
- function updateFocusTarget() {
+ function updateFocusTarget(forceUpdate = false) {
focusTarget.current = getNextFocusTarget();
for (const focusableElement of focusables.current) {
const isFocusable = focusablesState.current.get(focusableElement) ?? false;
const newIsFocusable = focusTarget.current === focusableElement || !!isElementSuppressed?.(focusableElement);
- if (newIsFocusable !== isFocusable) {
+ if (newIsFocusable !== isFocusable || forceUpdate) {
focusablesState.current.set(focusableElement, newIsFocusable);
focusHandlers.current.get(focusableElement)!(newIsFocusable);
}
}
}
-
+ function resetFocusTarget() {
+ updateFocusTarget(true);
+ }
function getFocusTarget() {
return focusTarget.current;
}
-
function isRegistered(element: Element) {
return focusables.current.has(element);
}
-
useImperativeHandle(ref, () => ({ updateFocusTarget, getFocusTarget, isRegistered }));
- return (
-
- {children}
-
- );
+ // Only one STSN context should be active at a time.
+ // The outer context is preferred over the inners. The components using STSN
+ // must either work with either outer or inner context, or an explicit switch mechanism
+ // needs to be implemented (that turns the outer context on and off based on user interaction).
+ const parentContext = useContext(SingleTabStopNavigationContext);
+ const value = parentContext.navigationActive
+ ? parentContext
+ : { navigationActive, registerFocusable, updateFocusTarget, resetFocusTarget };
+
+ // When contexts switching occurs, it is essential that the now-active one updates the focus target
+ // to ensure the tab indices are correctly set.
+ useEffectOnUpdate(() => {
+ if (parentContext.navigationActive) {
+ parentContext.resetFocusTarget();
+ } else {
+ resetFocusTarget();
+ }
+ // The updateFocusTarget and its dependencies must be pure.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [parentContext.navigationActive]);
+
+ return {children};
}
);