Skip to content

Commit 6f710c7

Browse files
katiegeorgeKatie Georgepan-kot
authored
feat: Single tab stop util (#112)
Co-authored-by: Katie George <[email protected]> Co-authored-by: Andrei Zhaleznichenka <[email protected]>
1 parent 5ae5804 commit 6f710c7

File tree

9 files changed

+725
-0
lines changed

9 files changed

+725
-0
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { nodeBelongs } from '../node-belongs';
5+
6+
describe('nodeBelongs', () => {
7+
let div: HTMLDivElement;
8+
9+
beforeEach(() => {
10+
div = document.createElement('div');
11+
document.documentElement.appendChild(div);
12+
});
13+
14+
afterEach(() => document.documentElement.removeChild(div));
15+
16+
test('returns "true", when the node and the container are the same element', () => {
17+
div.innerHTML = `
18+
<div id="container1"></div>
19+
`;
20+
expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#container1') as Node)).toBe(true);
21+
});
22+
23+
test('returns "true", when the node is descendant from the container', () => {
24+
div.innerHTML = `
25+
<div id="container1">
26+
<div id="node"></div>
27+
</div>
28+
`;
29+
expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#node') as Node)).toBe(true);
30+
});
31+
32+
test('returns "false", when the node is not a child of the container', () => {
33+
div.innerHTML = `
34+
<div id="container1"></div>
35+
<div id="node"></div>
36+
`;
37+
expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#node') as Node)).toBe(false);
38+
});
39+
40+
test('returns "true" when node belongs to a portal issued from within the container', () => {
41+
div.innerHTML = `
42+
<div id="container1">
43+
<div id="portal"></div>
44+
</div>
45+
<div data-awsui-referrer-id="portal">
46+
<div id="node"></div>
47+
</div>
48+
`;
49+
expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#node') as Node)).toBe(true);
50+
});
51+
52+
test('returns "true" when the node is a descendant of the container, both inside a portal', () => {
53+
div.innerHTML = `
54+
<div id="portal"></div>
55+
<div data-awsui-referrer-id="portal">
56+
<div id="container1">
57+
<div id="node"></div>
58+
</div>
59+
</div>
60+
`;
61+
expect(nodeBelongs(div.querySelector('#container1'), div.querySelector('#node') as Node)).toBe(true);
62+
});
63+
64+
test('returns false if target is not a node', () => {
65+
expect(nodeBelongs(div.querySelector('#container1'), {} as any)).toBe(false);
66+
});
67+
});

src/dom/node-belongs.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { findUpUntil, nodeContains } from '.';
5+
6+
import { isHTMLElement, isNode } from './element-types';
7+
8+
/**
9+
* Checks whether the given node (target) belongs to the container.
10+
* The function is similar to nodeContains but also accounts for dropdowns with expandToViewport=true.
11+
*
12+
* @param container Container node
13+
* @param target Node that is checked to be a descendant of the container
14+
*/
15+
export function nodeBelongs(container: Node | null, target: Node | EventTarget | null): boolean {
16+
if (!isNode(target)) {
17+
return false;
18+
}
19+
const portal = findUpUntil(
20+
target as HTMLElement,
21+
node => node === container || (isHTMLElement(node) && !!node.dataset.awsuiReferrerId)
22+
);
23+
if (portal && portal === container) {
24+
// We found the container as a direct ancestor without a portal
25+
return true;
26+
}
27+
const referrer = isHTMLElement(portal) ? document.getElementById(portal.dataset.awsuiReferrerId ?? '') : null;
28+
return referrer ? nodeContains(container, referrer) : nodeContains(container, target);
29+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Credits to
5+
// https://github.com/theKashey/focus-lock/blob/33f8b4bd9675d2605b15e2e4015b77fe35fbd6d0/src/utils/tabbables.ts
6+
const tabbables = [
7+
'button:enabled',
8+
'select:enabled',
9+
'textarea:enabled',
10+
'input:enabled',
11+
12+
'a[href]',
13+
'area[href]',
14+
15+
'summary',
16+
'iframe',
17+
'object',
18+
'embed',
19+
20+
'audio[controls]',
21+
'video[controls]',
22+
23+
'[tabindex]',
24+
'[contenteditable]',
25+
'[autofocus]',
26+
].join(',');
27+
28+
export function isFocusable(element: HTMLElement): boolean {
29+
return element.matches(tabbables);
30+
}
31+
32+
export function getAllFocusables(container: HTMLElement): HTMLElement[] {
33+
return Array.prototype.slice.call(container.querySelectorAll(tabbables));
34+
}
35+
36+
function getFocusables(container: HTMLElement): HTMLElement[] {
37+
return getAllFocusables(container).filter((element: HTMLElement) => element.tabIndex !== -1);
38+
}
39+
40+
export function getFirstFocusable(container: HTMLElement): null | HTMLElement {
41+
const focusables = getFocusables(container);
42+
return focusables[0] ?? null;
43+
}
44+
45+
export function getLastFocusable(container: HTMLElement): null | HTMLElement {
46+
const focusables = getFocusables(container);
47+
return focusables[focusables.length - 1] ?? null;
48+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useRef } from 'react';
5+
import { render, act } from '@testing-library/react';
6+
7+
import { SingleTabStopNavigationContext, useSingleTabStopNavigation } from '../';
8+
import { renderWithSingleTabStopNavigation } from './utils';
9+
10+
function Button(props: React.HTMLAttributes<HTMLButtonElement>) {
11+
const buttonRef = useRef<HTMLButtonElement>(null);
12+
const { tabIndex } = useSingleTabStopNavigation(buttonRef, { tabIndex: props.tabIndex });
13+
return <button {...props} ref={buttonRef} tabIndex={tabIndex} />;
14+
}
15+
16+
test('subscribed components can be rendered outside single tab stop navigation context', () => {
17+
render(<Button />);
18+
expect(document.querySelector('button')).not.toHaveAttribute('tabIndex');
19+
});
20+
21+
test('does not override tab index when keyboard navigation is not active', () => {
22+
renderWithSingleTabStopNavigation(<Button id="button" />, { navigationActive: false });
23+
expect(document.querySelector('#button')).not.toHaveAttribute('tabIndex');
24+
});
25+
26+
test('does not override tab index for suppressed elements', () => {
27+
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
28+
<div>
29+
<Button id="button1" />
30+
<Button id="button2" />
31+
<Button id="button3" tabIndex={-1} />
32+
<Button id="button4" />
33+
<Button id="button5" tabIndex={-1} />
34+
</div>,
35+
{ navigationActive: true }
36+
);
37+
act(() => {
38+
setCurrentTarget(document.querySelector('#button1'), [
39+
document.querySelector('#button1'),
40+
document.querySelector('#button2'),
41+
document.querySelector('#button3'),
42+
]);
43+
});
44+
45+
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '0');
46+
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '0');
47+
expect(document.querySelector('#button3')).toHaveAttribute('tabIndex', '-1');
48+
expect(document.querySelector('#button4')).toHaveAttribute('tabIndex', '-1');
49+
expect(document.querySelector('#button5')).toHaveAttribute('tabIndex', '-1');
50+
});
51+
52+
test('overrides tab index when keyboard navigation is active', () => {
53+
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
54+
<div>
55+
<Button id="button1" />
56+
<Button id="button2" />
57+
</div>
58+
);
59+
act(() => {
60+
setCurrentTarget(document.querySelector('#button1'));
61+
});
62+
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '0');
63+
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-1');
64+
});
65+
66+
test('does not override explicit tab index with 0', () => {
67+
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
68+
<div>
69+
<Button id="button1" tabIndex={-2} />
70+
<Button id="button2" tabIndex={-2} />
71+
</div>
72+
);
73+
act(() => {
74+
setCurrentTarget(document.querySelector('#button1'));
75+
});
76+
expect(document.querySelector('#button1')).toHaveAttribute('tabIndex', '-2');
77+
expect(document.querySelector('#button2')).toHaveAttribute('tabIndex', '-2');
78+
});
79+
80+
test('propagates and suppresses navigation active state', () => {
81+
function Component() {
82+
const { navigationActive } = useSingleTabStopNavigation(null);
83+
return <div>{String(navigationActive)}</div>;
84+
}
85+
function Test({ navigationActive }: { navigationActive: boolean }) {
86+
return (
87+
<SingleTabStopNavigationContext.Provider value={{ navigationActive, registerFocusable: () => () => {} }}>
88+
<Component />
89+
</SingleTabStopNavigationContext.Provider>
90+
);
91+
}
92+
93+
const { rerender } = render(<Test navigationActive={true} />);
94+
expect(document.querySelector('div')).toHaveTextContent('true');
95+
96+
rerender(<Test navigationActive={false} />);
97+
expect(document.querySelector('div')).toHaveTextContent('false');
98+
});

0 commit comments

Comments
 (0)