-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Katie George <[email protected]> Co-authored-by: Andrei Zhaleznichenka <[email protected]>
- Loading branch information
1 parent
5ae5804
commit 6f710c7
Showing
9 changed files
with
725 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
}); |
Oops, something went wrong.