Skip to content

Commit

Permalink
chore: Table grid navigation register context (#1860)
Browse files Browse the repository at this point in the history
  • Loading branch information
pan-kot authored Jan 8, 2024
1 parent 53ed8f1 commit 45e2bae
Show file tree
Hide file tree
Showing 11 changed files with 303 additions and 4 deletions.
37 changes: 37 additions & 0 deletions src/button/__tests__/button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import React from 'react';
import { act, render } from '@testing-library/react';
import Button, { ButtonProps } from '../../../lib/components/button';
import InternalButton from '../../../lib/components/button/internal';
import createWrapper, { ButtonWrapper } from '../../../lib/components/test-utils/dom';
import styles from '../../../lib/components/button/styles.css.js';
import { buttonRelExpectations, buttonTargetExpectations } from '../../__tests__/target-rel-test-helper';
import { renderWithSingleTabStopNavigation } from '../../internal/context/__tests__/utils';

function renderWrappedButton(props: ButtonProps = {}) {
const onClickSpy = jest.fn();
Expand Down Expand Up @@ -562,3 +564,38 @@ describe('Button Component', () => {
}
);
});

describe('table grid navigation support', () => {
function getButton(selector: string) {
return createWrapper().findButton(selector)!.getElement();
}

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

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

test('does not override explicit tab index with 0', () => {
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
<div>
<InternalButton id="button1" __nativeAttributes={{ tabIndex: -2 }} />
<InternalButton id="button2" __nativeAttributes={{ tabIndex: -2 }} />
</div>
);
setCurrentTarget(getButton('#button1'));
expect(getButton('#button1')).toHaveAttribute('tabIndex', '-2');
expect(getButton('#button2')).toHaveAttribute('tabIndex', '-1');
});
});
10 changes: 9 additions & 1 deletion src/button/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { FunnelMetrics } from '../internal/analytics';
import { useUniqueId } from '../internal/hooks/use-unique-id';
import { usePerformanceMarks } from '../internal/hooks/use-performance-marks';
import { useSingleTabStopNavigation } from '../internal/context/single-tab-stop-navigation-context';

export type InternalButtonProps = Omit<ButtonProps, 'variant'> & {
variant?: ButtonProps['variant'] | 'flashbar-icon' | 'breadcrumb-group' | 'menu-trigger' | 'modal-dismiss';
Expand Down Expand Up @@ -134,9 +135,16 @@ export const InternalButton = React.forwardRef(
[styles['full-width']]: shouldHaveContent && fullWidth,
});

const explicitTabIndex =
__nativeAttributes && 'tabIndex' in __nativeAttributes ? __nativeAttributes.tabIndex : undefined;
const { tabIndex } = useSingleTabStopNavigation(buttonRef, {
tabIndex: isAnchor && isNotInteractive ? -1 : explicitTabIndex,
});

const buttonProps = {
...props,
...__nativeAttributes,
tabIndex,
// https://github.com/microsoft/TypeScript/issues/36659
ref: useMergeRefs(buttonRef, __internalRootRef),
'aria-label': ariaLabel,
Expand All @@ -148,6 +156,7 @@ export const InternalButton = React.forwardRef(
onClick: handleClick,
[DATA_ATTR_FUNNEL_VALUE]: uniqueId,
} as const;

const iconProps: ButtonIconProps = {
loading,
iconName,
Expand Down Expand Up @@ -190,7 +199,6 @@ export const InternalButton = React.forwardRef(
target={target}
// security recommendation: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target
rel={rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)}
tabIndex={isNotInteractive ? -1 : undefined}
aria-disabled={isNotInteractive ? true : undefined}
download={download}
>
Expand Down
37 changes: 37 additions & 0 deletions src/checkbox/__tests__/checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { render } from '@testing-library/react';
import createWrapper, { CheckboxWrapper } from '../../../lib/components/test-utils/dom';
import FormField from '../../../lib/components/form-field';
import Checkbox, { CheckboxProps } from '../../../lib/components/checkbox';
import InternalCheckbox from '../../../lib/components/checkbox/internal';
import styles from '../../../lib/components/internal/components/checkbox-icon/styles.selectors.js';
import abstractSwitchStyles from '../../../lib/components/internal/components/abstract-switch/styles.css.js';
import { createCommonTests } from './common-tests';
import { renderWithSingleTabStopNavigation } from '../../internal/context/__tests__/utils';

function renderCheckbox(jsx: React.ReactElement) {
const { container, rerender } = render(jsx);
Expand Down Expand Up @@ -220,3 +222,38 @@ test('Should set aria-describedby and aria-labelledby from ariaLabelledby and ar
expect(checkboxInputAriaDescribedby).toBe('description-id' + ' ' + toggleDescriptionId);
expect(checkboxInputAriaLabelledby).toBe(toggleLabelId + ' ' + 'label-id');
});

describe('table grid navigation support', () => {
function getCheckboxInput(selector: string) {
return createWrapper().findCheckbox(selector)!.findNativeInput().getElement();
}

test('does not override tab index when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<Checkbox id="checkbox" checked={false} />);
expect(getCheckboxInput('#checkbox')).not.toHaveAttribute('tabIndex');
});

test('overrides tab index when keyboard navigation is active', () => {
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
<div>
<Checkbox id="checkbox1" checked={false} />
<Checkbox id="checkbox2" checked={false} />
</div>
);
setCurrentTarget(getCheckboxInput('#checkbox1'));
expect(getCheckboxInput('#checkbox1')).toHaveAttribute('tabIndex', '0');
expect(getCheckboxInput('#checkbox2')).toHaveAttribute('tabIndex', '-1');
});

test('does not override explicit tab index with 0', () => {
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
<div>
<InternalCheckbox id="checkbox1" checked={false} tabIndex={-1} />
<InternalCheckbox id="checkbox2" checked={false} tabIndex={-1} />
</div>
);
setCurrentTarget(getCheckboxInput('#checkbox1'));
expect(getCheckboxInput('#checkbox1')).toHaveAttribute('tabIndex', '-1');
expect(getCheckboxInput('#checkbox2')).toHaveAttribute('tabIndex', '-1');
});
});
5 changes: 4 additions & 1 deletion src/checkbox/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import styles from './styles.css.js';
import CheckboxIcon from '../internal/components/checkbox-icon';
import { InternalBaseComponentProps } from '../internal/hooks/use-base-component';
import { useFormFieldContext } from '../internal/context/form-field-context';
import { useSingleTabStopNavigation } from '../internal/context/single-tab-stop-navigation-context';

interface InternalProps extends CheckboxProps, InternalBaseComponentProps {
tabIndex?: -1;
Expand All @@ -32,7 +33,7 @@ const InternalCheckbox = React.forwardRef<CheckboxProps.Ref, InternalProps>(
onFocus,
onBlur,
onChange,
tabIndex,
tabIndex: explicitTabIndex,
showOutline,
ariaControls,
__internalRootRef,
Expand All @@ -50,6 +51,8 @@ const InternalCheckbox = React.forwardRef<CheckboxProps.Ref, InternalProps>(
}
});

const { tabIndex } = useSingleTabStopNavigation(checkboxRef, { tabIndex: explicitTabIndex });

return (
<AbstractSwitch
{...baseProps}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React, { useRef } from 'react';
import { render } from '@testing-library/react';
import {
SingleTabStopNavigationContext,
useSingleTabStopNavigation,
} from '../../../../lib/components/internal/context/single-tab-stop-navigation-context';
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" />);
expect(document.querySelector('#button')).not.toHaveAttribute('tabIndex');
});

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

test('propagates keyboard navigation state', () => {
function Component() {
const { navigationActive } = useSingleTabStopNavigation(null);
return <div>{String(navigationActive)}</div>;
}

const { rerender } = render(
<SingleTabStopNavigationContext.Provider value={{ navigationActive: true, focusTarget: null }}>
<Component />
</SingleTabStopNavigationContext.Provider>
);

expect(document.querySelector('div')).toHaveTextContent('true');

rerender(
<SingleTabStopNavigationContext.Provider value={{ navigationActive: false, focusTarget: null }}>
<Component />
</SingleTabStopNavigationContext.Provider>
);

expect(document.querySelector('div')).toHaveTextContent('false');
});
41 changes: 41 additions & 0 deletions src/internal/context/__tests__/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React, { createRef, forwardRef, useImperativeHandle, useState } from 'react';
import { render } from '@testing-library/react';
import { SingleTabStopNavigationContext } from '../../../../lib/components/internal/context/single-tab-stop-navigation-context';

interface ProviderRef {
setCurrentTarget(element: null | Element): void;
}

const FakeSingleTabStopNavigationProvider = forwardRef(
({ children }: { children: React.ReactNode }, ref: React.Ref<ProviderRef>) => {
const [focusTarget, setFocusTarget] = useState<null | Element>(null);

useImperativeHandle(ref, () => ({ setCurrentTarget: setFocusTarget }));

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

export function renderWithSingleTabStopNavigation(ui: React.ReactNode) {
const providerRef = createRef<ProviderRef>();
const { container, rerender } = render(
<FakeSingleTabStopNavigationProvider ref={providerRef}>{ui}</FakeSingleTabStopNavigationProvider>
);
return {
container,
rerender,
setCurrentTarget: (element: null | Element) => {
if (!providerRef.current) {
throw new Error('Provider is not ready');
}
providerRef.current.setCurrentTarget(element);
},
};
}
32 changes: 32 additions & 0 deletions src/internal/context/single-tab-stop-navigation-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { createContext, useContext } from 'react';

/**
* Single tab stop navigation context is used together with keyboard navigation that requires a single tab stop.
* It instructs interactive elements to override tab indices for just a single one to remain user-focusable.
*/
export const SingleTabStopNavigationContext = createContext<{
focusTarget: null | Element;
navigationActive: boolean;
}>({
focusTarget: null,
navigationActive: false,
});

export function useSingleTabStopNavigation(
focusable: null | React.RefObject<HTMLElement>,
options?: { tabIndex?: number }
) {
const { focusTarget, navigationActive } = useContext(SingleTabStopNavigationContext);

const focusTargetActive = Boolean(focusable && focusable.current === focusTarget);

let tabIndex: undefined | number = options?.tabIndex;
if (navigationActive) {
tabIndex = !focusTargetActive ? -1 : tabIndex ?? 0;
}

return { navigationActive, tabIndex };
}
29 changes: 29 additions & 0 deletions src/link/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { AnalyticsFunnel } from '../../../lib/components/internal/analytics/comp
import { FunnelMetrics } from '../../../lib/components/internal/analytics';

import { mockedFunnelInteractionId, mockFunnelMetrics } from '../../internal/analytics/__tests__/mocks';
import { renderWithSingleTabStopNavigation } from '../../internal/context/__tests__/utils';

function renderLink(props: LinkProps = {}) {
const renderResult = render(<Link {...props} />);
Expand Down Expand Up @@ -274,3 +275,31 @@ describe('Link component', () => {
});
});
});

describe('table grid navigation support', () => {
function getLink(selector: string) {
return createWrapper().findLink(selector)!.getElement();
}

test('does not override tab index for button link when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<Link id="link" />);
expect(getLink('#link')).toHaveAttribute('tabIndex', '0');
});

test('does not override tab index for anchor link when keyboard navigation is not active', () => {
renderWithSingleTabStopNavigation(<Link id="link" href="#" />);
expect(getLink('#link')).not.toHaveAttribute('tabIndex');
});

test.each([undefined, '#'])('overrides tab index when keyboard navigation is active href=%s', href => {
const { setCurrentTarget } = renderWithSingleTabStopNavigation(
<div>
<Link id="link1" href={href} />
<Link id="link2" href={href} />
</div>
);
setCurrentTarget(getLink('#link1'));
expect(getLink('#link1')).toHaveAttribute('tabIndex', '0');
expect(getLink('#link2')).toHaveAttribute('tabIndex', '-1');
});
});
20 changes: 18 additions & 2 deletions src/link/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
getSubStepAllSelector,
} from '../internal/analytics/selectors';
import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context';
import { useSingleTabStopNavigation } from '../internal/context/single-tab-stop-navigation-context';

type InternalLinkProps = InternalBaseComponentProps<HTMLAnchorElement> &
Omit<LinkProps, 'variant'> & {
Expand Down Expand Up @@ -184,9 +185,17 @@ const InternalLink = React.forwardRef(
</>
);

const { tabIndex } = useSingleTabStopNavigation(linkRef, { tabIndex: isButton ? 0 : undefined });

if (isButton) {
return (
<a {...sharedProps} role="button" tabIndex={0} onKeyDown={handleButtonKeyDown} onClick={handleButtonClick}>
<a
{...sharedProps}
role="button"
tabIndex={tabIndex}
onKeyDown={handleButtonKeyDown}
onClick={handleButtonClick}
>
{content}
</a>
);
Expand All @@ -195,7 +204,14 @@ const InternalLink = React.forwardRef(
return (
// we dynamically set proper rel in the code above
// eslint-disable-next-line react/jsx-no-target-blank
<a {...sharedProps} target={anchorTarget} rel={anchorRel} href={href} onClick={handleLinkClick}>
<a
{...sharedProps}
tabIndex={tabIndex}
target={anchorTarget}
rel={anchorRel}
href={href}
onClick={handleLinkClick}
>
{content}
</a>
);
Expand Down
Loading

0 comments on commit 45e2bae

Please sign in to comment.