Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Single tab stop util #112

Merged
merged 8 commits into from
Jan 22, 2025
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
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
Loading