Skip to content
Closed
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
63 changes: 63 additions & 0 deletions src/dom/__tests__/node-belongs.test.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<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);
});
});
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's take an action to reuse the nodeBelongs, handleKey, isNode, and other utils that are now part of the component-toolkit in the components and, potentially, other repos.

if (!isNode(target)) {
return false;

Check warning on line 17 in src/dom/node-belongs.ts

View check run for this annotation

Codecov / codecov/patch

src/dom/node-belongs.ts#L17

Added line #L17 was not covered by tests
}
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.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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we call this focus utils instead? This file lives in the focus-lock folder, but we did not carry over the focus lock component to the toolkit.

// 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;
}
61 changes: 61 additions & 0 deletions src/internal/single-tab-stop/__integ__/single-tab-stop.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) => {
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');
})
);
});
93 changes: 93 additions & 0 deletions src/internal/single-tab-stop/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>) {
const buttonRef = useRef<HTMLButtonElement>(null);
const { tabIndex } = useSingleTabStopNavigation(buttonRef, { tabIndex: props.tabIndex });
return <button {...props} ref={buttonRef} tabIndex={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');
});
66 changes: 66 additions & 0 deletions src/internal/single-tab-stop/__tests__/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React, { createRef, forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { render } from '@testing-library/react';

import { FocusableChangeHandler, SingleTabStopNavigationContext } from '../';

interface ProviderRef {
setCurrentTarget(focusTarget: null | Element, suppressed?: (null | Element)[]): void;
}

const FakeSingleTabStopNavigationProvider = forwardRef(
(
{ children, navigationActive }: { children: React.ReactNode; navigationActive: boolean },
ref: React.Ref<ProviderRef>
) => {
const focusablesRef = useRef(new Set<Element>());
const focusHandlersRef = useRef(new Map<Element, FocusableChangeHandler>());
const registerFocusable = useCallback((focusable: HTMLElement, changeHandler: FocusableChangeHandler) => {
focusablesRef.current.add(focusable);
focusHandlersRef.current.set(focusable, changeHandler);
return () => {
focusablesRef.current.delete(focusable);
focusHandlersRef.current.delete(focusable);
};
}, []);

useImperativeHandle(ref, () => ({
setCurrentTarget: (focusTarget: null | Element, suppressed: Element[] = []) => {
focusablesRef.current.forEach(focusable => {
const handler = focusHandlersRef.current.get(focusable)!;
handler(focusTarget === focusable || suppressed.includes(focusable));
});
},
}));

return (
<SingleTabStopNavigationContext.Provider value={{ registerFocusable, navigationActive }}>
{children}
</SingleTabStopNavigationContext.Provider>
);
}
);

export function renderWithSingleTabStopNavigation(
ui: React.ReactNode,
{ navigationActive = true }: { navigationActive?: boolean } = {}
) {
const providerRef = createRef<ProviderRef>();
const { container, rerender } = render(
<FakeSingleTabStopNavigationProvider ref={providerRef} navigationActive={navigationActive}>
{ui}
</FakeSingleTabStopNavigationProvider>
);
return {
container,
rerender,
setCurrentTarget: (focusTarget: null | Element, suppressed: (null | Element)[] = []) => {
if (!providerRef.current) {
throw new Error('Provider is not ready');
}
providerRef.current.setCurrentTarget(focusTarget, suppressed);
},
};
}
Loading
Loading