Skip to content
Merged
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
1 change: 1 addition & 0 deletions UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Enhancements

- Updated `Popover` to focus the correct element when closed ([#2255](https://github.com/Shopify/polaris-react/pull/2255))
- Updated the type of the `title` prop in `ChoiceList` from `string` to `ReactNode` ([#2355](https://github.com/Shopify/polaris-react/pull/2355))
- Added `disabled` prop to `Filters` component ([2389](https://github.com/Shopify/polaris-react/pull/2389))

Expand Down
33 changes: 26 additions & 7 deletions src/components/Popover/Popover.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import React from 'react';
import {createUniqueIDFactory} from '@shopify/javascript-utilities/other';
import {
focusFirstFocusableNode,
findFirstFocusableNode,
} from '@shopify/javascript-utilities/focus';
import {findFirstFocusableNode} from '@shopify/javascript-utilities/focus';
import {focusNextFocusableNode} from '../../utilities/focus';

import {PreferredPosition, PreferredAlignment} from '../PositionedOverlay';
import {Portal} from '../Portal';
import {portal} from '../shared';
import {CloseSource, Pane, PopoverOverlay, Section} from './components';

export {CloseSource};
Expand Down Expand Up @@ -126,16 +125,25 @@ export class Popover extends React.PureComponent<PopoverProps, State> {
}

private handleClose = (source: CloseSource) => {
const {activatorNode} = this.state;
this.props.onClose(source);

if (this.activatorContainer == null) {
return;
}

if (
source === CloseSource.FocusOut ||
source === CloseSource.EscapeKeypress
(source === CloseSource.FocusOut ||
source === CloseSource.EscapeKeypress) &&
activatorNode
) {
focusFirstFocusableNode(this.activatorContainer, false);
const focusableActivator =
findFirstFocusableNode(activatorNode) ||
findFirstFocusableNode(this.activatorContainer) ||
this.activatorContainer;
if (!focusNextFocusableNode(focusableActivator, isInPortal)) {
focusableActivator.focus();
}
}
};

Expand All @@ -150,3 +158,14 @@ export class Popover extends React.PureComponent<PopoverProps, State> {
this.activatorContainer = node;
};
}

function isInPortal(element: Element) {
let parentElement = element.parentElement;

while (parentElement) {
if (parentElement.matches(portal.selector)) return false;
parentElement = parentElement.parentElement;
}

return true;
}
43 changes: 43 additions & 0 deletions src/components/Popover/tests/Popover.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, {useState, useCallback} from 'react';
import {mountWithAppProvider, findByTestID} from 'test-utilities/legacy';
import {mountWithApp} from 'test-utilities';
import {Popover} from '../Popover';
import {PopoverOverlay} from '../components';

describe('<Popover />', () => {
const spy = jest.fn();
Expand Down Expand Up @@ -173,4 +175,45 @@ describe('<Popover />', () => {

expect(onCloseSpy).not.toHaveBeenCalled();
});

it('focuses the next available element when the popover is closed', () => {
const id = 'focus-target';
function PopoverTest() {
return (
<React.Fragment>
<div>
<Popover active activator={<div />} onClose={noop} />
</div>
<button id={id} />
</React.Fragment>
);
}

const popover = mountWithApp(<PopoverTest />);

popover.find(PopoverOverlay)!.trigger('onClose', 1);
const focusTarget = popover.find('button', {id})!.domNode;

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

it('focuses the activator when another focusable element is not available when the popover is closed', () => {
const id = 'activator';
function PopoverTest() {
return (
<React.Fragment>
<Popover active activator={<button id={id} />} onClose={noop} />
</React.Fragment>
);
}

const popover = mountWithApp(<PopoverTest />);

popover.find(PopoverOverlay)!.trigger('onClose', 1);
const focusTarget = popover.find('button', {id})!.domNode;

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

function noop() {}
3 changes: 2 additions & 1 deletion src/components/Portal/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import {createPortal} from 'react-dom';
import {createUniqueIDFactory} from '@shopify/javascript-utilities/other';
import {ThemeContext} from '../../utilities/theme';
import {portal} from '../shared';

export interface PortalProps {
children?: React.ReactNode;
Expand Down Expand Up @@ -31,7 +32,7 @@ export class Portal extends React.PureComponent<PortalProps, State> {

componentDidMount() {
this.portalNode = document.createElement('div');
this.portalNode.setAttribute('data-portal-id', this.portalId);
this.portalNode.setAttribute(portal.props[0], this.portalId);

if (this.context != null) {
/* eslint-disable babel/camelcase */
Expand Down
5 changes: 3 additions & 2 deletions src/components/Portal/tests/Portal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import {mount} from 'enzyme';
import {mountWithAppProvider} from 'test-utilities/legacy';
import {Portal} from '../Portal';
import {portal} from '../../shared';

jest.mock('react-dom', () => ({
...require.requireActual('react-dom'),
Expand Down Expand Up @@ -34,15 +35,15 @@ describe('<Portal />', () => {
const idPrefix = 'test';
mountWithAppProvider(<Portal idPrefix={idPrefix} />);
const [, portalNode] = lastSpyCall(createPortalSpy);
expect(portalNode.getAttribute('data-portal-id')).toMatch(
expect(portalNode.getAttribute(portal.props[0])).toMatch(
new RegExp(`^${idPrefix}-portal`),
);
});

it('is ignored when not defined', () => {
mountWithAppProvider(<Portal />);
const [, portalNode] = lastSpyCall(createPortalSpy);
expect(portalNode.getAttribute('data-portal-id')).toMatch(/^portal/);
expect(portalNode.getAttribute(portal.props[0])).toMatch(/^portal/);
});
});

Expand Down
5 changes: 5 additions & 0 deletions src/components/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export const headerCell = {
selector: '[data-polaris-header-cell]',
};

export const portal = {
props: ['data-portal-id'],
selector: '[data-portal-id]',
};

export const DATA_ATTRIBUTE = {
overlay,
layer,
Expand Down
37 changes: 37 additions & 0 deletions src/utilities/focus.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,42 @@
import {FOCUSABLE_SELECTOR} from '@shopify/javascript-utilities/focus';
import {isElementInViewport} from './is-element-in-viewport';

type Filter = (element: Element) => void;

export function handleMouseUpByBlurring({
currentTarget,
}: React.MouseEvent<HTMLAnchorElement | HTMLButtonElement>) {
currentTarget.blur();
}

export function nextFocusableNode(
node: HTMLElement,
filter?: Filter,
): HTMLElement | Element | null {
const allFocusableElements = [
Copy link
Contributor

Choose a reason for hiding this comment

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

❤️

...document.querySelectorAll(FOCUSABLE_SELECTOR),
];
const sliceLocation = allFocusableElements.indexOf(node) + 1;
const focusableElementsAfterNode = allFocusableElements.slice(sliceLocation);

for (const focusableElement of focusableElementsAfterNode) {
if (
isElementInViewport(focusableElement) &&
(!filter || (filter && filter(focusableElement)))
) {
return focusableElement;
}
}

return null;
}

export function focusNextFocusableNode(node: HTMLElement, filter?: Filter) {
const nextFocusable = nextFocusableNode(node, filter);
if (nextFocusable && nextFocusable instanceof HTMLElement) {
Copy link
Member Author

Choose a reason for hiding this comment

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

The Element interface doesn't contain focus so we need this check

nextFocusable.focus();
return true;
Copy link
Member Author

Choose a reason for hiding this comment

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

Added a boolean return rather than void so we can determine if an element was focused or not

}

return false;
}
10 changes: 10 additions & 0 deletions src/utilities/is-element-in-viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function isElementInViewport(element: Element) {
const {top, left, bottom, right} = element.getBoundingClientRect();

return (
top >= 0 &&
right <= window.innerWidth &&
bottom <= window.innerHeight &&
left >= 0
);
}
134 changes: 124 additions & 10 deletions src/utilities/tests/focus.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,128 @@
import {MouseEvent} from 'react';
import {handleMouseUpByBlurring} from '../focus';

describe('focus', () => {
describe('handleMouseUpByBlurring()', () => {
it('calls blur on the currentTarget', () => {
const currentTarget = document.createElement('button');
jest.spyOn(currentTarget, 'blur');
const mouseEvent = {currentTarget};
handleMouseUpByBlurring(mouseEvent as MouseEvent<HTMLButtonElement>);
expect(currentTarget.blur).toHaveBeenCalled();
import {
handleMouseUpByBlurring,
focusNextFocusableNode,
nextFocusableNode,
} from '../focus';

describe('handleMouseUpByBlurring()', () => {
it('calls blur on the currentTarget', () => {
const currentTarget = document.createElement('button');
jest.spyOn(currentTarget, 'blur');
const mouseEvent = {currentTarget};
handleMouseUpByBlurring(mouseEvent as MouseEvent<HTMLButtonElement>);
expect(currentTarget.blur).toHaveBeenCalled();
});
});

describe('nextFocusableNode', () => {
it('does not return the initial element as the focusable node', () => {
const {activator, otherNode} = domSetup();

expect(nextFocusableNode(activator)).toBe(otherNode);
});

it('returns null when a focusable element is not found', () => {
const {activator} = domSetup({
otherNodeTag: 'div',
});

expect(nextFocusableNode(activator)).toBeNull();
});

it('filters out elements', () => {
const {activator} = domSetup();

expect(nextFocusableNode(activator, () => false)).toBeNull();
});

it("returns the parent of an adjacent element when it's focusable", () => {
const {activator, otherNode} = domSetup();

expect(nextFocusableNode(activator)).toBe(otherNode);
});

it('searches adjacent elements for focusable children', () => {
const {activator, otherNodeNested} = domSetup({
otherNodeTag: 'div',
nested: true,
});

expect(nextFocusableNode(activator)).toBe(otherNodeNested);
});

it('searches parent elements for focusable children', () => {
const {activator, parentsFocusableNode} = domSetup({
otherNodeTag: 'div',
parents: true,
});

expect(nextFocusableNode(activator)).toBe(parentsFocusableNode);
});
});

describe('focusNextFocusableNode', () => {
it('returns true when the node was focused', () => {
const {activator} = domSetup();

expect(focusNextFocusableNode(activator)).toBe(true);
});

it('returns false when the node was not focused', () => {
const {activator} = domSetup({otherNodeTag: 'div'});

expect(focusNextFocusableNode(activator)).toBe(false);
});

it('focused the node', () => {
const {activator, otherNode} = domSetup();

focusNextFocusableNode(activator);

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

function domSetup(
Copy link
Contributor

Choose a reason for hiding this comment

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

This is great, not asking to change it, but I wonder if it wouldn't have been simpler outline the dom being tested with each test. It makes it a little tricky to review and it's almost like domSetup() needs its own tests.

options: {
wrapperTag?: string;
activatorTag?: string;
otherNodeTag?: string;
otherNodeNestedTag?: string;
nested?: boolean;
parents?: true;
} = {},
) {
const div = 'div';
const button = 'button';
const {
wrapperTag = div,
activatorTag = button,
otherNodeTag = button,
otherNodeNestedTag = button,
nested,
parents,
} = options;
const wrapper = document.createElement(wrapperTag);
const activator = document.createElement(activatorTag);
const otherNode = document.createElement(otherNodeTag);
let otherNodeNested = null;
let parentNode = null;
let parentsFocusableNode = null;

if (nested) {
otherNodeNested = document.createElement(otherNodeNestedTag);
otherNode.appendChild(otherNodeNested);
}

wrapper.append(activator, otherNode);

if (parents) {
parentNode = document.createElement(div);
parentsFocusableNode = document.createElement(button);
parentNode.append(wrapper, parentsFocusableNode);
}

document.body.appendChild(parentNode || wrapper);
return {wrapper, activator, otherNode, otherNodeNested, parentsFocusableNode};
}
Loading