Skip to content

Commit

Permalink
feat: Single tab stop util (#112)
Browse files Browse the repository at this point in the history
Co-authored-by: Katie George <[email protected]>
Co-authored-by: Andrei Zhaleznichenka <[email protected]>
  • Loading branch information
3 people authored Jan 22, 2025
1 parent 5ae5804 commit 6f710c7
Show file tree
Hide file tree
Showing 9 changed files with 725 additions and 0 deletions.
67 changes: 67 additions & 0 deletions src/dom/__tests__/node-belongs.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div id="container1"></div>
`;
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 = `
<div id="container1">
<div id="node"></div>
</div>
`;
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 = `
<div id="container1"></div>
<div id="node"></div>
`;
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 = `
<div id="container1">
<div id="portal"></div>
</div>
<div data-awsui-referrer-id="portal">
<div id="node"></div>
</div>
`;
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 = `
<div id="portal"></div>
<div data-awsui-referrer-id="portal">
<div id="container1">
<div id="node"></div>
</div>
</div>
`;
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);
});
});
29 changes: 29 additions & 0 deletions src/dom/node-belongs.ts
Original file line number Diff line number Diff line change
@@ -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);
}
48 changes: 48 additions & 0 deletions src/internal/focus-lock-utils/utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
98 changes: 98 additions & 0 deletions src/internal/single-tab-stop/__tests__/context.test.tsx
Original file line number Diff line number Diff line change
@@ -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<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');
});

test('does not override tab index when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<Button id="button" />, { navigationActive: false });
expect(document.querySelector('#button')).not.toHaveAttribute('tabIndex');
});

test('does not override tab index for suppressed elements', () => {
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
<div>
<Button id="button1" />
<Button id="button2" />
<Button id="button3" tabIndex={-1} />
<Button id="button4" />
<Button id="button5" tabIndex={-1} />
</div>,
{ navigationActive: true }
);
act(() => {
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');
expect(document.querySelector('#button4')).toHaveAttribute('tabIndex', '-1');
expect(document.querySelector('#button5')).toHaveAttribute('tabIndex', '-1');
});

test('overrides tab index when keyboard navigation is active', () => {
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
<div>
<Button id="button1" />
<Button id="button2" />
</div>
);
act(() => {
setCurrentTarget(document.querySelector('#button1'));
});
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '0');
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-1');
});

test('does not override explicit tab index with 0', () => {
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
<div>
<Button id="button1" tabIndex={-2} />
<Button id="button2" tabIndex={-2} />
</div>
);
act(() => {
setCurrentTarget(document.querySelector('#button1'));
});
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '-2');
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-2');
});

test('propagates and suppresses navigation active state', () => {
function Component() {
const { navigationActive } = useSingleTabStopNavigation(null);
return <div>{String(navigationActive)}</div>;
}
function Test({ navigationActive }: { navigationActive: boolean }) {
return (
<SingleTabStopNavigationContext.Provider value={{ navigationActive, registerFocusable: () => () => {} }}>
<Component />
</SingleTabStopNavigationContext.Provider>
);
}

const { rerender } = render(<Test navigationActive={true} />);
expect(document.querySelector('div')).toHaveTextContent('true');

rerender(<Test navigationActive={false} />);
expect(document.querySelector('div')).toHaveTextContent('false');
});
Loading

0 comments on commit 6f710c7

Please sign in to comment.