Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 174 additions & 22 deletions src/internal/single-tab-stop/__tests__/context.test.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,58 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

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

import { SingleTabStopNavigationContext, useSingleTabStopNavigation } from '../';
import {
SingleTabStopNavigationAPI,
SingleTabStopNavigationContext,
SingleTabStopNavigationProvider,
useSingleTabStopNavigation,
} from '../';
import { renderWithSingleTabStopNavigation } from './utils';

// Simple STSN subscriber component
function Button(props: React.HTMLAttributes<HTMLButtonElement>) {
const buttonRef = useRef<HTMLButtonElement>(null);
const { tabIndex } = useSingleTabStopNavigation(buttonRef, { tabIndex: props.tabIndex });
return <button {...props} ref={buttonRef} tabIndex={tabIndex} />;
}

test('subscribed components can be rendered outside single tab stop navigation context', () => {
render(<Button />);
expect(document.querySelector('button')).not.toHaveAttribute('tabIndex');
});
// Simple STSN provider component
function Group({
id,
navigationActive,
children,
}: {
id: string;
navigationActive: boolean;
children: React.ReactNode;
}) {
const navigationAPI = useRef<SingleTabStopNavigationAPI>(null);

useEffect(() => {
navigationAPI.current?.updateFocusTarget();
});

return (
<SingleTabStopNavigationProvider
ref={navigationAPI}
navigationActive={navigationActive}
getNextFocusTarget={() => document.querySelector(`#${id}`)!.querySelectorAll('button')[0] as HTMLElement}
>
<div id={id}>
<Button>First</Button>
<Button>Second</Button>
{children}
</div>
</SingleTabStopNavigationProvider>
);
}

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(<Button id="button" />, { navigationActive: false });
Expand All @@ -34,14 +70,11 @@ test('does not override tab index for suppressed elements', () => {
</div>,
{ navigationActive: true }
);
act(() => {
setCurrentTarget(document.querySelector('#button1'), [
document.querySelector('#button1'),
document.querySelector('#button2'),
document.querySelector('#button3'),
]);
});

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');
Expand All @@ -56,9 +89,7 @@ test('overrides tab index when keyboard navigation is active', () => {
<Button id="button2" />
</div>
);
act(() => {
setCurrentTarget(document.querySelector('#button1'));
});
setCurrentTarget(document.querySelector('#button1'));
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '0');
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-1');
});
Expand All @@ -70,9 +101,7 @@ test('does not override explicit tab index with 0', () => {
<Button id="button2" tabIndex={-2} />
</div>
);
act(() => {
setCurrentTarget(document.querySelector('#button1'));
});
setCurrentTarget(document.querySelector('#button1'));
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '-2');
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-2');
});
Expand All @@ -84,7 +113,9 @@ test('propagates and suppresses navigation active state', () => {
}
function Test({ navigationActive }: { navigationActive: boolean }) {
return (
<SingleTabStopNavigationContext.Provider value={{ navigationActive, registerFocusable: () => () => {} }}>
<SingleTabStopNavigationContext.Provider
value={{ navigationActive, registerFocusable: () => () => {}, resetFocusTarget: () => {} }}
>
<Component />
</SingleTabStopNavigationContext.Provider>
);
Expand All @@ -96,3 +127,124 @@ test('propagates and suppresses navigation active state', () => {
rerender(<Test navigationActive={false} />);
expect(document.querySelector('div')).toHaveTextContent('false');
});

test('subscriber components can be used without provider', () => {
function TestComponent(props: React.HTMLAttributes<HTMLButtonElement>) {
const ref = useRef(null);
const contextResult = useContext(SingleTabStopNavigationContext);
const hookResult = useSingleTabStopNavigation(ref, { tabIndex: props.tabIndex });
useEffect(() => {
contextResult.registerFocusable(ref.current!, () => {});
contextResult.resetFocusTarget();
});
return (
<div ref={ref}>
Context: {`${contextResult.navigationActive}`}, Hook: {`${hookResult.navigationActive}:${hookResult.tabIndex}`}
</div>
);
}
const { container } = render(<TestComponent />);
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(
<Group id="outer-most" navigationActive={false}>
<Group id="outer" navigationActive={false}>
<Group id="inner" navigationActive={true}>
{null}
</Group>
</Group>
</Group>
);
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(
<Group id="outer-most" navigationActive={false}>
<Group id="outer" navigationActive={true}>
<Group id="inner" navigationActive={true}>
{null}
</Group>
</Group>
</Group>
);
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(
<Group id="outer-most" navigationActive={true}>
<Group id="outer" navigationActive={true}>
<Group id="inner" navigationActive={true}>
{null}
</Group>
</Group>
</Group>
);
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(
<Group id="outer-most" navigationActive={true}>
<Group id="outer" navigationActive={true}>
<Group id="inner" navigationActive={true}>
{null}
</Group>
</Group>
</Group>
);
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(
<Group id="outer-most" navigationActive={false}>
<Group id="outer" navigationActive={true}>
<Group id="inner" navigationActive={true}>
{null}
</Group>
</Group>
</Group>
);
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(
<Group id="outer-most" navigationActive={false}>
<Group id="outer" navigationActive={false}>
<Group id="inner" navigationActive={true}>
{null}
</Group>
</Group>
</Group>
);
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');
});
});
4 changes: 3 additions & 1 deletion src/internal/single-tab-stop/__tests__/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ const FakeSingleTabStopNavigationProvider = forwardRef(
}));

return (
<SingleTabStopNavigationContext.Provider value={{ registerFocusable, navigationActive }}>
<SingleTabStopNavigationContext.Provider
value={{ registerFocusable, navigationActive, resetFocusTarget: () => {} }}
>
{children}
</SingleTabStopNavigationContext.Provider>
);
Expand Down
54 changes: 40 additions & 14 deletions src/internal/single-tab-stop/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,19 @@ import React, {
useState,
} from 'react';

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

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

export const defaultValue: {
navigationActive: boolean;
registerFocusable(focusable: HTMLElement, handler: FocusableChangeHandler): () => void;
resetFocusTarget(): void;
} = {
navigationActive: false,
registerFocusable: () => () => {},
resetFocusTarget: () => {},
};

/**
Expand Down Expand Up @@ -99,9 +102,12 @@ 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) {
focusables.current.add(focusableElement);
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);
const newIsFocusable = focusTarget.current === focusableElement || !!isElementSuppressed?.(focusableElement);
Expand All @@ -110,42 +116,62 @@ export const SingleTabStopNavigationProvider = forwardRef(
changeHandler(newIsFocusable);
}
onRegisterFocusable?.(focusableElement);
return () => unregisterFocusable(focusableElement);
}

return () => {
parentUnregister();
unregisterFocusable(focusableElement);
};
}
function unregisterFocusable(focusableElement: Element) {
focusables.current.delete(focusableElement);
focusHandlers.current.delete(focusableElement);
onUnregisterFocusable?.(focusableElement);
}

// 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 (
<SingleTabStopNavigationContext.Provider value={{ navigationActive, registerFocusable }}>
{children}
</SingleTabStopNavigationContext.Provider>
);
// 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 <SingleTabStopNavigationContext.Provider value={value}>{children}</SingleTabStopNavigationContext.Provider>;
}
);
19 changes: 19 additions & 0 deletions src/internal/use-effect-on-update/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { DependencyList, EffectCallback, useEffect, useRef } from 'react';

// useEffect, which skips the initial render
export function useEffectOnUpdate(callback: EffectCallback, deps: DependencyList) {
const isFirstRender = useRef<boolean>(true);

useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
} else {
return callback();
}
// This is a useEffect extension, will be validated at the call site
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
Loading