Skip to content

Commit

Permalink
feat(Dropdown): Added container prop to DropdownMenu using React.port…
Browse files Browse the repository at this point in the history
…al (reactstrap#2016)

* Added container prop to DropdownMenu using React.portal

* Fixed tabbing and keyboard events for menu items

* Fixed timing issue in tests with getMenuItems
  • Loading branch information
hsource committed Nov 17, 2020
1 parent e7718fa commit bd313c2
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 18 deletions.
21 changes: 20 additions & 1 deletion docs/lib/Components/DropdownsPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import DropdownSizingExample from '../examples/DropdownSizing';
import CustomDropdownExample from '../examples/CustomDropdown';
import DropdownUncontrolledExample from '../examples/DropdownUncontrolled';
import DropdownSetActiveFromChildExample from '../examples/DropdownSetActiveFromChild';
import DropdownContainerExample from '../examples/DropdownContainer';

const DropdownExampleSource = require('!!raw-loader!../examples/Dropdown');
const CustomDropdownExampleSource = require('!!raw-loader!../examples/CustomDropdown');
const DropdownUncontrolledExampleSource = require('!!raw-loader!../examples/DropdownUncontrolled');
const DropdownSetActiveFromChildSource = require('!!raw-loader!../examples/DropdownSetActiveFromChild');
const DropdownContainerSource = require('!!raw-loader!../examples/DropdownContainer');

export default class DropdownPage extends React.Component {
constructor(props) {
Expand Down Expand Up @@ -97,7 +99,11 @@ DropdownMenu.propTypes = {
modifiers: PropTypes.object,
persist: PropTypes.bool, // presist the popper, even when closed. See #779 for reasoning
// passed to popper, see https://popper.js.org/popper-documentation.html#Popper.Defaults.positionFixed
positionFixed: PropTypes.bool
positionFixed: PropTypes.bool,
// Element to place the menu in. Used to show the menu as a child of 'body'
// or other elements in the DOM instead of where it naturally is. This can be
// used to make the Dropdown escape a container with overflow: hidden
container: PropTypes.oneOfType([PropTypes.string, PropTypes.func, DOMElement]),
};
DropdownItem.propTypes = {
Expand Down Expand Up @@ -406,6 +412,19 @@ DropdownItem.propTypes = {
{DropdownSetActiveFromChildSource}
</PrismCode>
</pre>

<SectionTitle>container</SectionTitle>
<p>
Use the <code>container</code> prop to allow the dropdown menu to be placed inside an alternate container through a React Portal. This can be used to allow the dropdown menu to escape a container with the style <code>overflow: hidden</code>.
</p>
<div className="docs-example">
<DropdownContainerExample />
</div>
<pre>
<PrismCode className="language-jsx">
{DropdownContainerSource}
</PrismCode>
</pre>
</div>
);
}
Expand Down
28 changes: 28 additions & 0 deletions docs/lib/examples/DropdownContainer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React, { useState } from 'react';
import { Dropdown, DropdownToggle, DropdownMenu, DropdownItem } from 'reactstrap';

const Example = (props) => {
const [dropdownOpen, setDropdownOpen] = useState(false);

const toggle = () => setDropdownOpen(prevState => !prevState);
const [lastClicked, setLastClicked] = useState(null);

return (
<div style={{ width: 300, height: 100, border: '1px solid #000', padding: '8px', overflow: 'hidden' }}>
Container with overflow: hidden.<br />
Last clicked: {lastClicked || 'null'}
<Dropdown isOpen={dropdownOpen} toggle={toggle}>
<DropdownToggle caret>
Dropdown
</DropdownToggle>
<DropdownMenu container="body">
<DropdownItem onClick={() => setLastClicked(1)}>Action 1</DropdownItem>
<DropdownItem onClick={() => setLastClicked(2)}>Action 2</DropdownItem>
<DropdownItem onClick={() => setLastClicked(3)}>Action 3</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
);
}

export default Example;
54 changes: 42 additions & 12 deletions src/Dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,14 @@ class Dropdown extends React.Component {
this.handleKeyDown = this.handleKeyDown.bind(this);
this.removeEvents = this.removeEvents.bind(this);
this.toggle = this.toggle.bind(this);
this.handleMenuRef = this.handleMenuRef.bind(this);

this.containerRef = React.createRef();
this.menuRef = React.createRef();
}

handleMenuRef(menuRef) {
this.menuRef.current = menuRef;
}

getContextValue() {
Expand All @@ -66,7 +72,10 @@ class Dropdown extends React.Component {
isOpen: this.props.isOpen,
direction: (this.props.direction === 'down' && this.props.dropup) ? 'up' : this.props.direction,
inNavbar: this.props.inNavbar,
disabled: this.props.disabled
disabled: this.props.disabled,
// Callback that should be called by DropdownMenu to provide a ref to
// a HTML tag that's used for the DropdownMenu
onMenuRef: this.handleMenuRef,
};
}

Expand All @@ -88,14 +97,22 @@ class Dropdown extends React.Component {
return this.containerRef.current;
}

getMenu() {
return this.menuRef.current;
}

getMenuCtrl() {
if (this._$menuCtrl) return this._$menuCtrl;
this._$menuCtrl = this.getContainer().querySelector('[aria-expanded]');
return this._$menuCtrl;
}

getMenuItems() {
return [].slice.call(this.getContainer().querySelectorAll('[role="menuitem"]'));
// In a real menu with a child DropdownMenu, `this.getMenu()` should never
// be null, but it is sometimes null in tests. To mitigate that, we just
// use `this.getContainer()` as the fallback `menuContainer`.
const menuContainer = this.getMenu() || this.getContainer();
return [].slice.call(menuContainer.querySelectorAll('[role="menuitem"]'));
}

addEvents() {
Expand All @@ -113,18 +130,25 @@ class Dropdown extends React.Component {
handleDocumentClick(e) {
if (e && (e.which === 3 || (e.type === 'keyup' && e.which !== keyCodes.tab))) return;
const container = this.getContainer();

if (container.contains(e.target) && container !== e.target && (e.type !== 'keyup' || e.which === keyCodes.tab)) {
const menu = this.getMenu();
const clickIsInContainer = container.contains(e.target) && container !== e.target;
const clickIsInMenu = menu && menu.contains(e.target) && menu !== e.target;
if ((clickIsInContainer || clickIsInMenu) && (e.type !== 'keyup' || e.which === keyCodes.tab)) {
return;
}

this.toggle(e);
}

handleKeyDown(e) {
const isTargetMenuItem = e.target.getAttribute('role') === 'menuitem';
const isTargetMenuCtrl = this.getMenuCtrl() === e.target;
const isTab = keyCodes.tab === e.which;

if (
/input|textarea/i.test(e.target.tagName)
|| (keyCodes.tab === e.which && (e.target.getAttribute('role') !== 'menuitem' || !this.props.a11y))
|| (isTab && !this.props.a11y)
|| (isTab && !(isTargetMenuItem || isTargetMenuCtrl))
) {
return;
}
Expand All @@ -135,15 +159,21 @@ class Dropdown extends React.Component {

if (this.props.disabled) return;

if (this.getMenuCtrl() === e.target) {
if (
!this.props.isOpen
&& ([keyCodes.space, keyCodes.enter, keyCodes.up, keyCodes.down].indexOf(e.which) > -1)
) {
this.toggle(e);
if (isTargetMenuCtrl) {
if ([keyCodes.space, keyCodes.enter, keyCodes.up, keyCodes.down].indexOf(e.which) > -1) {
// Open the menu (if not open) and focus the first menu item
if (!this.props.isOpen) {
this.toggle(e);
}
setTimeout(() => this.getMenuItems()[0].focus());
} else if (this.props.isOpen && isTab) {
// Focus the first menu item if tabbing from an open menu. We need this
// for cases where the DropdownMenu sets a custom container, which may
// not be the natural next item to tab to from the DropdownToggle.
e.preventDefault();
this.getMenuItems()[0].focus();
} else if (this.props.isOpen && e.which === keyCodes.esc) {
this.toggle(e);
this.toggle(e);
}
}

Expand Down
40 changes: 35 additions & 5 deletions src/DropdownMenu.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import { Popper } from 'react-popper';
import { DropdownContext } from './DropdownContext';
import { mapToCssModules, tagPropType } from './utils';
import { mapToCssModules, tagPropType, targetPropType, getTarget } from './utils';

const propTypes = {
tag: tagPropType,
Expand All @@ -15,6 +16,7 @@ const propTypes = {
cssModule: PropTypes.object,
persist: PropTypes.bool,
positionFixed: PropTypes.bool,
container: targetPropType,
};

const defaultProps = {
Expand All @@ -31,10 +33,22 @@ const directionPositionMap = {
down: 'bottom',
};

class DropdownMenu extends React.Component {
class DropdownMenu extends React.Component {

render() {
const { className, cssModule, right, tag, flip, modifiers, persist, positionFixed, ...attrs } = this.props;
const {
className,
cssModule,
right,
tag,
flip,
modifiers,
persist,
positionFixed,
container,
...attrs
} = this.props;

const classes = mapToCssModules(classNames(
className,
'dropdown-menu',
Expand All @@ -57,19 +71,29 @@ class DropdownMenu extends React.Component {
} : modifiers;
const popperPositionFixed = !!positionFixed;

return (
const popper = (
<Popper
placement={poperPlacement}
modifiers={poperModifiers}
positionFixed={popperPositionFixed}
>
{({ ref, style, placement }) => {
let combinedStyle = { ...this.props.style, ...style };

const handleRef = (tagRef) => {
// Send the ref to `react-popper`
ref(tagRef);
// Send the ref to the parent Dropdown so that clicks outside
// it will cause it to close
const { onMenuRef } = this.context;
if (onMenuRef) onMenuRef(tagRef);
};

return (
<Tag
tabIndex="-1"
role="menu"
ref={ref}
ref={handleRef}
{...attrs}
style={combinedStyle}
aria-hidden={!this.context.isOpen}
Expand All @@ -80,6 +104,12 @@ class DropdownMenu extends React.Component {
}}
</Popper>
);

if (container) {
return ReactDOM.createPortal(popper, getTarget(container));
} else {
return popper;
}
}

return (
Expand Down
28 changes: 28 additions & 0 deletions src/__tests__/DropdownMenu.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,32 @@ describe('DropdownMenu', () => {
expect(wrapper.childAt(0).hasClass('dropdown-menu')).toBe(true);
expect(wrapper.getDOMNode().tagName.toLowerCase()).toBe('main');
});

describe('using container', () => {
let element;

beforeEach(() => {
element = document.createElement('div');
document.body.appendChild(element);
});

afterEach(() => {
document.body.removeChild(element);
element = null;
});

it('should render inside container', () => {
isOpen = true;
element.innerHTML = '<div id="anotherContainer"></div>';
const wrapper = mount(
<DropdownContext.Provider value={{ isOpen, direction, inNavbar }}>
<DropdownMenu container="#anotherContainer">My body</DropdownMenu>
</DropdownContext.Provider>
);

expect(document.getElementById('anotherContainer').innerHTML).toContain('My body');
expect(wrapper.text()).toBe('My body');
});
})

});
1 change: 1 addition & 0 deletions types/lib/DropdownMenu.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface DropdownMenuProps extends React.HTMLAttributes<HTMLElement> {
cssModule?: CSSModule;
persist?: boolean;
positionFixed?: boolean;
container?: string | HTMLElement | React.RefObject<HTMLElement>;
}

declare class DropdownMenu extends React.Component<DropdownMenuProps> {}
Expand Down

0 comments on commit bd313c2

Please sign in to comment.