diff --git a/src/dom/__tests__/node-belongs.test.ts b/src/dom/__tests__/node-belongs.test.ts new file mode 100644 index 0000000..7289742 --- /dev/null +++ b/src/dom/__tests__/node-belongs.test.ts @@ -0,0 +1,63 @@ +// 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); + }); +}); 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.ts b/src/internal/focus-lock/utils.ts new file mode 100644 index 0000000..d4c11eb --- /dev/null +++ b/src/internal/focus-lock/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/__integ__/single-tab-stop.test.ts b/src/internal/single-tab-stop/__integ__/single-tab-stop.test.ts new file mode 100644 index 0000000..cf572d0 --- /dev/null +++ b/src/internal/single-tab-stop/__integ__/single-tab-stop.test.ts @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; + +class SingleTabStopPage extends BasePageObject { + getActiveElementId() { + return this.browser.execute(function () { + return document.activeElement?.id; + }); + } +} + +describe('single tab stop', () => { + const setupTest = (testFn: (page: SingleTabStopPage) => Promise) => { + return useBrowser(async browser => { + const page = new SingleTabStopPage(browser); + await browser.url('/single-tab-stop.page'); + await testFn(page); + }); + }; + + const beforeButton = 'button:nth-of-type(1)'; + + test( + 'Expect focus to work as expected for horizontal buttons', + setupTest(async page => { + await page.click(beforeButton); + await page.keys('Tab'); + await expect(page.getActiveElementId()).resolves.toBe('one'); + + await page.keys(['ArrowRight', 'ArrowRight', 'ArrowRight', 'ArrowRight']); + await expect(page.getActiveElementId()).resolves.toBe('two'); + }) + ); + + test( + 'Expect focus to work as expected for vertical buttons', + setupTest(async page => { + await page.click(beforeButton); + await page.keys(['Tab', 'Tab']); + await expect(page.getActiveElementId()).resolves.toBe('four'); + + await page.keys(['ArrowDown', 'ArrowDown', 'ArrowDown', 'ArrowDown']); + await expect(page.getActiveElementId()).resolves.toBe('five'); + }) + ); + + test( + 'Expect focus to not move with horizontal arrow keys for vertical buttons', + setupTest(async page => { + await page.click(beforeButton); + await page.keys(['Tab', 'Tab']); + await expect(page.getActiveElementId()).resolves.toBe('four'); + + await page.keys('ArrowRight'); + await expect(page.getActiveElementId()).resolves.toBe('four'); + }) + ); +}); diff --git a/src/internal/single-tab-stop/__tests__/index.test.tsx b/src/internal/single-tab-stop/__tests__/index.test.tsx new file mode 100644 index 0000000..af9748f --- /dev/null +++ b/src/internal/single-tab-stop/__tests__/index.test.tsx @@ -0,0 +1,93 @@ +// 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 + + Horizontal: + (itemsRef.current.one = element)}> + One + + (itemsRef.current.two = element)}> + Two + + (itemsRef.current.three = element)}> + Three + + + + Vertical: +
+ (itemsRef.current.four = element)}> + Four + + (itemsRef.current.five = element)}> + Five + + (itemsRef.current.six = element)}> + Six + +
+
+ + + ); +} + +const TestButton = React.forwardRef( + ({ id, children }: { id: string; children: string }, ref: React.Ref) => { + const buttonRef = React.useRef(null); + const { tabIndex } = useSingleTabStopNavigation(buttonRef); + useForwardFocus(ref, buttonRef); + + return ( + + ); + } +);