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 ;
+}
+
+test('does not override tab index when keyboard navigation is not active', () => {
+ renderWithSingleTabStopNavigation(, { navigationActive: false });
+ expect(document.querySelector('#button')).not.toHaveAttribute('tabIndex');
+});
+
+test('does not override tab index for suppressed elements', () => {
+ const { setCurrentTarget } = renderWithSingleTabStopNavigation(
+