Skip to content

Commit

Permalink
feat(layers): handle doc click event on the layers level (uber#2933)
Browse files Browse the repository at this point in the history
  • Loading branch information
nadiia authored Feb 28, 2020
1 parent ed1ca3f commit 9ca755d
Show file tree
Hide file tree
Showing 14 changed files with 139 additions and 53 deletions.
1 change: 1 addition & 0 deletions documentation-site/examples/datepicker/in-popover.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default () => {
value={rangeDate}
onChange={({date}) => setRangeDate(date)}
placeholder="YYYY/MM/DD – YYYY/MM/DD"
quickSelect
/>
</React.Fragment>
);
Expand Down
1 change: 0 additions & 1 deletion src/datepicker/calendar-header.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,6 @@ export default class CalendarHeader extends React.Component<
<OverriddenPopover
placement="bottom"
focusLock={false}
mountNode={this.props.popoverMountNode}
isOpen={this.state.isMonthYearDropdownOpen}
onClick={() => {
this.setState(prev => ({
Expand Down
2 changes: 0 additions & 2 deletions src/datepicker/calendar.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,6 @@ export default class Calendar extends React.Component<
order={order}
onMonthChange={this.changeMonth}
onYearChange={this.changeYear}
popoverMountNode={this.state.rootElement}
/>
);
};
Expand Down Expand Up @@ -498,7 +497,6 @@ export default class Calendar extends React.Component<
{...quickSelectFormControlProps}
>
<QuickSelect
mountNode={this.state.rootElement}
aria-label={locale.datepicker.quickSelectAriaLabel}
labelKey="id"
onChange={params => {
Expand Down
1 change: 0 additions & 1 deletion src/datepicker/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@ export type CalendarPropsT = {
export type HeaderPropsT = CalendarPropsT & {
date: Date,
order: number,
popoverMountNode: ?HTMLElement,
};

export type DatepickerPropsT = CalendarPropsT & {
Expand Down
9 changes: 9 additions & 0 deletions src/layer/layer.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ class LayerComponent extends React.Component<

componentDidMount() {
this.context.addEscapeHandler(this.onEscape);
this.context.addDocClickHandler(this.onDocumentClick);

const {onMount, mountNode, host: layersManagerHost} = this.props;
if (mountNode) {
onMount && onMount();
Expand Down Expand Up @@ -64,6 +66,7 @@ class LayerComponent extends React.Component<

componentWillUnmount() {
this.context.removeEscapeHandler(this.onEscape);
this.context.removeDocClickHandler(this.onDocumentClick);

if (this.props.onUnmount) {
this.props.onUnmount();
Expand All @@ -84,6 +87,12 @@ class LayerComponent extends React.Component<
}
};

onDocumentClick = (event: MouseEvent) => {
if (this.props.onDocumentClick) {
this.props.onDocumentClick(event);
}
};

addContainer(host) {
const {index, mountNode, onMount} = this.props;
// Do nothing if mountNode is provided
Expand Down
41 changes: 37 additions & 4 deletions src/layer/layers-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,19 @@ import {initFocusVisible} from '../utils/focusVisible.js';
const StyledAppContainer = styled('div', {});
const StyledLayersContainer = styled('div', {});

function defaultEscapeHandlerFn() {
function defaultEventHandlerFn() {
if (__DEV__) {
console.warn(
'`LayersManager` was not found. This occurs if you are attempting to use a component requiring `Layer` without using the `BaseProvider` at the root of your app. Please visit https://baseweb.design/components/base-provider/ for more information',
);
}
}

export const LayersContext = React.createContext<LayersContextT>({
addEscapeHandler: defaultEscapeHandlerFn,
removeEscapeHandler: defaultEscapeHandlerFn,
addEscapeHandler: defaultEventHandlerFn,
removeEscapeHandler: defaultEventHandlerFn,
addDocClickHandler: defaultEventHandlerFn,
removeDocClickHandler: defaultEventHandlerFn,
host: undefined,
zIndex: undefined,
});
Expand All @@ -50,7 +53,7 @@ export default class LayersManager extends React.Component<

constructor(props: LayersManagerPropsT) {
super(props);
this.state = {escapeKeyHandlers: []};
this.state = {escapeKeyHandlers: [], docClickHandlers: []};
}

componentDidMount() {
Expand All @@ -59,15 +62,27 @@ export default class LayersManager extends React.Component<

if (__BROWSER__) {
document.addEventListener('keyup', this.onKeyUp);
// using mousedown event so that callback runs before events on children inside of the layer
document.addEventListener('mousedown', this.onDocumentClick);
}
}

componentWillUnmount() {
if (__BROWSER__) {
document.removeEventListener('keyup', this.onKeyUp);
document.removeEventListener('mousedown', this.onDocumentClick);
}
}

onDocumentClick = (event: MouseEvent) => {
const docClickHandler = this.state.docClickHandlers[
this.state.docClickHandlers.length - 1
];
if (docClickHandler) {
docClickHandler(event);
}
};

onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
const escapeKeyHandler = this.state.escapeKeyHandlers[
Expand Down Expand Up @@ -95,6 +110,22 @@ export default class LayersManager extends React.Component<
});
};

onAddDocClickHandler = (docClickHandler: (event: MouseEvent) => mixed) => {
this.setState(prev => {
return {docClickHandlers: [...prev.docClickHandlers, docClickHandler]};
});
};

onRemoveDocClickHandler = (docClickHandler: (event: MouseEvent) => mixed) => {
this.setState(prev => {
return {
docClickHandlers: prev.docClickHandlers.filter(
handler => handler !== docClickHandler,
),
};
});
};

render() {
const {overrides = {}} = this.props;
const [AppContainer, appContainerProps] = getOverrides(
Expand Down Expand Up @@ -123,6 +154,8 @@ export default class LayersManager extends React.Component<
zIndex: this.props.zIndex,
addEscapeHandler: this.onAddEscapeHandler,
removeEscapeHandler: this.onRemoveEscapeHandler,
addDocClickHandler: this.onAddDocClickHandler,
removeDocClickHandler: this.onRemoveDocClickHandler,
}}
>
<AppContainer {...appContainerProps} ref={this.containerRef}>
Expand Down
10 changes: 9 additions & 1 deletion src/layer/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ export type LayersManagerPropsT = {

export type LayersManagerStateT = {|
escapeKeyHandlers: Array<() => mixed>,
docClickHandlers: Array<(event: MouseEvent) => mixed>,
|};

export type LayersContextT = {
host: ?HTMLElement,
zIndex?: number,
addEscapeHandler: (() => mixed) => void,
removeEscapeHandler: (() => mixed) => void,
addDocClickHandler: (() => mixed) => void,
removeDocClickHandler: (() => mixed) => void,
};

/** Layer */
Expand All @@ -47,8 +50,12 @@ export type LayerPropsT = {
/** A custom DOM element where the layer is inserted to as a child.
Note that the `index` prop does not work with a custom `mountNode`. */
mountNode?: HTMLElement,
/** Handler called when escape key is pressed. */
/** Handler called when escape key is pressed.
Only the top most layer's handler is called. */
onEscape?: () => mixed,
/** Handler called when mousedown event happens on the document.
Only the top most layer's handler is called. */
onDocumentClick?: (event: MouseEvent) => mixed,
/** A handler that is called when the Layer is mounted. */
onMount?: () => mixed,
/** A handler that is called when the Layer is unmounted. */
Expand All @@ -64,6 +71,7 @@ export type LayerComponentPropsT = {
index?: number,
mountNode?: HTMLElement,
onEscape?: () => mixed,
onDocumentClick?: (event: MouseEvent) => mixed,
onMount?: () => mixed,
onUnmount?: () => mixed,
zIndex?: number,
Expand Down
1 change: 1 addition & 0 deletions src/popover/__tests__/__snapshots__/popover.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ exports[`Popover component as anchor 1`] = `
</CustomComponent>
<mockConstructor
key="new-layer"
onDocumentClick={[Function]}
onMount={[Function]}
onUnmount={[Function]}
>
Expand Down
56 changes: 30 additions & 26 deletions src/popover/__tests__/popover-select.scenario.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,36 @@ import {StatefulPopover} from '../index.js';
const SelectInPopover = () => {
const contentRef = React.useRef();
return (
<StatefulPopover
content={() => {
return (
<div ref={contentRef}>
<StatefulSelect
options={[
{id: 'AliceBlue', color: '#F0F8FF'},
{id: 'AntiqueWhite', color: '#FAEBD7'},
{id: 'Aqua', color: '#00FFFF'},
{id: 'Aquamarine', color: '#7FFFD4'},
{id: 'Azure', color: '#F0FFFF'},
{id: 'Beige', color: '#F5F5DC'},
]}
overrides={{ValueContainer: {props: {'data-id': 'selected'}}}}
labelKey="id"
valueKey="color"
placeholder="Start searching"
mountNode={contentRef.current ? contentRef.current : undefined}
/>
</div>
);
}}
accessibilityType={'tooltip'}
>
<Button>Open</Button>
</StatefulPopover>
<>
<div data-e2e="outside-popover">
Element outside of the popover to click on
</div>
<StatefulPopover
content={() => {
return (
<div ref={contentRef}>
<StatefulSelect
options={[
{id: 'AliceBlue', color: '#F0F8FF'},
{id: 'AntiqueWhite', color: '#FAEBD7'},
{id: 'Aqua', color: '#00FFFF'},
{id: 'Aquamarine', color: '#7FFFD4'},
{id: 'Azure', color: '#F0FFFF'},
{id: 'Beige', color: '#F5F5DC'},
]}
overrides={{ValueContainer: {props: {'data-id': 'selected'}}}}
labelKey="id"
valueKey="color"
placeholder="Start searching"
/>
</div>
);
}}
accessibilityType={'tooltip'}
>
<Button>Open</Button>
</StatefulPopover>
</>
);
};

Expand Down
52 changes: 51 additions & 1 deletion src/popover/__tests__/popover.e2e.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ LICENSE file in the root directory of this source tree.
const {mount, analyzeAccessibility} = require('../../../e2e/helpers');

const selectors = {
popover: '[data-baseweb="popover"]',
outsideOfPopover: '[data-e2e="outside-popover"]',
tooltip: '[role="tooltip"]',
selectInput: 'input[role="combobox"]',
selectDropDown: '[role="listbox"]',
Expand Down Expand Up @@ -52,7 +54,18 @@ describe('popover', () => {
await page.waitFor(selectors.selectInput);
await page.click(selectors.selectInput);
await page.waitFor(selectors.selectDropDown);

// Both popovers opened at this point.
// Make sure that layers rendered flat and not nested.
const noNestedPopovers = await page.$$eval(selectors.popover, popovers => {
let notNested = true;
for (let i = 0; i < popovers.length; i++) {
notNested =
notNested && !popovers[i].querySelector('[data-baseweb="popover"]');
}
return notNested;
});
expect(noNestedPopovers).toBe(true);
// Select an option from the select dropdown
const options = await page.$$(selectors.dropDownOption);
await options[0].click();
await page.waitFor(selectors.selectDropDown, {hidden: true});
Expand All @@ -62,6 +75,9 @@ describe('popover', () => {
select => select.textContent,
);
expect(selectedValue).toBe('AliceBlue');
// Click outside to close the initial popover
await page.click(selectors.outsideOfPopover);
await page.waitFor(selectors.selectInput, {hidden: true});
});

it('closes one popover at a time on esc key press', async () => {
Expand All @@ -81,6 +97,40 @@ describe('popover', () => {
await page.waitFor(selectors.selectInput, {hidden: true});
});

it('closes one popover at a time on click outside', async () => {
await mount(page, 'popover-select');
await page.waitFor('button');
await page.click('button');
await page.waitFor(selectors.tooltip);
await page.waitFor(selectors.selectInput);
await page.click(selectors.selectInput);
await page.waitFor(selectors.selectDropDown);
// Both popovers opened at this point.
// Verify that popovers are not nested but rendered in a flat layers way
// where every new layer rendered as a sibling to the rest of layers
// and not uses other layers rendered elements as a mount node.
const noNestedPopovers = await page.$$eval(selectors.popover, popovers => {
let notNested = true;
for (let i = 0; i < popovers.length; i++) {
notNested =
notNested && !popovers[i].querySelector('[data-baseweb="popover"]');
}
return notNested;
});
expect(noNestedPopovers).toBe(true);

// First document and outside of the popovers click
// closes only the top-most popover
await page.click(selectors.outsideOfPopover);
await page.waitFor(selectors.selectDropDown, {hidden: true});
await page.waitFor(selectors.selectInput);

// Second document and outside of the remaining popover click
// closes only the that popover
await page.click(selectors.outsideOfPopover);
await page.waitFor(selectors.selectInput, {hidden: true});
});

it('renders content even when hidden: with renderAll prop', async () => {
await mount(page, 'popover-render-all');
await page.waitFor('button');
Expand Down
Loading

0 comments on commit 9ca755d

Please sign in to comment.