Skip to content

Commit

Permalink
Merge pull request #2555 from Shopify/trapping-update
Browse files Browse the repository at this point in the history
[TrapFocus] Fix tab focusing
  • Loading branch information
dleroux authored Jan 10, 2020
2 parents 7d560a4 + ac5ca5d commit 5223276
Show file tree
Hide file tree
Showing 12 changed files with 447 additions and 100 deletions.
1 change: 1 addition & 0 deletions UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- Ensure the normalizedValue within `TextField` is a string (this was already ensured through type-checking at the TypeScript level, but people could force through with casting to `any`, which caused problems) ([#2598](https://github.com/Shopify/polaris-react/pull/2598))

- Fixed an issue with the `Filters` component where the `aria-expanded` attribute was `undefined` on mount ([#2589]https://github.com/Shopify/polaris-react/pull/2589)
- Fixed `TrapFocus` from tabbing out of the container ([#2555](https://github.com/Shopify/polaris-react/pull/2555))

### Documentation

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
"@babel/runtime": "^7.1.6",
"@material-ui/react-transition-group": "^4.2.0",
"@shopify/app-bridge": "^1.3.0",
"@shopify/javascript-utilities": "^2.2.1",
"@shopify/javascript-utilities": "^2.4.1",
"@shopify/polaris-icons": "^3.3.0",
"@shopify/polaris-tokens": "^2.6.0",
"@shopify/useful-types": "^1.2.4",
Expand Down
40 changes: 21 additions & 19 deletions src/components/KeypressListener/KeypressListener.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import {useEffect} from 'react';
import {
addEventListener,
removeEventListener,
Expand All @@ -8,29 +8,31 @@ import {Key} from '../../types';
export interface KeypressListenerProps {
keyCode: Key;
handler(event: KeyboardEvent): void;
keyEvent?: KeyEvent;
}

export class KeypressListener extends React.Component<
KeypressListenerProps,
never
> {
componentDidMount() {
addEventListener(document, 'keyup', this.handleKeyEvent);
}

componentWillUnmount() {
removeEventListener(document, 'keyup', this.handleKeyEvent);
}

render() {
return null;
}

private handleKeyEvent = (event: KeyboardEvent) => {
const {keyCode, handler} = this.props;
export enum KeyEvent {
KeyDown = 'keydown',
KeyUp = 'keyup',
}

export function KeypressListener({
keyCode,
handler,
keyEvent = KeyEvent.KeyUp,
}: KeypressListenerProps) {
const handleKeyEvent = (event: KeyboardEvent) => {
if (event.keyCode === keyCode) {
handler(event);
}
};

useEffect(() => {
addEventListener(document, keyEvent, handleKeyEvent);
return () => {
removeEventListener(document, keyEvent, handleKeyEvent);
};
});

return null;
}
6 changes: 5 additions & 1 deletion src/components/KeypressListener/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
export {KeypressListener, KeypressListenerProps} from './KeypressListener';
export {
KeypressListener,
KeypressListenerProps,
KeyEvent,
} from './KeypressListener';
7 changes: 3 additions & 4 deletions src/components/Modal/components/Dialog/Dialog.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ $large-width: rem(980px);
justify-content: center;
}
}
.Dialog:focus {
outline: 0;
}

.Modal {
position: fixed;
Expand All @@ -44,10 +47,6 @@ $large-width: rem(980px);
max-height: 100%;
}

&:focus {
outline: 0;
}

@include breakpoint-after(layout-width(page-with-nav)) {
position: relative;
max-width: $small-width;
Expand Down
22 changes: 15 additions & 7 deletions src/components/Modal/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React, {useRef, useCallback} from 'react';
import {durationBase} from '@shopify/polaris-tokens';
import {Transition, CSSTransition} from '@material-ui/react-transition-group';
import {focusFirstFocusableNode} from '@shopify/javascript-utilities/focus';
import {classNames} from '../../../../utilities/css';

import {AnimationProps, Key} from '../../../../types';

import {KeypressListener} from '../../../KeypressListener';
import {TrapFocus} from '../../../TrapFocus';

import {useComponentDidMount} from '../../../../utilities/use-component-did-mount';
import styles from './Dialog.scss';

export interface BaseDialogProps {
Expand Down Expand Up @@ -43,6 +45,10 @@ export function Dialog({
);
const TransitionChild = instant ? Transition : FadeUp;

useComponentDidMount(() => {
containerNode.current && focusFirstFocusableNode(containerNode.current);
});

return (
<TransitionChild
{...props}
Expand All @@ -61,17 +67,19 @@ export function Dialog({
>
<TrapFocus>
<div
className={classes}
role="dialog"
aria-labelledby={labelledBy}
tabIndex={-1}
className={styles.Dialog}
>
<KeypressListener
keyCode={Key.Escape}
handler={onClose}
testID="CloseKeypressListener"
/>
{children}
<div className={classes}>
<KeypressListener
keyCode={Key.Escape}
handler={onClose}
testID="CloseKeypressListener"
/>
{children}
</div>
</div>
</TrapFocus>
</div>
Expand Down
8 changes: 8 additions & 0 deletions src/components/Modal/tests/Modal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import {Modal as AppBridgeModal} from '@shopify/app-bridge/actions';
import {findFirstFocusableNode} from '@shopify/javascript-utilities/focus';
import {animationFrame} from '@shopify/jest-dom-mocks';
// eslint-disable-next-line no-restricted-imports
import {
Expand Down Expand Up @@ -53,6 +54,13 @@ describe('<Modal>', () => {
);
});

it('focuses the next focusable node on mount', () => {
const modal = mountWithAppProvider(<Modal onClose={jest.fn()} open />);
const focusedNode = findFirstFocusableNode(modal.find(Dialog).getDOMNode());

expect(focusedNode).toBe(document.activeElement);
});

describe('src', () => {
it('renders an iframe if src is provided', () => {
const modal = mountWithAppProvider(
Expand Down
140 changes: 75 additions & 65 deletions src/components/TrapFocus/TrapFocus.tsx
Original file line number Diff line number Diff line change
@@ -1,99 +1,109 @@
import React from 'react';
import {closest} from '@shopify/javascript-utilities/dom';
import {
focusFirstFocusableNode,
findFirstFocusableNode,
focusLastFocusableNode,
} from '@shopify/javascript-utilities/focus';
import {write} from '@shopify/javascript-utilities/fastdom';
import React, {useState, useRef} from 'react';
import {focusFirstFocusableNode} from '@shopify/javascript-utilities/focus';
import {Key} from '../../types';

import {useComponentDidMount} from '../../utilities/use-component-did-mount';
import {EventListener} from '../EventListener';
import {KeypressListener, KeyEvent} from '../KeypressListener';
import {Focus} from '../Focus';

import {
findFirstKeyboardFocusableNode,
focusFirstKeyboardFocusableNode,
findLastKeyboardFocusableNode,
focusLastKeyboardFocusableNode,
} from '../../utilities/focus';

export interface TrapFocusProps {
trapping?: boolean;
children?: React.ReactNode;
}

interface State {
shouldFocusSelf: boolean | undefined;
}

export class TrapFocus extends React.PureComponent<TrapFocusProps, State> {
state: State = {
shouldFocusSelf: undefined,
};

private focusTrapWrapper: HTMLElement | null = null;
export function TrapFocus({trapping = true, children}: TrapFocusProps) {
const [shouldFocusSelf, setFocusSelf] = useState<boolean | undefined>(
undefined,
);

componentDidMount() {
this.setState(this.handleTrappingChange());
}

handleTrappingChange() {
const {trapping = true} = this.props;
const focusTrapWrapper = useRef<HTMLDivElement>(null);

const handleTrappingChange = () => {
if (
this.focusTrapWrapper &&
this.focusTrapWrapper.contains(document.activeElement)
focusTrapWrapper.current &&
focusTrapWrapper.current.contains(document.activeElement)
) {
return {shouldFocusSelf: false};
return false;
}
return trapping;
};

return {shouldFocusSelf: trapping};
}

render() {
const {children} = this.props;

return (
<Focus disabled={this.shouldDisable()} root={this.focusTrapWrapper}>
<div ref={this.setFocusTrapWrapper}>
<EventListener event="focusout" handler={this.handleBlur} />
{children}
</div>
</Focus>
);
}

private shouldDisable() {
const {trapping = true} = this.props;
const {shouldFocusSelf} = this.state;
useComponentDidMount(() => setFocusSelf(handleTrappingChange()));

const shouldDisableFirstElementFocus = () => {
if (shouldFocusSelf === undefined) {
return true;
}

return shouldFocusSelf ? !trapping : !shouldFocusSelf;
}

private setFocusTrapWrapper = (node: HTMLDivElement) => {
this.focusTrapWrapper = node;
};

private handleBlur = (event: FocusEvent) => {
const {relatedTarget} = event;
const {focusTrapWrapper} = this;
const {trapping = true} = this.props;
const handleFocusIn = (event: FocusEvent) => {
const containerContentsHaveFocus =
focusTrapWrapper.current &&
focusTrapWrapper.current.contains(document.activeElement);

if (relatedTarget == null || trapping === false) {
if (
trapping === false ||
!focusTrapWrapper.current ||
containerContentsHaveFocus
) {
return;
}

if (
focusTrapWrapper &&
!focusTrapWrapper.contains(relatedTarget as HTMLElement) &&
(!relatedTarget ||
!closest(relatedTarget as HTMLElement, '[data-polaris-overlay]'))
focusTrapWrapper.current !== event.target &&
!focusTrapWrapper.current.contains(event.target as Node)
) {
focusFirstFocusableNode(focusTrapWrapper.current);
}
};

const handleTab = (event: KeyboardEvent) => {
if (trapping === false || !focusTrapWrapper.current) {
return;
}

const firstFocusableNode = findFirstKeyboardFocusableNode(
focusTrapWrapper.current,
);
const lastFocusableNode = findLastKeyboardFocusableNode(
focusTrapWrapper.current,
);

if (event.target === lastFocusableNode && !event.shiftKey) {
event.preventDefault();
focusFirstKeyboardFocusableNode(focusTrapWrapper.current);
}

if (event.srcElement === findFirstFocusableNode(focusTrapWrapper)) {
return write(() => focusLastFocusableNode(focusTrapWrapper));
}
const firstNode =
findFirstFocusableNode(focusTrapWrapper) || focusTrapWrapper;
write(() => focusFirstFocusableNode(firstNode));
if (event.target === firstFocusableNode && event.shiftKey) {
event.preventDefault();
focusLastKeyboardFocusableNode(focusTrapWrapper.current);
}
};

return (
<Focus
disabled={shouldDisableFirstElementFocus()}
root={focusTrapWrapper.current}
>
<div ref={focusTrapWrapper}>
<EventListener event="focusin" handler={handleFocusIn} />
<KeypressListener
keyCode={Key.Tab}
keyEvent={KeyEvent.KeyDown}
handler={handleTab}
/>
{children}
</div>
</Focus>
);
}
Loading

0 comments on commit 5223276

Please sign in to comment.