Skip to content

Commit 2916206

Browse files
authored
chore: Updates stsn implementation (#155)
1 parent c338b2d commit 2916206

File tree

4 files changed

+236
-37
lines changed

4 files changed

+236
-37
lines changed

src/internal/single-tab-stop/__tests__/context.test.tsx

Lines changed: 174 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,58 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import React, { useRef } from 'react';
5-
import { render, act } from '@testing-library/react';
4+
import React, { useContext, useEffect, useRef } from 'react';
5+
import { render } from '@testing-library/react';
66

7-
import { SingleTabStopNavigationContext, useSingleTabStopNavigation } from '../';
7+
import {
8+
SingleTabStopNavigationAPI,
9+
SingleTabStopNavigationContext,
10+
SingleTabStopNavigationProvider,
11+
useSingleTabStopNavigation,
12+
} from '../';
813
import { renderWithSingleTabStopNavigation } from './utils';
914

15+
// Simple STSN subscriber component
1016
function Button(props: React.HTMLAttributes<HTMLButtonElement>) {
1117
const buttonRef = useRef<HTMLButtonElement>(null);
1218
const { tabIndex } = useSingleTabStopNavigation(buttonRef, { tabIndex: props.tabIndex });
1319
return <button {...props} ref={buttonRef} tabIndex={tabIndex} />;
1420
}
1521

16-
test('subscribed components can be rendered outside single tab stop navigation context', () => {
17-
render(<Button />);
18-
expect(document.querySelector('button')).not.toHaveAttribute('tabIndex');
19-
});
22+
// Simple STSN provider component
23+
function Group({
24+
id,
25+
navigationActive,
26+
children,
27+
}: {
28+
id: string;
29+
navigationActive: boolean;
30+
children: React.ReactNode;
31+
}) {
32+
const navigationAPI = useRef<SingleTabStopNavigationAPI>(null);
33+
34+
useEffect(() => {
35+
navigationAPI.current?.updateFocusTarget();
36+
});
37+
38+
return (
39+
<SingleTabStopNavigationProvider
40+
ref={navigationAPI}
41+
navigationActive={navigationActive}
42+
getNextFocusTarget={() => document.querySelector(`#${id}`)!.querySelectorAll('button')[0] as HTMLElement}
43+
>
44+
<div id={id}>
45+
<Button>First</Button>
46+
<Button>Second</Button>
47+
{children}
48+
</div>
49+
</SingleTabStopNavigationProvider>
50+
);
51+
}
52+
53+
function findGroupButton(groupId: string, buttonIndex: number) {
54+
return document.querySelector(`#${groupId}`)!.querySelectorAll('button')[buttonIndex] as HTMLElement;
55+
}
2056

2157
test('does not override tab index when keyboard navigation is not active', () => {
2258
renderWithSingleTabStopNavigation(<Button id="button" />, { navigationActive: false });
@@ -34,14 +70,11 @@ test('does not override tab index for suppressed elements', () => {
3470
</div>,
3571
{ navigationActive: true }
3672
);
37-
act(() => {
38-
setCurrentTarget(document.querySelector('#button1'), [
39-
document.querySelector('#button1'),
40-
document.querySelector('#button2'),
41-
document.querySelector('#button3'),
42-
]);
43-
});
44-
73+
setCurrentTarget(document.querySelector('#button1'), [
74+
document.querySelector('#button1'),
75+
document.querySelector('#button2'),
76+
document.querySelector('#button3'),
77+
]);
4578
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '0');
4679
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '0');
4780
expect(document.querySelector('#button3')).toHaveAttribute('tabIndex', '-1');
@@ -56,9 +89,7 @@ test('overrides tab index when keyboard navigation is active', () => {
5689
<Button id="button2" />
5790
</div>
5891
);
59-
act(() => {
60-
setCurrentTarget(document.querySelector('#button1'));
61-
});
92+
setCurrentTarget(document.querySelector('#button1'));
6293
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '0');
6394
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-1');
6495
});
@@ -70,9 +101,7 @@ test('does not override explicit tab index with 0', () => {
70101
<Button id="button2" tabIndex={-2} />
71102
</div>
72103
);
73-
act(() => {
74-
setCurrentTarget(document.querySelector('#button1'));
75-
});
104+
setCurrentTarget(document.querySelector('#button1'));
76105
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '-2');
77106
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-2');
78107
});
@@ -84,7 +113,9 @@ test('propagates and suppresses navigation active state', () => {
84113
}
85114
function Test({ navigationActive }: { navigationActive: boolean }) {
86115
return (
87-
<SingleTabStopNavigationContext.Provider value={{ navigationActive, registerFocusable: () => () => {} }}>
116+
<SingleTabStopNavigationContext.Provider
117+
value={{ navigationActive, registerFocusable: () => () => {}, resetFocusTarget: () => {} }}
118+
>
88119
<Component />
89120
</SingleTabStopNavigationContext.Provider>
90121
);
@@ -96,3 +127,124 @@ test('propagates and suppresses navigation active state', () => {
96127
rerender(<Test navigationActive={false} />);
97128
expect(document.querySelector('div')).toHaveTextContent('false');
98129
});
130+
131+
test('subscriber components can be used without provider', () => {
132+
function TestComponent(props: React.HTMLAttributes<HTMLButtonElement>) {
133+
const ref = useRef(null);
134+
const contextResult = useContext(SingleTabStopNavigationContext);
135+
const hookResult = useSingleTabStopNavigation(ref, { tabIndex: props.tabIndex });
136+
useEffect(() => {
137+
contextResult.registerFocusable(ref.current!, () => {});
138+
contextResult.resetFocusTarget();
139+
});
140+
return (
141+
<div ref={ref}>
142+
Context: {`${contextResult.navigationActive}`}, Hook: {`${hookResult.navigationActive}:${hookResult.tabIndex}`}
143+
</div>
144+
);
145+
}
146+
const { container } = render(<TestComponent />);
147+
expect(container.textContent).toBe('Context: false, Hook: false:undefined');
148+
});
149+
150+
describe('nested contexts', () => {
151+
test('tab indices are distributed correctly when switching contexts from inner to outer', () => {
152+
const { rerender } = render(
153+
<Group id="outer-most" navigationActive={false}>
154+
<Group id="outer" navigationActive={false}>
155+
<Group id="inner" navigationActive={true}>
156+
{null}
157+
</Group>
158+
</Group>
159+
</Group>
160+
);
161+
expect(findGroupButton('outer-most', 0)).not.toHaveAttribute('tabindex');
162+
expect(findGroupButton('outer-most', 1)).not.toHaveAttribute('tabindex');
163+
expect(findGroupButton('outer', 0)).not.toHaveAttribute('tabindex');
164+
expect(findGroupButton('outer', 1)).not.toHaveAttribute('tabindex');
165+
expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '0');
166+
expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
167+
168+
rerender(
169+
<Group id="outer-most" navigationActive={false}>
170+
<Group id="outer" navigationActive={true}>
171+
<Group id="inner" navigationActive={true}>
172+
{null}
173+
</Group>
174+
</Group>
175+
</Group>
176+
);
177+
expect(findGroupButton('outer-most', 0)).not.toHaveAttribute('tabindex');
178+
expect(findGroupButton('outer-most', 1)).not.toHaveAttribute('tabindex');
179+
expect(findGroupButton('outer', 0)).toHaveAttribute('tabindex', '0');
180+
expect(findGroupButton('outer', 1)).toHaveAttribute('tabindex', '-1');
181+
expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '-1');
182+
expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
183+
184+
rerender(
185+
<Group id="outer-most" navigationActive={true}>
186+
<Group id="outer" navigationActive={true}>
187+
<Group id="inner" navigationActive={true}>
188+
{null}
189+
</Group>
190+
</Group>
191+
</Group>
192+
);
193+
expect(findGroupButton('outer-most', 0)).toHaveAttribute('tabindex', '0');
194+
expect(findGroupButton('outer-most', 1)).toHaveAttribute('tabindex', '-1');
195+
expect(findGroupButton('outer', 0)).toHaveAttribute('tabindex', '-1');
196+
expect(findGroupButton('outer', 1)).toHaveAttribute('tabindex', '-1');
197+
expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '-1');
198+
expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
199+
});
200+
201+
test('tab indices are distributed correctly when switching contexts from outer to inner', () => {
202+
const { rerender } = render(
203+
<Group id="outer-most" navigationActive={true}>
204+
<Group id="outer" navigationActive={true}>
205+
<Group id="inner" navigationActive={true}>
206+
{null}
207+
</Group>
208+
</Group>
209+
</Group>
210+
);
211+
expect(findGroupButton('outer-most', 0)).toHaveAttribute('tabindex', '0');
212+
expect(findGroupButton('outer-most', 1)).toHaveAttribute('tabindex', '-1');
213+
expect(findGroupButton('outer', 0)).toHaveAttribute('tabindex', '-1');
214+
expect(findGroupButton('outer', 1)).toHaveAttribute('tabindex', '-1');
215+
expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '-1');
216+
expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
217+
218+
rerender(
219+
<Group id="outer-most" navigationActive={false}>
220+
<Group id="outer" navigationActive={true}>
221+
<Group id="inner" navigationActive={true}>
222+
{null}
223+
</Group>
224+
</Group>
225+
</Group>
226+
);
227+
expect(findGroupButton('outer-most', 0)).not.toHaveAttribute('tabindex');
228+
expect(findGroupButton('outer-most', 1)).not.toHaveAttribute('tabindex');
229+
expect(findGroupButton('outer', 0)).toHaveAttribute('tabindex', '0');
230+
expect(findGroupButton('outer', 1)).toHaveAttribute('tabindex', '-1');
231+
expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '-1');
232+
expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
233+
234+
rerender(
235+
<Group id="outer-most" navigationActive={false}>
236+
<Group id="outer" navigationActive={false}>
237+
<Group id="inner" navigationActive={true}>
238+
{null}
239+
</Group>
240+
</Group>
241+
</Group>
242+
);
243+
expect(findGroupButton('outer-most', 0)).not.toHaveAttribute('tabindex');
244+
expect(findGroupButton('outer-most', 1)).not.toHaveAttribute('tabindex');
245+
expect(findGroupButton('outer', 0)).not.toHaveAttribute('tabindex');
246+
expect(findGroupButton('outer', 1)).not.toHaveAttribute('tabindex');
247+
expect(findGroupButton('inner', 0)).toHaveAttribute('tabindex', '0');
248+
expect(findGroupButton('inner', 1)).toHaveAttribute('tabindex', '-1');
249+
});
250+
});

src/internal/single-tab-stop/__tests__/utils.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ const FakeSingleTabStopNavigationProvider = forwardRef(
3636
}));
3737

3838
return (
39-
<SingleTabStopNavigationContext.Provider value={{ registerFocusable, navigationActive }}>
39+
<SingleTabStopNavigationContext.Provider
40+
value={{ registerFocusable, navigationActive, resetFocusTarget: () => {} }}
41+
>
4042
{children}
4143
</SingleTabStopNavigationContext.Provider>
4244
);

src/internal/single-tab-stop/index.tsx

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,19 @@ import React, {
1111
useState,
1212
} from 'react';
1313

14+
import { useEffectOnUpdate } from '../use-effect-on-update';
1415
import nodeBelongs from '../../dom/node-belongs';
1516

1617
export type FocusableChangeHandler = (isFocusable: boolean) => void;
1718

1819
export const defaultValue: {
1920
navigationActive: boolean;
2021
registerFocusable(focusable: HTMLElement, handler: FocusableChangeHandler): () => void;
22+
resetFocusTarget(): void;
2123
} = {
2224
navigationActive: false,
2325
registerFocusable: () => () => {},
26+
resetFocusTarget: () => {},
2427
};
2528

2629
/**
@@ -99,9 +102,12 @@ export const SingleTabStopNavigationProvider = forwardRef(
99102

100103
// Register a focusable element to allow navigating into it.
101104
// The focusable element tabIndex is only set to 0 if the element matches the focus target.
102-
function registerFocusable(focusableElement: Element, changeHandler: FocusableChangeHandler) {
103-
focusables.current.add(focusableElement);
105+
function registerFocusable(focusableElement: HTMLElement, changeHandler: FocusableChangeHandler) {
106+
// In case the contexts are nested, we must that the components register to all of them,
107+
// so that switching between contexts dynamically is possible.
108+
const parentUnregister = parentContext.registerFocusable(focusableElement, changeHandler);
104109

110+
focusables.current.add(focusableElement);
105111
focusHandlers.current.set(focusableElement, changeHandler);
106112
const isFocusable = !!focusablesState.current.get(focusableElement);
107113
const newIsFocusable = focusTarget.current === focusableElement || !!isElementSuppressed?.(focusableElement);
@@ -110,42 +116,62 @@ export const SingleTabStopNavigationProvider = forwardRef(
110116
changeHandler(newIsFocusable);
111117
}
112118
onRegisterFocusable?.(focusableElement);
113-
return () => unregisterFocusable(focusableElement);
114-
}
115119

120+
return () => {
121+
parentUnregister();
122+
unregisterFocusable(focusableElement);
123+
};
124+
}
116125
function unregisterFocusable(focusableElement: Element) {
117126
focusables.current.delete(focusableElement);
118127
focusHandlers.current.delete(focusableElement);
119128
onUnregisterFocusable?.(focusableElement);
120129
}
121130

122131
// Update focus target with next single focusable element and notify all registered focusables of a change.
123-
function updateFocusTarget() {
132+
function updateFocusTarget(forceUpdate = false) {
124133
focusTarget.current = getNextFocusTarget();
125134
for (const focusableElement of focusables.current) {
126135
const isFocusable = focusablesState.current.get(focusableElement) ?? false;
127136
const newIsFocusable = focusTarget.current === focusableElement || !!isElementSuppressed?.(focusableElement);
128-
if (newIsFocusable !== isFocusable) {
137+
if (newIsFocusable !== isFocusable || forceUpdate) {
129138
focusablesState.current.set(focusableElement, newIsFocusable);
130139
focusHandlers.current.get(focusableElement)!(newIsFocusable);
131140
}
132141
}
133142
}
134-
143+
function resetFocusTarget() {
144+
updateFocusTarget(true);
145+
}
135146
function getFocusTarget() {
136147
return focusTarget.current;
137148
}
138-
139149
function isRegistered(element: Element) {
140150
return focusables.current.has(element);
141151
}
142-
143152
useImperativeHandle(ref, () => ({ updateFocusTarget, getFocusTarget, isRegistered }));
144153

145-
return (
146-
<SingleTabStopNavigationContext.Provider value={{ navigationActive, registerFocusable }}>
147-
{children}
148-
</SingleTabStopNavigationContext.Provider>
149-
);
154+
// Only one STSN context should be active at a time.
155+
// The outer context is preferred over the inners. The components using STSN
156+
// must either work with either outer or inner context, or an explicit switch mechanism
157+
// needs to be implemented (that turns the outer context on and off based on user interaction).
158+
const parentContext = useContext(SingleTabStopNavigationContext);
159+
const value = parentContext.navigationActive
160+
? parentContext
161+
: { navigationActive, registerFocusable, updateFocusTarget, resetFocusTarget };
162+
163+
// When contexts switching occurs, it is essential that the now-active one updates the focus target
164+
// to ensure the tab indices are correctly set.
165+
useEffectOnUpdate(() => {
166+
if (parentContext.navigationActive) {
167+
parentContext.resetFocusTarget();
168+
} else {
169+
resetFocusTarget();
170+
}
171+
// The updateFocusTarget and its dependencies must be pure.
172+
// eslint-disable-next-line react-hooks/exhaustive-deps
173+
}, [parentContext.navigationActive]);
174+
175+
return <SingleTabStopNavigationContext.Provider value={value}>{children}</SingleTabStopNavigationContext.Provider>;
150176
}
151177
);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { DependencyList, EffectCallback, useEffect, useRef } from 'react';
5+
6+
// useEffect, which skips the initial render
7+
export function useEffectOnUpdate(callback: EffectCallback, deps: DependencyList) {
8+
const isFirstRender = useRef<boolean>(true);
9+
10+
useEffect(() => {
11+
if (isFirstRender.current) {
12+
isFirstRender.current = false;
13+
} else {
14+
return callback();
15+
}
16+
// This is a useEffect extension, will be validated at the call site
17+
// eslint-disable-next-line react-hooks/exhaustive-deps
18+
}, deps);
19+
}

0 commit comments

Comments
 (0)