From 3f566a7816b330f64d640fd68f9157b26d0a01c1 Mon Sep 17 00:00:00 2001 From: ItsJonQ Date: Thu, 26 Oct 2017 16:48:54 -0400 Subject: [PATCH] Dropdown: CANI This commit makes a lot of improvements to the Dropdown component (and it's sub-components). Here's a list of enhancements/fixes :muscle: **Trigger** * Can now render custom child components **Item** * Has a disabled "state" (with accompanying styles) **Header** * Adds a new DropdownHeader component **Menu** * Fixes method that closes the menu on item click * Fixes onOpen/onClose callbacks * Closes the entire menu tree on item click (even for nested items) --- src/components/Drop/index.js | 2 + src/components/Dropdown/Dropdown.js | 227 ++++++++++++++++++ src/components/Dropdown/Header.js | 26 ++ src/components/Dropdown/Item.js | 52 +++- src/components/Dropdown/Menu.js | 56 ++++- src/components/Dropdown/Trigger.js | 43 +++- src/components/Dropdown/docs/Item.md | 1 + src/components/Dropdown/docs/Menu.md | 16 +- src/components/Dropdown/docs/Trigger.md | 17 ++ src/components/Dropdown/index.js | 224 +---------------- src/components/Dropdown/tests/Header.test.js | 8 + src/components/Dropdown/tests/Item.test.js | 50 +++- src/components/Dropdown/tests/Menu.test.js | 53 +++- src/components/Dropdown/tests/Trigger.test.js | 134 ++++++++++- src/components/PortalWrapper/index.js | 1 + .../components/Dropdown/DropdownHeader.scss | 4 + .../components/Dropdown/DropdownItem.scss | 26 +- .../components/Dropdown/DropdownMenu.scss | 1 - src/styles/components/Dropdown/__index.scss | 1 + stories/Dropdown.js | 172 +++++++------ 20 files changed, 766 insertions(+), 348 deletions(-) create mode 100644 src/components/Dropdown/Dropdown.js create mode 100644 src/components/Dropdown/Header.js create mode 100644 src/components/Dropdown/tests/Header.test.js create mode 100644 src/styles/components/Dropdown/DropdownHeader.scss diff --git a/src/components/Drop/index.js b/src/components/Drop/index.js index ef5f02e5b..d8e8f1ec3 100644 --- a/src/components/Drop/index.js +++ b/src/components/Drop/index.js @@ -73,6 +73,8 @@ export const DropComponent = (/* istanbul ignore next */ options = defaultOption diff --git a/src/components/Dropdown/Dropdown.js b/src/components/Dropdown/Dropdown.js new file mode 100644 index 000000000..71a892ad4 --- /dev/null +++ b/src/components/Dropdown/Dropdown.js @@ -0,0 +1,227 @@ +import React, {PureComponent as Component} from 'react' +import ReactDOM from 'react-dom' +import PropTypes from 'prop-types' +import EventListener from '../EventListener' +import Divider from './Divider' +import Header from './Header' +import Item from './Item' +import { default as Menu, MenuComponent } from './Menu' +import Trigger from './Trigger' +import KeypressListener from '../KeypressListener' +import Keys from '../../constants/Keys' +import classNames from '../../utilities/classNames' +import { focusNextFocusableNode, focusPreviousFocusableNode } from '../../utilities/focus' +import { isNodeElement } from '../../utilities/node' +import { noop } from '../../utilities/other' + +export const propTypes = { + closeMenuOnClick: PropTypes.bool, + direction: PropTypes.string, + isOpen: PropTypes.bool, + onClose: PropTypes.func, + onSelect: PropTypes.func, + selectedIndex: PropTypes.number +} +const defaultProps = { + closeMenuOnClick: true, + direction: 'down', + onClose: noop +} + +class Dropdown extends Component { + constructor (props) { + super() + this.state = { + isOpen: props.isOpen, + selectedIndex: props.selectedIndex + } + + this.isFocused = false + this.triggerNode = null + + this.handleOnBodyClick = this.handleOnBodyClick.bind(this) + this.handleOnTriggerClick = this.handleOnTriggerClick.bind(this) + this.handleOnTriggerFocus = this.handleOnTriggerFocus.bind(this) + this.handleOnMenuClose = this.handleOnMenuClose.bind(this) + this.handleDownArrow = this.handleDownArrow.bind(this) + this.handleTab = this.handleTab.bind(this) + this.handleShiftTab = this.handleShiftTab.bind(this) + } + + componentDidMount () { + this.setTriggerNode() + } + + componentWillUpdate (nextProps) { + if (this.props.isOpen !== nextProps.isOpen) { + this.setState({ isOpen: nextProps.isOpen }) + } + } + + setTriggerNode () { + const trigger = this.refs.trigger + /* istanbul ignore next */ + if (!this.triggerNode) { + this.triggerNode = isNodeElement(trigger) ? trigger : ReactDOM.findDOMNode(trigger) + } + } + + handleOnBodyClick (event) { + const clickNode = event.target + if (this.state.isOpen) { + if (clickNode !== this.triggerNode) { + this.handleOnMenuClose() + } else { + this.handleOnTriggerClick() + } + } else { + /* istanbul ignore else */ + if (clickNode === this.triggerNode) { + this.handleOnTriggerClick() + } + } + } + + handleOnTriggerClick () { + this.setState({ isOpen: !this.state.isOpen }) + this.isFocused = true + } + + handleOnTriggerFocus () { + setTimeout(() => { + this.isFocused = true + }, 0) + } + + handleOnMenuClose () { + const { onClose } = this.props + this.setState({ selectedIndex: null, isOpen: false }) + this.isFocused = false + + onClose() + } + + handleDownArrow () { + const { isOpen } = this.state + if (!isOpen && this.isFocused) { + this.setState({ isOpen: true, selectedIndex: 0 }) + } + } + + handleTab (event) { + this.isFocused = false + /* istanbul ignore else */ + if (this.state.isOpen) { + event.preventDefault() + this.handleOnMenuClose() + /* istanbul ignore next */ + if (this.triggerNode) { + /* istanbul ignore next */ + // Method is tested in Jest. However, it can't be + // tested in a React instance of Dropdown + focusNextFocusableNode(this.triggerNode) + } + return false + } + } + + handleShiftTab (event) { + this.isFocused = false + /* istanbul ignore else */ + if (this.state.isOpen) { + event.preventDefault() + this.handleOnMenuClose() + /* istanbul ignore next */ + if (this.triggerNode) { + /* istanbul ignore next */ + // Method is tested in Jest. However, it can't be + // tested in a React instance of Dropdown + focusPreviousFocusableNode(this.triggerNode) + } + return false + } + } + + render () { + const { + children, + className, + closeMenuOnClick, + direction, + onClose, + onSelect, + isOpen: propsisOpen, + selectedIndex: propsSelectedIndex, + ...rest + } = this.props + const { + isOpen, + selectedIndex + } = this.state + + const handleOnBodyClick = this.handleOnBodyClick + const handleOnTriggerFocus = this.handleOnTriggerFocus + const handleOnMenuClose = this.handleOnMenuClose + const handleDownArrow = this.handleDownArrow + const handleTab = this.handleTab + const handleShiftTab = this.handleShiftTab + + const componentClassName = classNames( + 'c-Dropdown', + isOpen && 'is-open', + className + ) + const childrenMarkup = React.Children.map(children, (child, index) => { + if (index === 0) { + let triggerProps = { + ref: 'trigger', + onFocus: handleOnTriggerFocus, + 'aria-haspopup': true, + 'aria-expanded': isOpen + } + if (child.type === Trigger) { + // TODO: Allow for dynamic directions + triggerProps = Object.assign({}, triggerProps, { + direction: 'down' + }) + } + return React.cloneElement(child, triggerProps) + } + + if (child.type === Menu || child.type === MenuComponent) { + return isOpen ? React.cloneElement(child, { + closeMenuOnClick, + direction, + isOpen, + onClose: handleOnMenuClose, + onSelect: onSelect || child.props.onSelect, + ref: 'menu', + trigger: this.triggerNode, + selectedIndex: child.props.selectedIndex !== undefined ? child.props.selectedIndex : selectedIndex + }) : null + } + + return child + }) + + return ( +
+ + + + + {childrenMarkup} +
+ ) + } +} + +Dropdown.propTypes = propTypes +Dropdown.defaultProps = defaultProps +Dropdown.Divider = Divider +Dropdown.Header = Header +Dropdown.Item = Item +Dropdown.Menu = Menu +Dropdown.Trigger = Trigger + +export default Dropdown diff --git a/src/components/Dropdown/Header.js b/src/components/Dropdown/Header.js new file mode 100644 index 000000000..f15e2a20e --- /dev/null +++ b/src/components/Dropdown/Header.js @@ -0,0 +1,26 @@ +import React from 'react' +import Heading from '../Heading' +import classNames from '../../utilities/classNames' + +const Header = props => { + const { + children, + className, + ...rest + } = props + + const componentClassName = classNames( + 'c-DropdownHeader', + className + ) + + return ( +
+ + {children} + +
+ ) +} + +export default Header diff --git a/src/components/Dropdown/Item.js b/src/components/Dropdown/Item.js index 01af7f838..bbd308fac 100644 --- a/src/components/Dropdown/Item.js +++ b/src/components/Dropdown/Item.js @@ -8,6 +8,7 @@ import classNames from '../../utilities/classNames' import { noop } from '../../utilities/other' export const propTypes = { + disabled: PropTypes.bool, isHover: PropTypes.bool, isFocused: PropTypes.bool, itemIndex: PropTypes.number, @@ -18,19 +19,27 @@ export const propTypes = { onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, onMenuClose: PropTypes.func, + onParentMenuClose: PropTypes.func, value: PropTypes.node } const defaultProps = { + disabled: false, onBlur: noop, onClick: noop, onFocus: noop, onMouseEnter: noop, onMouseLeave: noop, onMenuClose: noop, + onParentMenuClose: noop, onSelect: noop } +const childContextTypes = { + parentMenu: PropTypes.element, + parentMenuClose: PropTypes.func +} + class Item extends Component { constructor (props) { super() @@ -49,12 +58,25 @@ class Item extends Component { this.handleOnMouseLeave = this.handleOnMouseLeave.bind(this) this.handleOnMenuClose = this.handleOnMenuClose.bind(this) this.node = null + this._isMounted = false } componentWillMount () { this.menu = this.getMenuFromChildren() } + componentDidMount () { + this._isMounted = true + } + + getChildContext () { + const { onParentMenuClose } = this.props + return { + parentMenu: this.menu, + parentMenuClose: onParentMenuClose + } + } + componentWillReceiveProps (nextProps) { const { isFocused, isHover, isOpen } = this.state if (nextProps.isFocused !== isFocused || @@ -69,6 +91,10 @@ class Item extends Component { } } + componentWillUnmount () { + this._isMounted = false + } + handleOnBlur (event, reactEvent) { const { onBlur } = this.props onBlur(event, reactEvent, this) @@ -90,8 +116,11 @@ class Item extends Component { } handleOnClick (event, reactEvent) { - event.stopPropagation() - const { onClick, onSelect, value } = this.props + const { disabled, onClick, onSelect, value } = this.props + + if (event) event.stopPropagation() + if (disabled) return + /* istanbul ignore else */ if (!this.menu) { onClick(event, reactEvent, this) @@ -135,7 +164,7 @@ class Item extends Component { } getMenu (child) { - if (!React.isValidElement(child)) return false + if (!React.isValidElement(child)) return null return (child.type && (child.type === Menu || child.type === MenuComponent)) } @@ -144,7 +173,7 @@ class Item extends Component { if (Array.isArray(children)) { return children.find(child => this.getMenu(child)) } else { - return this.getMenu(children) ? children : false + return this.getMenu(children) ? children : null } } @@ -161,6 +190,7 @@ class Item extends Component { const { children, className, + disabled, itemRef, isFocused: propIsFocused, isHover: propIsHover, @@ -171,6 +201,7 @@ class Item extends Component { onFocus, onMouseEnter, onMenuClose, + onParentMenuClose, ...rest } = this.props const { isOpen, isHover, isFocused } = this.state @@ -185,6 +216,7 @@ class Item extends Component { const componentClassName = classNames( 'c-DropdownItem', + disabled && 'is-disabled', isHover && 'is-hover', isFocused && 'is-focused', className @@ -192,15 +224,14 @@ class Item extends Component { const itemMarkup = this.removeMenuFromChildren() - const menuMarkup = this.menu && isOpen ? ( + const menuMarkup = !disabled && this.menu && isOpen ? (
{React.cloneElement(this.menu, { isOpen, selectedIndex: this.menu.props.selectedIndex !== undefined ? this.menu.props.selectedIndex : 0, onClose: handleOnMenuClose, trigger: this.node, - direction: this.menu.props.direction ? this.menu.props.direction : 'right', - parentMenu: true + direction: this.menu.props.direction ? this.menu.props.direction : 'right' })}
) : null @@ -212,7 +243,7 @@ class Item extends Component { ) : null return ( -
  • { this.node = node }} + role='menuitem' aria-haspopup={!!this.menu} aria-expanded={!!(this.menu && isOpen)} + aria-disabled={disabled} > @@ -238,12 +271,13 @@ class Item extends Component { {menuMarkup} -
  • + ) } } Item.propTypes = propTypes Item.defaultProps = defaultProps +Item.childContextTypes = childContextTypes export default Item diff --git a/src/components/Dropdown/Menu.js b/src/components/Dropdown/Menu.js index 22f8ac6bb..82edff985 100644 --- a/src/components/Dropdown/Menu.js +++ b/src/components/Dropdown/Menu.js @@ -20,8 +20,8 @@ export const propTypes = { enableCycling: PropTypes.bool, isOpen: PropTypes.bool, onClose: PropTypes.func, + onOpen: PropTypes.func, onSelect: PropTypes.func, - parentMenu: PropTypes.bool, selectedIndex: PropTypes.number } @@ -30,9 +30,15 @@ const defaultProps = { enableCycling: false, isOpen: false, onClose: noop, + onOpen: noop, onSelect: noop } +const contextTypes = { + parentMenu: PropTypes.element, + parentMenuClose: PropTypes.func +} + const dropOptions = { autoPosition: true, id: 'Dropdown', @@ -64,6 +70,7 @@ class Menu extends Component { this.handleItemOnMouseEnter = this.handleItemOnMouseEnter.bind(this) this.handleItemOnMenuClose = this.handleItemOnMenuClose.bind(this) this.handleOnClose = this.handleOnClose.bind(this) + this.handleOnCloseParent = this.handleOnCloseParent.bind(this) this.handleOnMenuClick = this.handleOnMenuClick.bind(this) this.node = null @@ -100,6 +107,13 @@ class Menu extends Component { this._isMounted = false } + safeSetState (newState) { + /* istanbul ignore else */ + if (this._isMounted) { + this.setState(newState) + } + } + handleOnResize () { const height = getHeightRelativeToViewport({ node: this.listNode, @@ -144,7 +158,7 @@ class Menu extends Component { itemCount }) - this.setState({ + this.safeSetState({ focusIndex: newFocusIndex, hoverIndex: null }) @@ -167,7 +181,7 @@ class Menu extends Component { handleLeftArrow (event) { event.preventDefault() if (!this.isFocused) return - const { parentMenu } = this.props + const { parentMenu } = this.context if (parentMenu) { this.handleOnClose() } @@ -182,7 +196,7 @@ class Menu extends Component { const item = this.items[focusIndex] if (item && item.menu) { - this.setState({ hoverIndex: focusIndex }) + this.safeSetState({ hoverIndex: focusIndex }) this.isFocused = false } } @@ -206,20 +220,20 @@ class Menu extends Component { handleItemOnClick () { const { closeMenuOnClick } = this.props - if (closeMenuOnClick) { - this.handleOnClose() - } + + if (!closeMenuOnClick) return + this.handleOnCloseParent() } handleItemOnFocus (event, reactEvent, item) { const focusIndex = this.getIndexFromItem(item) - this.setState({ focusIndex }) + this.safeSetState({ focusIndex }) } handleItemOnMouseEnter (event, reactEvent, item) { const focusIndex = this.getIndexFromItem(item) const hoverIndex = focusIndex - this.setState({ focusIndex, hoverIndex }) + this.safeSetState({ focusIndex, hoverIndex }) if (item.menu) { this.isFocused = false } @@ -229,7 +243,7 @@ class Menu extends Component { /* istanbul ignore else */ if (this._isMounted) { this.isFocused = true - this.setState({ hoverIndex: null }) + this.safeSetState({ hoverIndex: null }) } } @@ -238,6 +252,15 @@ class Menu extends Component { onClose() } + handleOnCloseParent () { + const { parentMenuClose } = this.context + this.handleOnClose() + + if (parentMenuClose) { + parentMenuClose() + } + } + handleOnMenuClick (event) { event.stopPropagation() } @@ -255,7 +278,6 @@ class Menu extends Component { onClose, onOpen, onSelect, - parentMenu, selectedIndex, trigger, ...rest @@ -266,11 +288,16 @@ class Menu extends Component { hoverIndex } = this.state + const { + parentMenu + } = this.context + const handleUpArrow = this.handleUpArrow const handleDownArrow = this.handleDownArrow const handleLeftArrow = this.handleLeftArrow const handleRightArrow = this.handleRightArrow const handleEscape = this.handleEscape + const handleOnCloseParent = this.handleOnCloseParent const handleItemOnClick = this.handleItemOnClick const handleItemOnFocus = this.handleItemOnFocus @@ -300,6 +327,7 @@ class Menu extends Component { }, onMouseEnter: handleItemOnMouseEnter, onMenuClose: handleItemOnMenuClose, + onParentMenuClose: handleOnCloseParent, onSelect }) : child }) @@ -335,12 +363,13 @@ class Menu extends Component { style={{height: this.height}} > -
      { this.listNode = node }} + role='menu' > {childrenMarkup} -
    +
    @@ -353,6 +382,7 @@ class Menu extends Component { Menu.propTypes = propTypes Menu.defaultProps = defaultProps +Menu.contextTypes = contextTypes export const MenuComponent = Menu export default Drop(dropOptions)(Menu) diff --git a/src/components/Dropdown/Trigger.js b/src/components/Dropdown/Trigger.js index 627e280ef..142beaec6 100644 --- a/src/components/Dropdown/Trigger.js +++ b/src/components/Dropdown/Trigger.js @@ -1,17 +1,36 @@ import React, {PureComponent as Component} from 'react' -import classNames from '../../utilities/classNames' +import PropTypes from 'prop-types' import Button from '../Button' import Icon from '../Icon' +import classNames from '../../utilities/classNames' +import { noop } from '../../utilities/other' import { dropdownDirectionTypes } from './propTypes' export const propTypes = { - direction: dropdownDirectionTypes + children: PropTypes.node.isRequired, + direction: dropdownDirectionTypes, + onClick: PropTypes.func } const defaultProps = { - direction: 'down' + children: 'Dropdown', + direction: 'down', + onClick: noop } class Trigger extends Component { + constructor () { + super() + this.handleOnClick = this.handleOnClick.bind(this) + } + + /* istanbul ignore next */ + // Tested, but Istanbul isn't picking it up + handleOnClick (event) { + const { onClick } = this.props + if (event) event.preventDefault() + onClick(event) + } + render () { const { isActive, @@ -21,8 +40,14 @@ class Trigger extends Component { ...rest } = this.props + const handleOnClick = this.handleOnClick + + const child = typeof children !== 'object' ? children : React.Children.only(children) + const componentClassName = classNames( 'c-DropdownTrigger', + direction && `is-${direction}`, + isActive && 'is-active', className ) @@ -36,18 +61,26 @@ class Trigger extends Component { /> ) - return ( + const triggerMarkup = typeof child !== 'object' ? ( - ) + ) : React.cloneElement(child, { + ...rest, + className: componentClassName, + onClick: handleOnClick, + tabIndex: 0 + }) + + return triggerMarkup } } diff --git a/src/components/Dropdown/docs/Item.md b/src/components/Dropdown/docs/Item.md index d4ef6bf62..e67cf0fe1 100644 --- a/src/components/Dropdown/docs/Item.md +++ b/src/components/Dropdown/docs/Item.md @@ -24,6 +24,7 @@ An Item component is a list-item wrapper for individual actions or links that ap | onMouseEnter | function | Callback when mouse enters the component. | | onMouseLeave | function | Callback when mouse leaves the component. | | onMenuClose | function | Callback when nested Menu is closed. | +| onParentMenuClose | function | Callback from parent [Menu](./Menu.md) to recursively close parent Menus. | | onSelect | function | Callback when component is selected. | | className | string | Custom class names to be added to the component. | | isFocused | boolean | Determines the focus state/style of the component. | diff --git a/src/components/Dropdown/docs/Menu.md b/src/components/Dropdown/docs/Menu.md index da7d5423f..381c7bdbb 100644 --- a/src/components/Dropdown/docs/Menu.md +++ b/src/components/Dropdown/docs/Menu.md @@ -24,5 +24,19 @@ A Menu component contains the series of [Item](../Item) components used within a | closeMenuOnClick | boolean | Closes component when item is clicked. Default is `true`. | | enableCycling | boolean | Cycles item selection in a when up/down arrows are pressed. Default is `false.` | | isOpen | boolean | Determines if component is open/rendered. | -| parentMenu | boolean | Determines if component is a parent menu or sub-menu. | +| onSelect | function | Callback when [Item](./Item.md) is selected. | | selectedIndex | number | Pre-select an item based on it's index number. | + + +### Render hooks + +This component has special callback props tied into it's mounting cycle. + +| Prop | Type | Description | +| --- | --- | --- | +| onBeforeOpen | function | Fires when the component is mounted, but not rendered. | +| onOpen | function | Fires as soon as the component has rendered. | +| onBeforeClose | function | Fires when the component is about to unmount. | +| onClose | function | Fires after the component is unmounted. | + +See [Portal's documentation](../Portal#render-hooks) for more details. diff --git a/src/components/Dropdown/docs/Trigger.md b/src/components/Dropdown/docs/Trigger.md index e66f9fc0b..ab2134022 100644 --- a/src/components/Dropdown/docs/Trigger.md +++ b/src/components/Dropdown/docs/Trigger.md @@ -12,10 +12,27 @@ A Trigger component is an enhanced [Button](../Button) component used specifical ``` +### Custom Markup + +By default, if a text-node is passed into Trigger, it will render the text-node into an enhanced [Button](../../Button) wrapper. However, you can provide you own component into Trigger as well. + +```html + + + Link Trigger + + +``` + + ## Props | Prop | Type | Description | | --- | --- | --- | +| children | string / number / element | Single child component/text-node to render. Single child component/text-node to render. | | direction | string | Direction of the dropdown. | +| onBlur | function | Callback function when component blurs. | +| onClick | function | Callback function when component is clicked. | +| onFocus | function | Callback function when component focuses. | For additional props, see the [Button](../Button) component. diff --git a/src/components/Dropdown/index.js b/src/components/Dropdown/index.js index efde78382..b59bcac42 100644 --- a/src/components/Dropdown/index.js +++ b/src/components/Dropdown/index.js @@ -1,225 +1,3 @@ -import React, {PureComponent as Component} from 'react' -import ReactDOM from 'react-dom' -import PropTypes from 'prop-types' -import EventListener from '../EventListener' -import Divider from './Divider' -import Item from './Item' -import { default as Menu, MenuComponent } from './Menu' -import Trigger from './Trigger' -import KeypressListener from '../KeypressListener' -import Keys from '../../constants/Keys' -import classNames from '../../utilities/classNames' -import { focusNextFocusableNode, focusPreviousFocusableNode } from '../../utilities/focus' -import { isNodeElement } from '../../utilities/node' -import { noop } from '../../utilities/other' - -export const propTypes = { - closeMenuOnClick: PropTypes.bool, - direction: PropTypes.string, - isOpen: PropTypes.bool, - onClose: PropTypes.func, - onSelect: PropTypes.func, - selectedIndex: PropTypes.number -} -const defaultProps = { - closeMenuOnClick: true, - direction: 'down', - onClose: noop -} - -class Dropdown extends Component { - constructor (props) { - super() - this.state = { - isOpen: props.isOpen, - selectedIndex: props.selectedIndex - } - - this.isFocused = false - this.triggerNode = null - - this.handleOnBodyClick = this.handleOnBodyClick.bind(this) - this.handleOnTriggerClick = this.handleOnTriggerClick.bind(this) - this.handleOnTriggerFocus = this.handleOnTriggerFocus.bind(this) - this.handleOnMenuClose = this.handleOnMenuClose.bind(this) - this.handleDownArrow = this.handleDownArrow.bind(this) - this.handleTab = this.handleTab.bind(this) - this.handleShiftTab = this.handleShiftTab.bind(this) - } - - componentDidMount () { - this.setTriggerNode() - } - - componentWillUpdate (nextProps) { - if (this.props.isOpen !== nextProps.isOpen) { - this.setState({ isOpen: nextProps.isOpen }) - } - } - - setTriggerNode () { - const trigger = this.refs.trigger - /* istanbul ignore next */ - if (!this.triggerNode) { - this.triggerNode = isNodeElement(trigger) ? trigger : ReactDOM.findDOMNode(trigger) - } - } - - handleOnBodyClick (event) { - const clickNode = event.target - if (this.state.isOpen) { - if (clickNode !== this.triggerNode) { - this.handleOnMenuClose() - } else { - this.handleOnTriggerClick() - } - } else { - /* istanbul ignore else */ - if (clickNode === this.triggerNode) { - this.handleOnTriggerClick() - } - } - } - - handleOnTriggerClick () { - this.setState({ isOpen: !this.state.isOpen }) - this.isFocused = true - } - - handleOnTriggerFocus () { - setTimeout(() => { - this.isFocused = true - }, 0) - } - - handleOnMenuClose () { - const { onClose } = this.props - this.setState({ selectedIndex: null, isOpen: false }) - this.isFocused = false - - onClose() - } - - handleDownArrow () { - const { isOpen } = this.state - if (!isOpen && this.isFocused) { - this.setState({ isOpen: true, selectedIndex: 0 }) - } - } - - handleTab (event) { - this.isFocused = false - /* istanbul ignore else */ - if (this.state.isOpen) { - event.preventDefault() - this.handleOnMenuClose() - /* istanbul ignore next */ - if (this.triggerNode) { - /* istanbul ignore next */ - // Method is tested in Jest. However, it can't be - // tested in a React instance of Dropdown - focusNextFocusableNode(this.triggerNode) - } - return false - } - } - - handleShiftTab (event) { - this.isFocused = false - /* istanbul ignore else */ - if (this.state.isOpen) { - event.preventDefault() - this.handleOnMenuClose() - /* istanbul ignore next */ - if (this.triggerNode) { - /* istanbul ignore next */ - // Method is tested in Jest. However, it can't be - // tested in a React instance of Dropdown - focusPreviousFocusableNode(this.triggerNode) - } - return false - } - } - - render () { - const { - children, - className, - closeMenuOnClick, - direction, - onClose, - onSelect, - isOpen: propsisOpen, - selectedIndex: propsSelectedIndex, - ...rest - } = this.props - const { - isOpen, - selectedIndex - } = this.state - - const handleOnBodyClick = this.handleOnBodyClick - const handleOnTriggerFocus = this.handleOnTriggerFocus - const handleOnMenuClose = this.handleOnMenuClose - const handleDownArrow = this.handleDownArrow - const handleTab = this.handleTab - const handleShiftTab = this.handleShiftTab - - const componentClassName = classNames( - 'c-Dropdown', - isOpen && 'is-open', - className - ) - const childrenMarkup = React.Children.map(children, (child, index) => { - if (index === 0) { - let triggerProps = { - ref: 'trigger', - onFocus: handleOnTriggerFocus, - 'aria-haspopup': true, - 'aria-expanded': isOpen - } - if (child.type === Trigger) { - // TODO: Allow for dynamic directions - triggerProps = Object.assign({}, triggerProps, { - direction: 'down' - }) - } - return React.cloneElement(child, triggerProps) - } - - if (child.type === Menu || child.type === MenuComponent) { - return isOpen ? React.cloneElement(child, { - closeMenuOnClick, - direction, - isOpen, - onClose: handleOnMenuClose, - onSelect: onSelect || child.props.onSelect, - ref: 'menu', - trigger: this.triggerNode, - selectedIndex: child.props.selectedIndex !== undefined ? child.props.selectedIndex : selectedIndex - }) : null - } - - return child - }) - - return ( -
    - - - - - {childrenMarkup} -
    - ) - } -} - -Dropdown.propTypes = propTypes -Dropdown.defaultProps = defaultProps -Dropdown.Divider = Divider -Dropdown.Item = Item -Dropdown.Menu = Menu -Dropdown.Trigger = Trigger +import Dropdown from './Dropdown' export default Dropdown diff --git a/src/components/Dropdown/tests/Header.test.js b/src/components/Dropdown/tests/Header.test.js new file mode 100644 index 000000000..3ebb6dd78 --- /dev/null +++ b/src/components/Dropdown/tests/Header.test.js @@ -0,0 +1,8 @@ +import Header from '../Header' +import { baseComponentTest } from '../../../tests/helpers/components' + +const baseComponentOptions = { + className: 'c-DropdownHeader' +} + +baseComponentTest(Header, baseComponentOptions) diff --git a/src/components/Dropdown/tests/Item.test.js b/src/components/Dropdown/tests/Item.test.js index 51714ed1e..e63f92e12 100644 --- a/src/components/Dropdown/tests/Item.test.js +++ b/src/components/Dropdown/tests/Item.test.js @@ -184,19 +184,6 @@ describe('Sub menu', () => { expect(o.props().selectedIndex).toBe(3) }) - - test('Sets parentMenu prop on sub-menu', () => { - const wrapper = mount( - - Nested - - - ) - - const o = wrapper.find(Menu) - - expect(o.props().parentMenu).toBeTruthy() - }) }) describe('Events', () => { @@ -309,3 +296,40 @@ describe('Events', () => { expect(spy).toHaveBeenCalledWith('Brick') }) }) + +describe('Disabled', () => { + test('Is not disabled by default', () => { + const wrapper = shallow() + + expect(wrapper.instance().props.disabled).not.toBeTruthy() + expect(wrapper.hasClass('is-disabled')).not.toBeTruthy() + }) + + test('Can be set to disabled', () => { + const wrapper = shallow() + + expect(wrapper.instance().props.disabled).toBeTruthy() + expect(wrapper.hasClass('is-disabled')).toBeTruthy() + }) + + test('onClick callback cannot be fired, if disabled', () => { + const spy = jest.fn() + const wrapper = shallow() + const o = wrapper.find('.c-DropdownItem__link') + + o.simulate('click') + + expect(spy).not.toHaveBeenCalled() + }) + + test('Does not render sub-menu if open, and disabled', () => { + const wrapper = mount( + + + + ) + const o = wrapper.find('c-DropdownItem__menu') + + expect(o.length).not.toBeTruthy() + }) +}) diff --git a/src/components/Dropdown/tests/Menu.test.js b/src/components/Dropdown/tests/Menu.test.js index 52d5232e6..827b115fa 100644 --- a/src/components/Dropdown/tests/Menu.test.js +++ b/src/components/Dropdown/tests/Menu.test.js @@ -219,6 +219,41 @@ describe('Items', () => { done() }, 100) }) + + test('Closes ALL menus when a sub-menu item is clicked', () => { + const spy = jest.fn() + const wrapper = mount( + + + + + + + + + + + + + + + + + + + ) + + const o = wrapper.find(Item).first() + .find(MenuComponent) + .find(Item).first() + .find(MenuComponent) + .find(Item).first() + .find('.c-DropdownItem__link') + + o.simulate('click') + + expect(spy).toHaveBeenCalled() + }) }) describe('Selected', () => { @@ -454,13 +489,17 @@ describe('Keyboard Arrows: Left/Right', () => { test('Left arrow fires onClose callback if menu is sub menu', () => { const spy = jest.fn() mount( - + - ) + , { + context: { + parentMenu: (
    ) + } + }) simulateKeyPress(Keys.LEFT_ARROW, 'keydown') @@ -470,7 +509,7 @@ describe('Keyboard Arrows: Left/Right', () => { test('Left arrow does not fire onClose callback, if menu is sub menu, but not focused', () => { const spy = jest.fn() const wrapper = mount( - + @@ -487,7 +526,7 @@ describe('Keyboard Arrows: Left/Right', () => { test('Right arrow sets hoverIndex + unfocuses menu, if sub menu is present', () => { const spy = jest.fn() const wrapper = mount( - + Sub Menu @@ -510,7 +549,7 @@ describe('Keyboard Arrows: Left/Right', () => { test('Right arrow does not unfocus, if there is no sub menu is present', () => { const spy = jest.fn() const wrapper = mount( - + @@ -530,7 +569,7 @@ describe('Keyboard Arrows: Left/Right', () => { test('Right arrow does not unfocus, if there is no focusIndex', () => { const spy = jest.fn() const wrapper = mount( - + @@ -553,7 +592,7 @@ describe('Keyboard Arrows: Left/Right', () => { test('Right arrow does not fire onClose callback, if menu is sub menu, but not focused', () => { const spy = jest.fn() const wrapper = mount( - + diff --git a/src/components/Dropdown/tests/Trigger.test.js b/src/components/Dropdown/tests/Trigger.test.js index 08b4482d1..f335cea2a 100644 --- a/src/components/Dropdown/tests/Trigger.test.js +++ b/src/components/Dropdown/tests/Trigger.test.js @@ -1,8 +1,132 @@ +import React from 'react' +import { shallow } from 'enzyme' import Trigger from '../Trigger' -import { baseComponentTest } from '../../../tests/helpers/components' +import { Button, Icon } from '../../' -const baseComponentOptions = { - className: 'c-DropdownTrigger' -} +describe('ClassName', () => { + test('Has default className', () => { + const wrapper = shallow() -baseComponentTest(Trigger, baseComponentOptions) + expect(wrapper.hasClass('c-DropdownTrigger')).toBeTruthy() + }) + + test('Applies custom className if specified', () => { + const customClass = 'piano-key-neck-tie' + const wrapper = shallow() + + expect(wrapper.hasClass(customClass)).toBeTruthy() + }) +}) + +describe('Active', () => { + test('Is not active by default', () => { + const wrapper = shallow() + + expect(wrapper.props().isActive).not.toBeTruthy() + }) + + test('Adds active className, if set', () => { + const wrapper = shallow() + + expect(wrapper.hasClass('is-active')).toBeTruthy() + }) + + test('Adds active className to non-text child, if set', () => { + const wrapper = shallow(Link) + const o = wrapper.find('a') + + expect(o.hasClass('is-active')).toBeTruthy() + }) +}) + +describe('Children', () => { + test('Can render a text-node child, with default button markup', () => { + const wrapper = shallow(Text) + const o = wrapper.find(Button) + + expect(o.length).toBeTruthy() + expect(o.find(Icon).length).toBeTruthy() + }) + + test('Can render a non-text-node child, while preserving props', () => { + const wrapper = shallow( + + Link + + ) + + const o = wrapper.find('a') + const n = wrapper.find(Button) + + expect(o.length).toBeTruthy() + expect(o.hasClass('c-DropdownTrigger')).toBeTruthy() + expect(o.hasClass('buddy-link')).toBeTruthy() + expect(o.props().style.background).toBe('red') + expect(o.props().tabIndex).toBe(0) + expect(n.length).not.toBeTruthy() + }) + + test('Only accepts a single child component', () => { + const wrapper = () => shallow( + + Link + Link Two + + ) + + expect(wrapper).toThrow(Error, /React.Children.only/) + }) +}) + +describe('Callbacks', () => { + test('onBlur callback can be fired', () => { + const spy = jest.fn() + const wrapper = shallow() + + wrapper.simulate('blur') + + expect(spy).toHaveBeenCalled() + }) + + test('onClick callback can be fired', () => { + const spy = jest.fn() + const wrapper = shallow() + const o = wrapper.find(Button) + + o.simulate('click') + + expect(spy).toHaveBeenCalled() + }) + + test('onFocus callback can be fired', () => { + const spy = jest.fn() + const wrapper = shallow() + + wrapper.simulate('focus') + + expect(spy).toHaveBeenCalled() + }) +}) + +describe('Direction', () => { + test('Has a default direction of down', () => { + const wrapper = shallow() + + expect(wrapper.hasClass('is-down')).toBeTruthy() + }) + + test('Can set custom direction', () => { + const wrapper = shallow() + + expect(wrapper.hasClass('is-down')).not.toBeTruthy() + expect(wrapper.hasClass('is-up')).toBeTruthy() + }) +}) + +describe('Style', () => { + test('Can accept custom styles', () => { + const wrapper = shallow() + + expect(wrapper.props().style.padding).toBe(200) + }) +}) diff --git a/src/components/PortalWrapper/index.js b/src/components/PortalWrapper/index.js index 3d42cdce7..41b7e86ca 100644 --- a/src/components/PortalWrapper/index.js +++ b/src/components/PortalWrapper/index.js @@ -81,6 +81,7 @@ const PortalWrapper = (options = defaultOptions) => ComposedComponent => { unlockBody () { const { lockBodyOnOpen } = this.state + /* istanbul ignore else */ if (lockBodyOnOpen) { document.body.style.overflow = this.bodyOverflowStyle } diff --git a/src/styles/components/Dropdown/DropdownHeader.scss b/src/styles/components/Dropdown/DropdownHeader.scss new file mode 100644 index 000000000..a0774060e --- /dev/null +++ b/src/styles/components/Dropdown/DropdownHeader.scss @@ -0,0 +1,4 @@ +.c-DropdownHeader { + @import "../../resets/base"; + padding: 8px 16px; +} diff --git a/src/styles/components/Dropdown/DropdownItem.scss b/src/styles/components/Dropdown/DropdownItem.scss index f4d101061..737f7e9c1 100644 --- a/src/styles/components/Dropdown/DropdownItem.scss +++ b/src/styles/components/Dropdown/DropdownItem.scss @@ -1,10 +1,11 @@ +@import "pack/seed-this/_index"; @import "../../configs/color"; .c-DropdownItem { @import "../../resets/base"; + $block: this(); cursor: pointer; padding: 0; - list-style: none; user-select: none; &:last-child { @@ -26,7 +27,6 @@ outline: none; padding: 8px 16px; transition: background-color 0.1s ease; - &:active { background-color: rgba(_color(grey, 400), 1); } @@ -35,4 +35,26 @@ &__submenu-icon { margin-right: -8px; } + + // States + &.is-disabled { + color: _color(text, muted); + cursor: not-allowed; + &.is-hover { + background-color: transparent; + } + &.is-focused { + background-color: rgba(_color(grey, 300), 0.5); + } + &.is-selected { + background-color: transparent; + color: _color(text, muted); + } + + #{$block}__link { + &:active { + background-color: transparent; + } + } + } } diff --git a/src/styles/components/Dropdown/DropdownMenu.scss b/src/styles/components/Dropdown/DropdownMenu.scss index 3bf262ce0..4d0dea4f6 100644 --- a/src/styles/components/Dropdown/DropdownMenu.scss +++ b/src/styles/components/Dropdown/DropdownMenu.scss @@ -8,7 +8,6 @@ } &__list { - list-style: none; margin: 0; padding: 5px 0; } diff --git a/src/styles/components/Dropdown/__index.scss b/src/styles/components/Dropdown/__index.scss index 201e0111d..508287586 100644 --- a/src/styles/components/Dropdown/__index.scss +++ b/src/styles/components/Dropdown/__index.scss @@ -1,4 +1,5 @@ @import "./DropdownDivider"; +@import "./DropdownHeader"; @import "./DropdownItem"; @import "./DropdownMenu"; @import "./DropdownTrigger"; diff --git a/stories/Dropdown.js b/stories/Dropdown.js index 0eac99866..3d4bf8dda 100644 --- a/stories/Dropdown.js +++ b/stories/Dropdown.js @@ -1,6 +1,6 @@ import React from 'react' import { storiesOf } from '@storybook/react' -import { Dropdown, Flexy } from '../src/index.js' +import { Dropdown, Flexy, Link } from '../src/index.js' const logAction = (i) => () => { console.log(`Action ${i}`) @@ -24,71 +24,105 @@ const onBeforeClose = onClose => { onClose() } -storiesOf('Dropdown', module) - .add('Default', () => ( -
    - - - - - Dropdown - - - - Nested - - - Nested - - - Arrow up/down Cycling enabled - - {itemsMarkup(4)} - - - {itemsMarkup(4)} - - - - {itemsMarkup(10)} - - - - - - - Another - - - {itemsMarkup(5)} - - - - - Link - - -
    - )) - .add('Item', () => ( -
    - - Item - - - Nested - - - Nested - - - Arrow up/down Cycling enabled - - {itemsMarkup(4)} - - - {itemsMarkup(4)} - - -
    - )) +const onOpen = () => { + console.log('onOpen') +} + +const stories = storiesOf('Dropdown', module) + +stories.add('Default', () => ( +
    + + + + + Dropdown + + + Header + + Nested + + + Nested + + + Arrow up/down Cycling enabled + + {itemsMarkup(4)} + + + {itemsMarkup(4)} + + + + Disabled Item + + + {itemsMarkup(10)} + + + + + + + Another + + + {itemsMarkup(5)} + + + + + Link + + +
    +)) + +stories.add('Item', () => ( +
    + + Item + + + Nested + + + Nested + + + Arrow up/down Cycling enabled + + {itemsMarkup(4)} + + + {itemsMarkup(4)} + + +
    +)) + +stories.add('Trigger', () => ( +
    + + + Default + + + {itemsMarkup(10)} + + + +

    + + + + Custom Link + + + {itemsMarkup(10)} + + +
    +))