From 6f710c74d06d4fdd4729cbd9b89ea3109ec51ff0 Mon Sep 17 00:00:00 2001 From: Katie George Date: Wed, 22 Jan 2025 08:17:08 -0800 Subject: [PATCH] feat: Single tab stop util (#112) Co-authored-by: Katie George Co-authored-by: Andrei Zhaleznichenka --- src/dom/__tests__/node-belongs.test.ts | 67 +++++++ src/dom/node-belongs.ts | 29 +++ src/internal/focus-lock-utils/utils.ts | 48 +++++ .../__tests__/context.test.tsx | 98 ++++++++++ .../__tests__/register.test.tsx | 171 ++++++++++++++++++ .../single-tab-stop/__tests__/utils.tsx | 66 +++++++ src/internal/single-tab-stop/index.tsx | 151 ++++++++++++++++ src/internal/utils/circle-index.ts | 13 ++ src/internal/utils/handle-key.ts | 82 +++++++++ 9 files changed, 725 insertions(+) create mode 100644 src/dom/__tests__/node-belongs.test.ts create mode 100644 src/dom/node-belongs.ts create mode 100644 src/internal/focus-lock-utils/utils.ts create mode 100644 src/internal/single-tab-stop/__tests__/context.test.tsx create mode 100644 src/internal/single-tab-stop/__tests__/register.test.tsx create mode 100644 src/internal/single-tab-stop/__tests__/utils.tsx create mode 100644 src/internal/single-tab-stop/index.tsx create mode 100644 src/internal/utils/circle-index.ts create mode 100644 src/internal/utils/handle-key.ts diff --git a/src/dom/__tests__/node-belongs.test.ts b/src/dom/__tests__/node-belongs.test.ts new file mode 100644 index 0000000..950597c --- /dev/null +++ b/src/dom/__tests__/node-belongs.test.ts @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { nodeBelongs } from '../node-belongs'; + +describe('nodeBelongs', () => { + let div: HTMLDivElement; + + beforeEach(() => { + div = document.createElement('div'); + document.documentElement.appendChild(div); + }); + + afterEach(() => document.documentElement.removeChild(div)); + + test('returns "true", when the node and the container are the same element', () => { + div.innerHTML = ` +
+ `; + expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#container1') as Node)).toBe(true); + }); + + test('returns "true", when the node is descendant from the container', () => { + div.innerHTML = ` +
+
+
+ `; + expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#node') as Node)).toBe(true); + }); + + test('returns "false", when the node is not a child of the container', () => { + div.innerHTML = ` +
+
+ `; + expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#node') as Node)).toBe(false); + }); + + test('returns "true" when node belongs to a portal issued from within the container', () => { + div.innerHTML = ` +
+
+
+
+
+
+ `; + expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#node') as Node)).toBe(true); + }); + + test('returns "true" when the node is a descendant of the container, both inside a portal', () => { + div.innerHTML = ` +
+
+
+
+
+
+ `; + expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#node') as Node)).toBe(true); + }); + + test('returns false if target is not a node', () => { + expect(nodeBelongs(div.querySelector('#container1'), {} as any)).toBe(false); + }); +}); diff --git a/src/dom/node-belongs.ts b/src/dom/node-belongs.ts new file mode 100644 index 0000000..5ffc3ba --- /dev/null +++ b/src/dom/node-belongs.ts @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { findUpUntil, nodeContains } from '.'; + +import { isHTMLElement, isNode } from './element-types'; + +/** + * Checks whether the given node (target) belongs to the container. + * The function is similar to nodeContains but also accounts for dropdowns with expandToViewport=true. + * + * @param container Container node + * @param target Node that is checked to be a descendant of the container + */ +export function nodeBelongs(container: Node | null, target: Node | EventTarget | null): boolean { + if (!isNode(target)) { + return false; + } + const portal = findUpUntil( + target as HTMLElement, + node => node === container || (isHTMLElement(node) && !!node.dataset.awsuiReferrerId) + ); + if (portal && portal === container) { + // We found the container as a direct ancestor without a portal + return true; + } + const referrer = isHTMLElement(portal) ? document.getElementById(portal.dataset.awsuiReferrerId ?? '') : null; + return referrer ? nodeContains(container, referrer) : nodeContains(container, target); +} diff --git a/src/internal/focus-lock-utils/utils.ts b/src/internal/focus-lock-utils/utils.ts new file mode 100644 index 0000000..d4c11eb --- /dev/null +++ b/src/internal/focus-lock-utils/utils.ts @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Credits to +// https://github.com/theKashey/focus-lock/blob/33f8b4bd9675d2605b15e2e4015b77fe35fbd6d0/src/utils/tabbables.ts +const tabbables = [ + 'button:enabled', + 'select:enabled', + 'textarea:enabled', + 'input:enabled', + + 'a[href]', + 'area[href]', + + 'summary', + 'iframe', + 'object', + 'embed', + + 'audio[controls]', + 'video[controls]', + + '[tabindex]', + '[contenteditable]', + '[autofocus]', +].join(','); + +export function isFocusable(element: HTMLElement): boolean { + return element.matches(tabbables); +} + +export function getAllFocusables(container: HTMLElement): HTMLElement[] { + return Array.prototype.slice.call(container.querySelectorAll(tabbables)); +} + +function getFocusables(container: HTMLElement): HTMLElement[] { + return getAllFocusables(container).filter((element: HTMLElement) => element.tabIndex !== -1); +} + +export function getFirstFocusable(container: HTMLElement): null | HTMLElement { + const focusables = getFocusables(container); + return focusables[0] ?? null; +} + +export function getLastFocusable(container: HTMLElement): null | HTMLElement { + const focusables = getFocusables(container); + return focusables[focusables.length - 1] ?? null; +} diff --git a/src/internal/single-tab-stop/__tests__/context.test.tsx b/src/internal/single-tab-stop/__tests__/context.test.tsx new file mode 100644 index 0000000..7b812c0 --- /dev/null +++ b/src/internal/single-tab-stop/__tests__/context.test.tsx @@ -0,0 +1,98 @@ +// 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 { SingleTabStopNavigationContext, useSingleTabStopNavigation } from '../'; +import { renderWithSingleTabStopNavigation } from './utils'; + +function Button(props: React.HTMLAttributes) { + const buttonRef = useRef(null); + const { tabIndex } = useSingleTabStopNavigation(buttonRef, { tabIndex: props.tabIndex }); + return + ))} + + + + ); +} + +function getButton(testId: string) { + return document.querySelector(`[data-testid="${testId}"]`) as null | HTMLElement; +} + +test('registers focusable elements', () => { + const apiRef = createRef(); + const { rerender } = render( + getButton('A')} apiRef={apiRef} /> + ); + const buttons = [getButton('A')!, getButton('B')!, getButton('C')!, getButton('X')!]; + + expect(apiRef.current!.isRegistered(buttons[0])).toBe(true); + expect(apiRef.current!.isRegistered(buttons[1])).toBe(true); + expect(apiRef.current!.isRegistered(buttons[2])).toBe(true); + expect(apiRef.current!.isRegistered(buttons[3])).toBe(false); + + rerender( getButton('A')} apiRef={apiRef} />); + + expect(apiRef.current!.isRegistered(buttons[0])).toBe(true); + expect(apiRef.current!.isRegistered(buttons[1])).toBe(true); + expect(apiRef.current!.isRegistered(buttons[2])).toBe(false); + expect(apiRef.current!.isRegistered(buttons[3])).toBe(false); +}); + +test('updates and retrieves focus target', () => { + const focusTarget = { current: 'A' }; + const apiRef = createRef(); + render( + getButton(focusTarget.current)} apiRef={apiRef} /> + ); + + expect(getButton('A')).toHaveAttribute('tabIndex', '-1'); + expect(getButton('B')).toHaveAttribute('tabIndex', '-1'); + expect(getButton('X')).toHaveAttribute('tabIndex', '0'); + + apiRef.current!.updateFocusTarget(); + expect(apiRef.current!.getFocusTarget()).toBe(getButton('A')); + expect(getButton('A')).toHaveAttribute('tabIndex', '0'); + expect(getButton('B')).toHaveAttribute('tabIndex', '-1'); + expect(getButton('X')).toHaveAttribute('tabIndex', '0'); + + focusTarget.current = 'B'; + apiRef.current!.updateFocusTarget(); + expect(apiRef.current!.getFocusTarget()).toBe(getButton('B')); + expect(getButton('A')).toHaveAttribute('tabIndex', '-1'); + expect(getButton('B')).toHaveAttribute('tabIndex', '0'); + expect(getButton('X')).toHaveAttribute('tabIndex', '0'); + + focusTarget.current = 'X'; + apiRef.current!.updateFocusTarget(); + expect(apiRef.current!.getFocusTarget()).toBe(getButton('X')); + expect(getButton('A')).toHaveAttribute('tabIndex', '-1'); + expect(getButton('B')).toHaveAttribute('tabIndex', '-1'); + expect(getButton('X')).toHaveAttribute('tabIndex', '0'); +}); + +test('ignores elements that are suppressed', () => { + const apiRef = createRef(); + const { rerender } = render( + getButton('A')} apiRef={apiRef} /> + ); + + apiRef.current!.updateFocusTarget(); + expect(getButton('A')).toHaveAttribute('tabIndex', '0'); + expect(getButton('B')).toHaveAttribute('tabIndex', '-1'); + + rerender( + getButton('A')} + isElementSuppressed={element => element === getButton('B')} + apiRef={apiRef} + /> + ); + expect(getButton('A')).toHaveAttribute('tabIndex', '0'); + expect(getButton('B')).toHaveAttribute('tabIndex', '0'); +}); + +test('calls onUnregisterActive', async () => { + const apiRef = createRef(); + const onUnregisterActive = jest.fn(); + const { rerender } = render( + getButton('A')} + onUnregisterActive={onUnregisterActive} + apiRef={apiRef} + /> + ); + + getButton('A')!.focus(); + + rerender( + getButton('A')} + onUnregisterActive={onUnregisterActive} + apiRef={apiRef} + /> + ); + await waitFor(() => { + expect(onUnregisterActive).toHaveBeenCalledWith(getButton('A')); + }); +}); + +test('context defaults', () => { + function Button() { + const context = useContext(SingleTabStopNavigationContext); + // Checking the default registerFocusable is defined. + context.registerFocusable(document.createElement('div'), () => {})(); + return ; + } + + render(