From 346501e736e280e1b744009412c5285087eb6c90 Mon Sep 17 00:00:00 2001 From: Matthew Smith Date: Wed, 29 Apr 2015 08:12:33 -0600 Subject: [PATCH] [changed] DropdownButton, SplitButton, DropdownMenu, MenuItem completely rewritten Adds: - Keyboard Navigation - Aria properties for Assistive Technology - Option to use custom menu with any of the dropdown variations - `NavDropdown` component (Similar to `DropdownButton` but specifically for use within `Nav` components. - `DropdownToggle` Component which can be used as an alternative to the `DropdownButton` `title` prop. Can either be directly imported or used as a static property of the `DropdownButton` with `DropdownButton.Toggle`. _(Useful should you want to use glyphicons or custom html within the toggle that's not a simple string.)_ - `SplitToggle` Similar to the `DropdownToggle` but targeted at the `SplitButton`'s toggle. - Generic `Dropdown` component for easy dropdown customization. Changed: - Event handling of all these components to be in line with #419 - Written with ES6 class syntax Removed: - DropdownStateMixin - Everything is using ES6 class syntax so no more mixin usage --- .ackrc | 2 + .eslintrc | 6 +- .projections.json | 4 + docs/assets/style.css | 35 +- docs/examples/.eslintrc | 3 + docs/examples/ButtonGroupJustified.js | 2 +- docs/examples/ButtonGroupNested.js | 2 +- docs/examples/ButtonGroupVertical.js | 6 +- docs/examples/CollapsibleNav.js | 4 +- docs/examples/DropdownButtonBasic.js | 2 +- docs/examples/DropdownButtonCustom.js | 35 ++ docs/examples/DropdownButtonCustomMenu.js | 75 ++++ docs/examples/DropdownButtonNoCaret.js | 2 +- docs/examples/DropdownButtonSizes.js | 16 +- docs/examples/InputAddons.js | 2 +- docs/examples/MenuItem.js | 8 +- docs/examples/NavDropdown.js | 8 +- docs/examples/NavbarBasic.js | 4 +- docs/examples/NavbarBrand.js | 4 +- docs/examples/NavbarCollapsible.js | 4 +- docs/examples/SplitButtonBasic.js | 2 +- docs/examples/SplitButtonDropup.js | 4 +- docs/examples/SplitButtonRight.js | 2 +- docs/generate-metadata.js | 2 +- docs/src/ComponentsPage.js | 31 +- docs/src/PropTable.js | 43 ++- docs/src/ReactPlayground.js | 3 + docs/src/Samples.js | 2 + karma.conf.js | 2 +- package.json | 6 +- src/Dropdown.js | 288 ++++++++++++++ src/DropdownButton.js | 160 +++----- src/DropdownMenu.js | 158 ++++++-- src/DropdownStateMixin.js | 85 ---- src/DropdownToggle.js | 54 +++ src/MenuItem.js | 106 ++--- src/NavDropdown.js | 32 ++ src/RootCloseWrapper.js | 26 +- src/SplitButton.js | 134 +++---- src/SplitToggle.js | 16 + src/index.js | 7 +- src/utils/CustomPropTypes.js | 101 +++-- src/utils/childrenToArray.js | 15 + test/DropdownButtonSpec.js | 293 ++++++-------- test/DropdownMenuSpec.js | 237 ++++++++---- test/DropdownSpec.js | 447 ++++++++++++++++++++++ test/DropdownToggleSpec.js | 94 +++++ test/InputSpec.js | 4 +- test/MenuItemSpec.js | 181 +++++---- test/NavDropdownSpec.js | 65 ++++ test/SplitButtonSpec.js | 234 +++-------- tools/.eslintrc | 8 + tools/buildBabel.js | 8 +- webpack/.eslintrc | 12 + webpack/docs.config.js | 2 + 55 files changed, 2150 insertions(+), 938 deletions(-) create mode 100644 .projections.json create mode 100644 docs/examples/DropdownButtonCustom.js create mode 100644 docs/examples/DropdownButtonCustomMenu.js create mode 100644 src/Dropdown.js delete mode 100644 src/DropdownStateMixin.js create mode 100644 src/DropdownToggle.js create mode 100644 src/NavDropdown.js create mode 100644 src/SplitToggle.js create mode 100644 src/utils/childrenToArray.js create mode 100644 test/DropdownSpec.js create mode 100644 test/DropdownToggleSpec.js create mode 100644 test/NavDropdownSpec.js create mode 100644 webpack/.eslintrc diff --git a/.ackrc b/.ackrc index ac3018e347..bd92dbf50b 100644 --- a/.ackrc +++ b/.ackrc @@ -1,3 +1,4 @@ +--ignore-dir=.coverage --ignore-dir=lib --ignore-dir=dist --ignore-dir=amd @@ -7,3 +8,4 @@ --ignore-dir=tmp-bower-repo --ignore-file=match:test_bundle.js --ignore-file=match:components.html +--ignore-file=match:.orig diff --git a/.eslintrc b/.eslintrc index 571bb3202e..3f0a245abe 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,7 +10,8 @@ "parser": "babel-eslint", "plugins": [ "react", - "babel" + "babel", + "lodash" ], "rules": { "constructor-super": 2, @@ -32,11 +33,12 @@ "react/no-did-mount-set-state": 2, "react/no-did-update-set-state": 2, "react/no-multi-comp": 2, - "react/prop-types": [2, { "ignore": [ "children", "className" ] }], + "react/prop-types": [1, { "ignore": [ "children", "className" ] }], "react/react-in-jsx-scope": 2, "react/self-closing-comp": 2, "react/wrap-multilines": 2, "react/jsx-uses-vars": 2, + "lodash/import": 2, "space-infix-ops": 2, "strict": [2, "never"] } diff --git a/.projections.json b/.projections.json new file mode 100644 index 0000000000..d1519dd643 --- /dev/null +++ b/.projections.json @@ -0,0 +1,4 @@ +{ + "src/*.js": { "alternate": "test/{}Spec.js" }, + "test/*Spec.js": { "alternate": "src/{}.js" } +} diff --git a/docs/assets/style.css b/docs/assets/style.css index 98330c9927..d71f5df7a4 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -36,7 +36,7 @@ body { } } -.navbar>.container .navbar-brand, .navbar>.container-fluid .navbar-brand { +.bs-docs-nav .navbar-brand { color: #00d8ff; } @@ -173,6 +173,29 @@ body { } +.bs-example .super-colors { + background: -moz-linear-gradient( top , + rgba(255, 0, 0, 1) 0%, + rgba(255, 255, 0, 1) 15%, + rgba(0, 255, 0, 1) 30%, + rgba(0, 255, 255, 1) 50%, + rgba(0, 0, 255, 1) 65%, + rgba(255, 0, 255, 1) 80%, + rgba(255, 0, 0, 1) 100%); + background: -webkit-gradient(linear, left top, left bottom, + color-stop(0%, rgba(255, 0, 0, 1)), + color-stop(15%, rgba(255, 255, 0, 1)), + color-stop(30%, rgba(0, 255, 0, 1)), + color-stop(50%, rgba(0, 255, 255, 1)), + color-stop(65%, rgba(0, 0, 255, 1)), + color-stop(80%, rgba(255, 0, 255, 1)), + color-stop(100%, rgba(255, 0, 0, 1))); +} + +/*.bs-example .custom-menu > ul > li { + padding: 0 20px; +}*/ + .anchor, .anchor:hover, .anchor:active, @@ -199,3 +222,13 @@ h4:hover .anchor-icon, h4 a:focus .anchor-icon { opacity: 0.5; } + +.prop-desc pre { + border-radius: 0; + border-width: 0; + border-left-width: 3px; +} + +.prop-desc-heading { + margin-bottom: 10px; +} diff --git a/docs/examples/.eslintrc b/docs/examples/.eslintrc index 536c05e521..946c5f8337 100644 --- a/docs/examples/.eslintrc +++ b/docs/examples/.eslintrc @@ -19,7 +19,9 @@ "Carousel", "CarouselItem", "Col", + "Dropdown", "DropdownButton", + "DropdownMenu", "FormControls", "Glyphicon", "Grid", @@ -30,6 +32,7 @@ "ListGroupItem", "Nav", "Navbar", + "NavDropdown", "NavItem", "MenuItem", "Modal", diff --git a/docs/examples/ButtonGroupJustified.js b/docs/examples/ButtonGroupJustified.js index 989cf46ddf..1ca9b95088 100644 --- a/docs/examples/ButtonGroupJustified.js +++ b/docs/examples/ButtonGroupJustified.js @@ -2,7 +2,7 @@ const buttonGroupInstance = ( - + Dropdown link Dropdown link diff --git a/docs/examples/ButtonGroupNested.js b/docs/examples/ButtonGroupNested.js index e1a9e1eb9d..b579fdb015 100644 --- a/docs/examples/ButtonGroupNested.js +++ b/docs/examples/ButtonGroupNested.js @@ -2,7 +2,7 @@ const buttonGroupInstance = ( - + Dropdown link Dropdown link diff --git a/docs/examples/ButtonGroupVertical.js b/docs/examples/ButtonGroupVertical.js index 9453906974..49cc941227 100644 --- a/docs/examples/ButtonGroupVertical.js +++ b/docs/examples/ButtonGroupVertical.js @@ -2,17 +2,17 @@ const buttonGroupInstance = ( - + Dropdown link Dropdown link - + Dropdown link Dropdown link - + Dropdown link Dropdown link diff --git a/docs/examples/CollapsibleNav.js b/docs/examples/CollapsibleNav.js index 2d04d65fc8..6eb611b076 100644 --- a/docs/examples/CollapsibleNav.js +++ b/docs/examples/CollapsibleNav.js @@ -4,13 +4,13 @@ const navbarInstance = ( ); } }); -React.render(, mountNode); +React.render(, mountNode); diff --git a/docs/examples/NavbarBasic.js b/docs/examples/NavbarBasic.js index 756b6634b7..a3923a627f 100644 --- a/docs/examples/NavbarBasic.js +++ b/docs/examples/NavbarBasic.js @@ -3,13 +3,13 @@ const navbarInstance = ( ); diff --git a/docs/examples/NavbarBrand.js b/docs/examples/NavbarBrand.js index e210c5270f..594d7b8505 100644 --- a/docs/examples/NavbarBrand.js +++ b/docs/examples/NavbarBrand.js @@ -3,13 +3,13 @@ const navbarInstance = ( ); diff --git a/docs/examples/NavbarCollapsible.js b/docs/examples/NavbarCollapsible.js index 35b9564cf1..91c34d5e87 100644 --- a/docs/examples/NavbarCollapsible.js +++ b/docs/examples/NavbarCollapsible.js @@ -3,13 +3,13 @@ const navbarInstance = ( ); diff --git a/docs/examples/SplitButtonBasic.js b/docs/examples/SplitButtonBasic.js index 4507b39a6a..de6aee53f2 100644 --- a/docs/examples/SplitButtonBasic.js +++ b/docs/examples/SplitButtonBasic.js @@ -2,7 +2,7 @@ const BUTTONS = ['Default', 'Primary', 'Success', 'Info', 'Warning', 'Danger']; function renderDropdownButton (title, i) { return ( - + Action Another action Something else here diff --git a/docs/examples/SplitButtonDropup.js b/docs/examples/SplitButtonDropup.js index abfe5cfe0f..a5c5018bfa 100644 --- a/docs/examples/SplitButtonDropup.js +++ b/docs/examples/SplitButtonDropup.js @@ -1,7 +1,7 @@ const buttonsInstance = (
- + Action Another action Something else here @@ -11,7 +11,7 @@ const buttonsInstance = ( - + Action Another action Something else here diff --git a/docs/examples/SplitButtonRight.js b/docs/examples/SplitButtonRight.js index b9254720a5..638a7ef9f2 100644 --- a/docs/examples/SplitButtonRight.js +++ b/docs/examples/SplitButtonRight.js @@ -1,5 +1,5 @@ const buttonsInstance = ( - + Action Another action Something else here diff --git a/docs/generate-metadata.js b/docs/generate-metadata.js index bf30c07d0e..63a821bcea 100644 --- a/docs/generate-metadata.js +++ b/docs/generate-metadata.js @@ -67,7 +67,7 @@ function applyPropDoclets(props, propName){ } -export default function generate(destination, options = { mixins: true }){ +export default function generate(destination, options = { mixins: true, inferComponent: true }){ return globp(__dirname + '/../src/**/*.js') //eslint-disable-line no-path-concat .then( files => { diff --git a/docs/src/ComponentsPage.js b/docs/src/ComponentsPage.js index 6d875bb4f9..2b234c11bf 100644 --- a/docs/src/ComponentsPage.js +++ b/docs/src/ComponentsPage.js @@ -181,13 +181,42 @@ const ComponentsPage = React.createClass({

Trigger dropdown menus that align to the right of the button using the pullRight prop.

+

Dropdown Customization

+

+ If the default handling of the dropdown menu and toggle components aren't to your liking, you can + customize them, by using the more basic Dropdown Component to explicitly specify + the Toggle and Menu components +

+
+

Additional Import Options

+

+ As a convenience Toggle and Menu components available as static properties + on the Dropdown component. However, you can also import them directly, from + the /lib directory like: {"require('react-bootstrap/lib/DropdownToggle')"}. +

+
+ + +

Custom Dropdown Components

+ +

+ For those that want to customize everything, you can forgo the included Toggle and Menu components, + and create your own. In order to tell the Dropdown component what role your custom components play + add a special prop bsRole to your menu or toggle components. The Dropdown expects + at least one component with bsRole='toggle' and exactly one with bsRole='menu'. +

+ +

Props

-

DropdownButton

+

DropdownButton

SplitButton

+ +

Dropdown

+
{/* Menu Item */} diff --git a/docs/src/PropTable.js b/docs/src/PropTable.js index 250a70a8f6..b759f7140c 100644 --- a/docs/src/PropTable.js +++ b/docs/src/PropTable.js @@ -1,11 +1,12 @@ import merge from 'lodash/object/merge'; import React from 'react'; - +import Glyphicon from '../../src/Glyphicon'; import Label from '../../src/Label'; import Table from '../../src/Table'; let cleanDocletValue = str => str.trim().replace(/^\{/, '').replace(/\}$/, ''); +let capitalize = str => str[0].toUpperCase() + str.substr(1); function getPropsData(componentData, metadata){ @@ -29,6 +30,8 @@ function getPropsData(componentData, metadata){ return props; } + + const PropTable = React.createClass({ contextTypes: { @@ -85,9 +88,12 @@ const PropTable = React.createClass({ { propData.doclets.deprecated - &&
{'Deprecated: ' + propData.doclets.deprecated + ' '}
+ &&
+ {'Deprecated: ' + propData.doclets.deprecated + ' '} +
} -
+ { this.renderControllableNote(propData, propName) } +
); @@ -104,6 +110,37 @@ const PropTable = React.createClass({ ); }, + renderControllableNote(prop, propName){ + let controllable = prop.doclets.controllable; + let isHandler = this.getDisplayTypeName(prop.type.name) === 'function'; + + if (!controllable) { + return false; + } + + let text = isHandler ? ( + + controls {controllable} + + ) : ( + + controlled by: {controllable}, + initial prop: {'default' + capitalize(propName)} + + ); + + return ( +
+ + + +  { text } + + +
+ ); + }, + getType(prop) { let type = prop.type || {}; let name = this.getDisplayTypeName(type.name); diff --git a/docs/src/ReactPlayground.js b/docs/src/ReactPlayground.js index e91b3ade92..f351323aa5 100644 --- a/docs/src/ReactPlayground.js +++ b/docs/src/ReactPlayground.js @@ -18,7 +18,9 @@ const Col = require('../../src/Col'); const Collapse = require('../../src/Collapse'); const CollapsibleMixin = require('../../src/CollapsibleMixin'); const CollapsibleNav = require('../../src/CollapsibleNav'); +const Dropdown = require('../../src/Dropdown').default; const DropdownButton = require('../../src/DropdownButton'); +const DropdownMenu = require('../../src/DropdownMenu'); const Fade = require('../../src/Fade'); const FormControls = require('../../src/FormControls'); const Glyphicon = require('../../src/Glyphicon'); @@ -33,6 +35,7 @@ const Modal = require('../../src/Modal'); const Nav = require('../../src/Nav'); const Navbar = require('../../src/Navbar'); const NavItem = require('../../src/NavItem'); +const NavDropdown = require('../../src/NavDropdown'); const Overlay = require('../../src/Overlay'); const OverlayTrigger = require('../../src/OverlayTrigger'); const PageHeader = require('../../src/PageHeader'); diff --git a/docs/src/Samples.js b/docs/src/Samples.js index 66e141a051..3722bc3dc4 100644 --- a/docs/src/Samples.js +++ b/docs/src/Samples.js @@ -19,6 +19,8 @@ export default { ButtonGroupJustified: require('fs').readFileSync(__dirname + '/../examples/ButtonGroupJustified.js', 'utf8'), ButtonGroupBlock: require('fs').readFileSync(__dirname + '/../examples/ButtonGroupBlock.js', 'utf8'), DropdownButtonBasic: require('fs').readFileSync(__dirname + '/../examples/DropdownButtonBasic.js', 'utf8'), + DropdownButtonCustom: require('fs').readFileSync(__dirname + '/../examples/DropdownButtonCustom.js', 'utf8'), + DropdownButtonCustomMenu: require('fs').readFileSync(__dirname + '/../examples/DropdownButtonCustomMenu.js', 'utf8'), SplitButtonBasic: require('fs').readFileSync(__dirname + '/../examples/SplitButtonBasic.js', 'utf8'), DropdownButtonSizes: require('fs').readFileSync(__dirname + '/../examples/DropdownButtonSizes.js', 'utf8'), DropdownButtonNoCaret: require('fs').readFileSync(__dirname + '/../examples/DropdownButtonNoCaret.js', 'utf8'), diff --git a/karma.conf.js b/karma.conf.js index 2e82eeceb4..da4f415448 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -39,7 +39,7 @@ module.exports = function (config) { webpack: webpackConfig, webpackMiddleware: { - noInfo: isCI + noInfo: true }, reporters: reporters, diff --git a/package.json b/package.json index f68ca2da50..36b4673e40 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "phantomjs": "^1.9.17", "portfinder": "^0.4.0", "react": "^0.13.3", - "react-component-metadata": "^1.2.1", + "react-component-metadata": "^1.3.0", "react-hot-loader": "^1.2.8", "react-router": "^0.13.3", "rimraf": "^2.4.2", @@ -110,6 +110,8 @@ "babel-runtime": "^5.8.19", "classnames": "^2.1.3", "dom-helpers": "^2.2.4", - "lodash": "^3.10.0" + "keycode": "^2.0.0", + "lodash": "^3.10.0", + "uncontrollable": "^3.0.0" } } diff --git a/src/Dropdown.js b/src/Dropdown.js new file mode 100644 index 0000000000..e50c506ff7 --- /dev/null +++ b/src/Dropdown.js @@ -0,0 +1,288 @@ +import React, { cloneElement } from 'react'; +import keycode from 'keycode'; +import classNames from 'classnames'; +import uncontrollable from 'uncontrollable'; +import ButtonGroup from './ButtonGroup'; +import DropdownToggle from './DropdownToggle'; +import DropdownMenu from './DropdownMenu'; +import CustomPropTypes from './utils/CustomPropTypes'; +import createChainedFunction from './utils/createChainedFunction'; +import find from 'lodash/collection/find'; +import omit from 'lodash/object/omit'; + +const TOGGLE_REF = 'toggle-btn'; + +export const TOGGLE_ROLE = DropdownToggle.defaultProps.bsRole; +export const MENU_ROLE = DropdownMenu.defaultProps.bsRole; + +class Dropdown extends React.Component { + + constructor(props) { + super(props); + + this.Toggle = DropdownToggle; + + this.toggleOpen = this.toggleOpen.bind(this); + this.handleClick = this.handleClick.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleClose = this.handleClose.bind(this); + this.extractChildren = this.extractChildren.bind(this); + + this.refineMenu = this.refineMenu.bind(this); + this.refineToggle = this.refineToggle.bind(this); + + this.childExtractors = [{ + key: 'toggle', + matches: child => child.props.bsRole === TOGGLE_ROLE, + refine: this.refineToggle + }, { + key: 'menu', + exclusive: true, + matches: child => child.props.bsRole === MENU_ROLE, + refine: this.refineMenu + }]; + + this.state = {}; + } + + componentDidMount() { + let menu = this.refs.menu; + if (this.props.open && menu.focusNext) { + menu.focusNext(); + } + } + + componentDidUpdate(prevProps, prevState) { + let menu = this.refs.menu; + if (this.props.open && !prevProps.open && menu.focusNext) { + menu.focusNext(); + } + } + + render() { + let children = this.extractChildren(); + let Component = this.props.componentClass; + + let props = omit(this.props, ['id']); + + const rootClasses = { + open: this.props.open, + dropdown: !this.props.dropup, + dropup: this.props.dropup + }; + + return ( + + { children } + + ); + } + + toggleOpen() { + let open = !this.props.open; + + if (this.props.onToggle){ + this.props.onToggle(open); + } + } + + handleClick(event) { + if (this.props.disabled) { + return; + } + + this.toggleOpen(); + } + + handleKeyDown(event) { + let focusNext = () => { + if (this.refs.menu.focusNext) { + this.refs.menu.focusNext(); + } + }; + + switch(event.keyCode) { + case keycode.codes.down: + if (!this.props.open) { + this.toggleOpen(); + } + else { + focusNext(); + } + event.preventDefault(); + break; + case keycode.codes.esc: + case keycode.codes.tab: + if (this.props.open) { + this.handleClose(event); + } + break; + } + } + + handleClose(event) { + if (!this.props.open) { + return; + } + + // we need to let the current event finish before closing the menu. + // otherwise the menu may close, shifting focus to document.body, before focus has moved + // to the next focusable input + if (event && event.keyCode === keycode.codes.tab){ + setTimeout(this.toggleOpen); + } + else { + this.toggleOpen(); + } + + if (event && event.type === 'keydown' && event.keyCode === keycode.codes.esc) { + let toggle = React.findDOMNode(this.refs[TOGGLE_REF]); + event.preventDefault(); + event.stopPropagation(); + toggle.focus(); + } + } + + extractChildren() { + let open = !!this.props.open; + let seen = {}; + + return React.Children.map(this.props.children, child => { + let extractor = find(this.childExtractors, x => x.matches(child)); + + if (extractor) { + if (seen[extractor.key]){ + return false; + } + + seen[extractor.key] = extractor.exclusive; + child = extractor.refine(child, open); + } + + return child; + }); + } + + refineMenu(menu, open) { + const menuProps = { + ref: 'menu', + open, + labelledBy: this.props.id, + pullRight: this.props.pullRight + }; + + menuProps.onClose = createChainedFunction( + menu.props.onClose, + this.props.onClose, + this.handleClose + ); + + menuProps.onSelect = createChainedFunction( + menu.props.onSelect, + this.props.onSelect, + this.handleClose + ); + + return cloneElement(menu, menuProps, menu.props.children); + } + + refineToggle(toggle, open) { + let toggleProps = { + open, + id: this.props.id, + ref: TOGGLE_REF + }; + + toggleProps.onClick = createChainedFunction( + toggle.props.onClick, + this.handleClick + ); + + toggleProps.onKeyDown = createChainedFunction( + toggle.props.onKeyDown, + this.handleKeyDown + ); + + return cloneElement(toggle, toggleProps, toggle.props.children); + } +} + +Dropdown.Toggle = DropdownToggle; + +Dropdown.TOGGLE_REF = TOGGLE_REF; + +Dropdown.defaultProps = { + componentClass: ButtonGroup +}; + +Dropdown.propTypes = { + /** + * The menu will open above the dropdown button, instead of below it. + */ + dropup: React.PropTypes.bool, + + /** + * An html id attribute, necessary for assistive technologies, such as screen readers. + * @type {string|number} + * @required + */ + id: CustomPropTypes.isRequiredForA11y( + React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.number + ]) + ), + + componentClass: CustomPropTypes.elementType, + + /** + * The children of a Dropdown may be a `` or a ``. + * @type {node} + */ + children: CustomPropTypes.all([ + CustomPropTypes.requiredRoles(TOGGLE_ROLE, MENU_ROLE), + CustomPropTypes.exclusiveRoles(MENU_ROLE) + ]), + + /** + * Align the menu to the right side of the Dropdown toggle + */ + pullRight: React.PropTypes.bool, + + /** + * Whether or not the Dropdown is visible. + * + * @controllable onToggle + */ + open: React.PropTypes.bool, + + /** + * A callback fired when the Dropdown wishes to change visibility. Called with the requested + * `open` value. + * + * ```js + * function(Boolean isOpen){} + * ``` + * @controllable open + */ + onToggle: React.PropTypes.func, + + /** + * A callback fired when a menu item is selected. + * + * ```js + * function(Object event, Any eventKey) + * ``` + */ + onSelect: React.PropTypes.func +}; + +Dropdown = uncontrollable(Dropdown, { open: 'onToggle' }); + +Dropdown.Toggle = DropdownToggle; +Dropdown.Menu = DropdownMenu; + +export default Dropdown; diff --git a/src/DropdownButton.js b/src/DropdownButton.js index 9d33e8cd28..ac37fcc729 100644 --- a/src/DropdownButton.js +++ b/src/DropdownButton.js @@ -1,126 +1,62 @@ -/* eslint react/prop-types: [2, {ignore: "bsSize"}] */ -/* BootstrapMixin contains `bsSize` type validation */ - -import React, { cloneElement } from 'react'; -import classNames from 'classnames'; - -import createChainedFunction from './utils/createChainedFunction'; +import React from 'react'; import BootstrapMixin from './BootstrapMixin'; -import DropdownStateMixin from './DropdownStateMixin'; -import Button from './Button'; -import ButtonGroup from './ButtonGroup'; -import DropdownMenu from './DropdownMenu'; -import ValidComponentChildren from './utils/ValidComponentChildren'; +import Dropdown from './Dropdown'; +import NavDropdown from './NavDropdown'; +import CustomPropTypes from './utils/CustomPropTypes'; +import deprecationWarning from './utils/deprecationWarning'; +import omit from 'lodash/object/omit'; -const DropdownButton = React.createClass({ - mixins: [BootstrapMixin, DropdownStateMixin], +class DropdownButton extends React.Component { - propTypes: { - pullRight: React.PropTypes.bool, - dropup: React.PropTypes.bool, - title: React.PropTypes.node, - href: React.PropTypes.string, - id: React.PropTypes.string, - onClick: React.PropTypes.func, - onSelect: React.PropTypes.func, - navItem: React.PropTypes.bool, - noCaret: React.PropTypes.bool, - buttonClassName: React.PropTypes.string, - className: React.PropTypes.string, - children: React.PropTypes.node - }, + constructor(props) { + super(props); + } render() { - let renderMethod = this.props.navItem ? - 'renderNavItem' : 'renderButtonGroup'; - - let caret = this.props.noCaret ? - null : (); - - return this[renderMethod]([ - , - - {ValidComponentChildren.map(this.props.children, this.renderMenuItem)} - - ]); - }, + let { title, navItem, ...props } = this.props; - renderButtonGroup(children) { - let groupClasses = { - 'open': this.state.open, - 'dropup': this.props.dropup - }; + let toggleProps = omit(props, Dropdown.ControlledComponent.propTypes); - return ( - - {children} - - ); - }, - - renderNavItem(children) { - let classes = { - 'dropdown': true, - 'open': this.state.open, - 'dropup': this.props.dropup - }; + if (navItem){ + return ; + } return ( -
  • - {children} -
  • + + + {title} + + + {this.props.children} + + ); - }, - - renderMenuItem(child, index) { - // Only handle the option selection if an onSelect prop has been set on the - // component or it's child, this allows a user not to pass an onSelect - // handler and have the browser preform the default action. - let handleOptionSelect = this.props.onSelect || child.props.onSelect ? - this.handleOptionSelect : null; - - return cloneElement( - child, - { - // Capture onSelect events - onSelect: createChainedFunction(child.props.onSelect, handleOptionSelect), - key: child.key ? child.key : index + } +} + +DropdownButton.propTypes = { + /** + * When used with the `title` prop, the noCaret option will not render a caret icon, in the toggle element. + */ + noCaret: React.PropTypes.bool, + + /** + * Specify whether this Dropdown is part of a Nav component + * + * @type {bool} + * @deprecated Use the `NavDropdown` instead. + */ + navItem: CustomPropTypes.all([ + React.PropTypes.bool, + function(props, propName, componentName) { + if (props.navItem) { + deprecationWarning('navItem', 'NavDropdown component', 'https://github.com/react-bootstrap/react-bootstrap/issues/526'); } - ); - }, - - handleDropdownClick(e) { - e.preventDefault(); - - this.setDropdownState(!this.state.open); - }, - - handleOptionSelect(key) { - if (this.props.onSelect) { - this.props.onSelect(key); } - - this.setDropdownState(false); - } -}); + ]), + title: React.PropTypes.node.isRequired, + ...Dropdown.propTypes, + ...BootstrapMixin.propTypes +}; export default DropdownButton; diff --git a/src/DropdownMenu.js b/src/DropdownMenu.js index 1d3b3daae2..4fc5aa7b75 100644 --- a/src/DropdownMenu.js +++ b/src/DropdownMenu.js @@ -1,43 +1,133 @@ -import React, { cloneElement } from 'react'; +import React from 'react'; +import keycode from 'keycode'; import classNames from 'classnames'; - import createChainedFunction from './utils/createChainedFunction'; -import ValidComponentChildren from './utils/ValidComponentChildren'; +import RootCloseWrapper from './RootCloseWrapper'; + +class DropdownMenu extends React.Component { + constructor(props) { + super(props); + + this.focusNext = this.focusNext.bind(this); + this.focusPrevious = this.focusPrevious.bind(this); + this.getFocusableMenuItems = this.getFocusableMenuItems.bind(this); + this.getItemsAndActiveIndex = this.getItemsAndActiveIndex.bind(this); + + this.handleKeyDown = this.handleKeyDown.bind(this); + } + + handleKeyDown(event) { + + switch(event.keyCode) { + case keycode.codes.down: + this.focusNext(); + event.preventDefault(); + break; + case keycode.codes.up: + this.focusPrevious(); + event.preventDefault(); + break; + case keycode.codes.esc: + case keycode.codes.tab: + this.props.onClose(event); + break; + } + } + + focusNext() { + let { items, activeItemIndex } = this.getItemsAndActiveIndex(); + + if (activeItemIndex === items.length - 1) { + items[0].focus(); + return; + } + + items[activeItemIndex + 1].focus(); + } + + focusPrevious() { + let { items, activeItemIndex } = this.getItemsAndActiveIndex(); + + if (activeItemIndex === 0) { + items[items.length - 1].focus(); + return; + } + + items[activeItemIndex - 1].focus(); + } -const DropdownMenu = React.createClass({ - propTypes: { - pullRight: React.PropTypes.bool, - onSelect: React.PropTypes.func - }, + getItemsAndActiveIndex() { + let items = this.getFocusableMenuItems(); + let activeElement = document.activeElement; + let activeItemIndex = items.indexOf(activeElement); + + return {items, activeItemIndex}; + } + + getFocusableMenuItems() { + let menuNode = React.findDOMNode(this); + + if (menuNode === undefined) { + return []; + } + + return [].slice.call(menuNode.querySelectorAll('[tabIndex="-1"]'), 0); + } render() { - let classes = { - 'dropdown-menu': true, - 'dropdown-menu-right': this.props.pullRight - }; - - return ( -
      - {ValidComponentChildren.map(this.props.children, this.renderMenuItem)} -
    - ); - }, - - renderMenuItem(child, index) { - return cloneElement( - child, - { - // Capture onSelect events - onSelect: createChainedFunction(child.props.onSelect, this.props.onSelect), - - // Force special props to be transferred - key: child.key ? child.key : index - } + const items = React.Children.map(this.props.children, child => { + let { + children, + onKeyDown, + onSelect + } = child.props || {}; + + return React.cloneElement(child, { + onKeyDown: createChainedFunction(onKeyDown, this.handleKeyDown), + onSelect: createChainedFunction(onSelect, this.props.onSelect) + }, children); + }); + + const classes = { + 'dropdown-menu': true, + 'dropdown-menu-right': this.props.pullRight + }; + + let list = ( +
      + {items} +
    ); + + if (this.props.open) { + list = ( + + {list} + + ); + } + + return list; } -}); +} + +DropdownMenu.defaultProps = { + bsRole: 'menu' +}; + +DropdownMenu.propTypes = { + open: React.PropTypes.bool, + pullRight: React.PropTypes.bool, + onClose: React.PropTypes.func, + labelledBy: React.PropTypes.oneOfType([ + React.PropTypes.string, + React.PropTypes.number + ]), + onSelect: React.PropTypes.func +}; export default DropdownMenu; diff --git a/src/DropdownStateMixin.js b/src/DropdownStateMixin.js deleted file mode 100644 index 368c743c93..0000000000 --- a/src/DropdownStateMixin.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import domUtils from './utils/domUtils'; -import EventListener from './utils/EventListener'; - -/** - * Checks whether a node is within - * a root nodes tree - * - * @param {DOMElement} node - * @param {DOMElement} root - * @returns {boolean} - */ -function isNodeInRoot(node, root) { - while (node) { - if (node === root) { - return true; - } - node = node.parentNode; - } - - return false; -} - -const DropdownStateMixin = { - getInitialState() { - return { - open: false - }; - }, - - setDropdownState(newState, onStateChangeComplete) { - if (newState) { - this.bindRootCloseHandlers(); - } else { - this.unbindRootCloseHandlers(); - } - - this.setState({ - open: newState - }, onStateChangeComplete); - }, - - handleDocumentKeyUp(e) { - if (e.keyCode === 27) { - this.setDropdownState(false); - } - }, - - handleDocumentClick(e) { - // If the click originated from within this component - // don't do anything. - // e.srcElement is required for IE8 as e.target is undefined - let target = e.target || e.srcElement; - if (isNodeInRoot(target, React.findDOMNode(this))) { - return; - } - - this.setDropdownState(false); - }, - - bindRootCloseHandlers() { - let doc = domUtils.ownerDocument(this); - - this._onDocumentClickListener = - EventListener.listen(doc, 'click', this.handleDocumentClick); - this._onDocumentKeyupListener = - EventListener.listen(doc, 'keyup', this.handleDocumentKeyUp); - }, - - unbindRootCloseHandlers() { - if (this._onDocumentClickListener) { - this._onDocumentClickListener.remove(); - } - - if (this._onDocumentKeyupListener) { - this._onDocumentKeyupListener.remove(); - } - }, - - componentWillUnmount() { - this.unbindRootCloseHandlers(); - } -}; - -export default DropdownStateMixin; diff --git a/src/DropdownToggle.js b/src/DropdownToggle.js new file mode 100644 index 0000000000..5d3b3145b7 --- /dev/null +++ b/src/DropdownToggle.js @@ -0,0 +1,54 @@ +import React from 'react'; +import classNames from 'classnames'; +import Button from './Button'; +import CustomPropTypes from './utils/CustomPropTypes'; +import SafeAnchor from './SafeAnchor'; + +const CARET = ; + +export default class DropdownToggle extends React.Component { + render() { + const caret = this.props.noCaret ? null : CARET; + + const classes = { + 'dropdown-toggle': true + }; + + const Component = this.props.useAnchor ? SafeAnchor : Button; + + return ( + + {this.props.title || this.props.children}{caret} + + ); + } +} + +const titleAndChildrenValidation = CustomPropTypes.singlePropFrom([ + 'title', + 'children' +]); + +DropdownToggle.defaultProps = { + open: false, + useAnchor: false, + bsRole: 'toggle' +}; + +DropdownToggle.propTypes = { + bsRole: React.PropTypes.string, + children: titleAndChildrenValidation, + noCaret: React.PropTypes.bool, + open: React.PropTypes.bool, + title: titleAndChildrenValidation, + useAnchor: React.PropTypes.bool +}; + +DropdownToggle.isToggle = true; +DropdownToggle.titleProp = 'title'; +DropdownToggle.onClickProp = 'onClick'; diff --git a/src/MenuItem.js b/src/MenuItem.js index 0f8f331724..e7124d21e0 100644 --- a/src/MenuItem.js +++ b/src/MenuItem.js @@ -1,67 +1,81 @@ import React from 'react'; -import classNames from 'classnames'; +import classnames from 'classnames'; +import CustomPropTypes from './utils/CustomPropTypes'; import SafeAnchor from './SafeAnchor'; -const MenuItem = React.createClass({ - propTypes: { - header: React.PropTypes.bool, - divider: React.PropTypes.bool, - href: React.PropTypes.string, - title: React.PropTypes.string, - target: React.PropTypes.string, - onSelect: React.PropTypes.func, - eventKey: React.PropTypes.any, - active: React.PropTypes.bool, - disabled: React.PropTypes.bool - }, +export default class MenuItem extends React.Component { + constructor(props) { + super(props); - getDefaultProps() { - return { - active: false - }; - }, + this.handleClick = this.handleClick.bind(this); + } + + handleClick(event) { + if (!this.props.href || this.props.disabled) { + event.preventDefault(); + } - handleClick(e) { if (this.props.disabled) { - e.preventDefault(); return; } + if (this.props.onSelect) { - e.preventDefault(); - this.props.onSelect(this.props.eventKey, this.props.href, this.props.target); + this.props.onSelect(event, this.props.eventKey); } - }, - - renderAnchor() { - return ( - - {this.props.children} - - ); - }, + } render() { - let classes = { - 'dropdown-header': this.props.header, - 'divider': this.props.divider, - 'active': this.props.active, - 'disabled': this.props.disabled - }; + if (this.props.divider) { + return
  • ; + } - let children = null; if (this.props.header) { - children = this.props.children; - } else if (!this.props.divider) { - children = this.renderAnchor(); + return ( +
  • {this.props.children}
  • + ); } + const classes = { + disabled: this.props.disabled + }; + return ( -
  • - {children} +
  • + + {this.props.children} +
  • ); } -}); +} -export default MenuItem; +MenuItem.propTypes = { + disabled: React.PropTypes.bool, + divider: CustomPropTypes.all([ + React.PropTypes.bool, + function(props, propName, componentName) { + if (props.divider && props.children) { + return new Error('Children will not be rendered for dividers'); + } + } + ]), + eventKey: React.PropTypes.oneOfType([ + React.PropTypes.number, + React.PropTypes.string + ]), + header: React.PropTypes.bool, + href: React.PropTypes.string, + title: React.PropTypes.string, + onKeyDown: React.PropTypes.func, + onSelect: React.PropTypes.func +}; diff --git a/src/NavDropdown.js b/src/NavDropdown.js new file mode 100644 index 0000000000..1cfb731698 --- /dev/null +++ b/src/NavDropdown.js @@ -0,0 +1,32 @@ +import React from 'react'; +import Dropdown from './Dropdown'; + +class NavDropdown extends React.Component { + + render() { + let { children, title, noCaret, bsStyle, ...props } = this.props; + + return ( + + + {title} + + + {children} + + + ); + } +} + +NavDropdown.propTypes = { + title: React.PropTypes.node.isRequired, + ...Dropdown.propTypes +}; + +export default NavDropdown; diff --git a/src/RootCloseWrapper.js b/src/RootCloseWrapper.js index 35fecab769..0455bab25a 100644 --- a/src/RootCloseWrapper.js +++ b/src/RootCloseWrapper.js @@ -1,6 +1,7 @@ import React from 'react'; import domUtils from './utils/domUtils'; import EventListener from './utils/EventListener'; +import createChainedFunction from './utils/createChainedFunction'; // TODO: Merge this logic with dropdown logic once #526 is done. @@ -61,11 +62,20 @@ export default class RootCloseWrapper extends React.Component { } render() { + let noWrap = this.props.noWrap; + let child = React.Children.only(this.props.children); + + if (noWrap) { + return React.cloneElement(child, { + onClick: createChainedFunction(suppressRootClose, child.props.onClick) + }); + } + // Wrap the child in a new element, so the child won't have to handle // potentially combining multiple onClick listeners. return (
    - {React.Children.only(this.props.children)} + {child}
    ); } @@ -75,13 +85,23 @@ export default class RootCloseWrapper extends React.Component { // stealing the ref from the owner, but we know exactly the DOM structure // that will be rendered, so we can just do this to get the child's DOM // node for doing size calculations in OverlayMixin. - return React.findDOMNode(this).children[0]; + return this.props.noWrap + ? React.findDOMNode(this) + : React.findDOMNode(this).children[0]; } componentWillUnmount() { this.unbindRootCloseHandlers(); } } + RootCloseWrapper.propTypes = { - onRootClose: React.PropTypes.func.isRequired + onRootClose: React.PropTypes.func.isRequired, + + /** + * Passes the suppress click handler directly to the child component instead of + * placing it on a wrapping div. Only use when you can be sure the child will properly handle the + * click event. + */ + noWrap: React.PropTypes.bool }; diff --git a/src/SplitButton.js b/src/SplitButton.js index b350817e0f..f99a27649e 100644 --- a/src/SplitButton.js +++ b/src/SplitButton.js @@ -1,112 +1,64 @@ -/* eslint react/prop-types: [2, {ignore: "bsSize"}] */ -/* BootstrapMixin contains `bsSize` type validation */ - import React from 'react'; -import classNames from 'classnames'; import BootstrapMixin from './BootstrapMixin'; -import DropdownStateMixin from './DropdownStateMixin'; import Button from './Button'; -import ButtonGroup from './ButtonGroup'; -import DropdownMenu from './DropdownMenu'; - -const SplitButton = React.createClass({ - mixins: [BootstrapMixin, DropdownStateMixin], +import Dropdown from './Dropdown'; +import SplitToggle from './SplitToggle'; - propTypes: { - pullRight: React.PropTypes.bool, - title: React.PropTypes.node, - href: React.PropTypes.string, - id: React.PropTypes.string, - target: React.PropTypes.string, - dropdownTitle: React.PropTypes.node, - dropup: React.PropTypes.bool, - onClick: React.PropTypes.func, - onSelect: React.PropTypes.func, - disabled: React.PropTypes.bool, - className: React.PropTypes.string, - children: React.PropTypes.node - }, - - getDefaultProps() { - return { - dropdownTitle: 'Toggle dropdown' - }; - }, +class SplitButton extends React.Component { render() { - let groupClasses = { - 'open': this.state.open, - 'dropup': this.props.dropup - }; + let { + children, title, onClick, target, href, bsStyle, ...props } = this.props; - let button = ( - - ); + let { disabled } = props; - let dropdownButton = ( + let button = ( ); return ( - + {button} - {dropdownButton} - - {this.props.children} - - - ); - }, - - handleButtonClick(e) { - if (this.state.open) { - this.setDropdownState(false); - } - if (this.props.onClick) { - this.props.onClick(e, this.props.href, this.props.target); - } - }, + + + {children} + + + ); + } +} - handleDropdownClick(e) { - e.preventDefault(); - this.setDropdownState(!this.state.open); - }, +SplitButton.propTypes = { + //dropup: React.PropTypes.bool, + ...Dropdown.propTypes, + ...BootstrapMixin.propTypes, - handleOptionSelect(key) { - if (this.props.onSelect) { - this.props.onSelect(key); - } + /** + * @private + */ + onClick(){}, + target: React.PropTypes.string, + href: React.PropTypes.string, + /** + * The content of the split button. + */ + title: React.PropTypes.node.isRequired +}; - this.setDropdownState(false); - } -}); +SplitButton.Toggle = SplitToggle; export default SplitButton; diff --git a/src/SplitToggle.js b/src/SplitToggle.js new file mode 100644 index 0000000000..1b3a39afc4 --- /dev/null +++ b/src/SplitToggle.js @@ -0,0 +1,16 @@ +import React from 'react'; +import DropdownToggle from './DropdownToggle'; + +export default class SplitToggle extends React.Component { + render() { + return ( + + ); + } +} + +SplitToggle.defaultProps = DropdownToggle.defaultProps; diff --git a/src/index.js b/src/index.js index 85b611af11..211f54218d 100644 --- a/src/index.js +++ b/src/index.js @@ -15,9 +15,12 @@ export CarouselItem from './CarouselItem'; export Col from './Col'; export CollapsibleMixin from './CollapsibleMixin'; export CollapsibleNav from './CollapsibleNav'; + +export Dropdown from './Dropdown'; export DropdownButton from './DropdownButton'; -export DropdownMenu from './DropdownMenu'; -export DropdownStateMixin from './DropdownStateMixin'; +export NavDropdown from './NavDropdown'; +export SplitButton from './SplitButton'; + export FadeMixin from './FadeMixin'; export Glyphicon from './Glyphicon'; export Grid from './Grid'; diff --git a/src/utils/CustomPropTypes.js b/src/utils/CustomPropTypes.js index cb57a653cb..c7120dea54 100644 --- a/src/utils/CustomPropTypes.js +++ b/src/utils/CustomPropTypes.js @@ -1,7 +1,34 @@ import React from 'react'; +import childrenToArray from './childrenToArray'; const ANONYMOUS = '<>'; +/** + * Create chain-able isRequired validator + * + * Largely copied directly from: + * https://github.com/facebook/react/blob/0.11-stable/src/core/ReactPropTypes.js#L94 + */ +function createChainableTypeChecker(validate) { + function checkType(isRequired, props, propName, componentName) { + componentName = componentName || ANONYMOUS; + if (props[propName] == null) { + if (isRequired) { + return new Error( + `Required prop '${propName}' was not specified in '${componentName}'.` + ); + } + } else { + return validate(props, propName, componentName); + } + } + + let chainedCheckType = checkType.bind(null, false); + chainedCheckType.isRequired = checkType.bind(null, true); + + return chainedCheckType; +} + const CustomPropTypes = { isRequiredForA11y(propType){ @@ -17,6 +44,54 @@ const CustomPropTypes = { }; }, + requiredRoles(...roles) { + return createChainableTypeChecker( + function requiredRolesValidator(props, propName, component) { + let missing; + let children = childrenToArray(props.children); + + let inRole = (role, child) => role === child.props.bsRole; + + roles.every(role => { + if (!children.some(child => inRole(role, child))){ + missing = role; + return false; + } + return true; + }); + + if (missing) { + return new Error(`(children) ${component} - Missing a required child with bsRole: ${missing}. ` + + `${component} must have at least one child of each of the following bsRoles: ${roles.join(', ')}`); + } + }); + }, + + exclusiveRoles(...roles) { + return createChainableTypeChecker( + function exclusiveRolesValidator(props, propName, component) { + let children = childrenToArray(props.children); + let duplicate; + + roles.every(role => { + let childrenWithRole = children.filter(child => child.props.bsRole === role); + + if (childrenWithRole.length > 1){ + duplicate = role; + return false; + } + return true; + }); + + if (duplicate) { + return new Error( + `(children) ${component} - Duplicate children detected of bsRole: ${duplicate}. ` + + `Only one child each allowed with the following bsRoles: ${roles.join(', ')}`); + } + + }); + }, + /** * Checks whether a prop provides a DOM element * @@ -73,32 +148,6 @@ function errMsg(props, propName, componentName, msgContinuation) { ` supplied to '${componentName}'${msgContinuation}`; } -/** - * Create chain-able isRequired validator - * - * Largely copied directly from: - * https://github.com/facebook/react/blob/0.11-stable/src/core/ReactPropTypes.js#L94 - */ -function createChainableTypeChecker(validate) { - function checkType(isRequired, props, propName, componentName) { - componentName = componentName || ANONYMOUS; - if (props[propName] == null) { - if (isRequired) { - return new Error( - `Required prop '${propName}' was not specified in '${componentName}'.` - ); - } - } else { - return validate(props, propName, componentName); - } - } - - let chainedCheckType = checkType.bind(null, false); - chainedCheckType.isRequired = checkType.bind(null, true); - - return chainedCheckType; -} - function createMountableChecker() { function validate(props, propName, componentName) { if (typeof props[propName] !== 'object' || diff --git a/src/utils/childrenToArray.js b/src/utils/childrenToArray.js new file mode 100644 index 0000000000..f1127886a1 --- /dev/null +++ b/src/utils/childrenToArray.js @@ -0,0 +1,15 @@ +import validChildren from './ValidComponentChildren'; + +export default function childrenAsArray(children) { + let result = []; + + if (children === undefined) { + return result; + } + + validChildren.forEach(children, child => { + result.push(child); + }); + + return result; +} diff --git a/test/DropdownButtonSpec.js b/test/DropdownButtonSpec.js index 56ac8bd078..00a38d3c7f 100644 --- a/test/DropdownButtonSpec.js +++ b/test/DropdownButtonSpec.js @@ -1,234 +1,181 @@ import React from 'react'; import ReactTestUtils from 'react/lib/ReactTestUtils'; import DropdownButton from '../src/DropdownButton'; -import MenuItem from '../src/MenuItem'; import DropdownMenu from '../src/DropdownMenu'; -import Button from '../src/Button'; +import MenuItem from '../src/MenuItem'; +import { shouldWarn } from './helpers'; -describe('DropdownButton', function () { - let instance; - afterEach(function() { - if (instance && ReactTestUtils.isCompositeComponent(instance) && instance.isMounted()) { - React.unmountComponentAtNode(React.findDOMNode(instance)); - } - }); +describe('DropdownButton', function() { - it('Should render button correctly', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); + const simpleDropdown = ( + + Item 1 + Item 2 + Item 3 + Item 4 + + ); + + it('renders title prop', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleDropdown); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); - let button = React.findDOMNode(ReactTestUtils.findRenderedComponentWithType(instance, Button)); - assert.ok(React.findDOMNode(instance).className.match(/\bbtn-group\b/)); - assert.ok(React.findDOMNode(instance).className.match(/\btest-class\b/)); - assert.ok(button.className.match(/\bbtn\b/)); - assert.equal(button.nodeName, 'BUTTON'); - assert.equal(button.type, 'button'); - assert.ok(button.className.match(/\bdropdown-toggle\b/)); - assert.ok(button.lastChild.className.match(/\bcaret\b/)); - assert.equal(button.innerText.trim(), 'Title'); + buttonNode.innerText.should.match(/Simple Dropdown/); }); - it('Should render menu correctly', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); + it('renders dropdown toggle button', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleDropdown); - let menu = ReactTestUtils.findRenderedComponentWithType(instance, DropdownMenu); - let allMenuItems = ReactTestUtils.scryRenderedComponentsWithType(menu, MenuItem); - assert.equal(allMenuItems.length, 2); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + buttonNode.tagName.should.equal('BUTTON'); + buttonNode.className.should.match(/\bbtn[ $]/); + buttonNode.className.should.match(/\bbtn-default\b/); + buttonNode.className.should.match(/\bdropdown-toggle\b/); + buttonNode.getAttribute('type').should.equal('button'); + buttonNode.getAttribute('aria-expanded').should.equal('false'); + buttonNode.getAttribute('id').should.be.ok; }); - it('Should pass props to button', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + it('renders single MenuItem child', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 ); - let button = React.findDOMNode(ReactTestUtils.findRenderedComponentWithType(instance, Button)); - assert.ok(button.className.match(/\bbtn-primary\b/)); - assert.equal(button.getAttribute('id'), 'testId'); - assert.ok(button.disabled); - }); - - it('Should be closed by default', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - ); + const menuNode = React.findDOMNode( + ReactTestUtils.findRenderedComponentWithType(instance, DropdownMenu)); - assert.notOk(React.findDOMNode(instance).className.match(/\bopen\b/)); + expect(menuNode.children.length).to.equal(1); }); - it('Should open when clicked', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + + it('forwards pullRight to menu', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 ); + const menu = ReactTestUtils.findRenderedComponentWithType(instance, DropdownMenu); - ReactTestUtils.SimulateNative.click(React.findDOMNode(instance.refs.dropdownButton)); - assert.ok(React.findDOMNode(instance).className.match(/\bopen\b/)); + menu.props.pullRight.should.be.true; }); - it('should call onSelect with eventKey when MenuItem is clicked', function (done) { - function handleSelect(eventKey) { - assert.equal(eventKey, '2'); - assert.equal(instance.state.open, false); - done(); - } - - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + it('renders bsSize', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 ); + const node = React.findDOMNode(instance); - let menuItems = ReactTestUtils.scryRenderedComponentsWithType(instance, MenuItem); - assert.equal(menuItems.length, 2); - ReactTestUtils.SimulateNative.click( - ReactTestUtils.findRenderedDOMComponentWithTag(menuItems[1], 'a') - ); + node.className.should.match(/\bbtn-group-sm\b/); }); - it('should call MenuItem onSelect with eventKey when MenuItem is clicked', function (done) { - function handleSelect(eventKey) { - assert.equal(eventKey, '2'); - assert.equal(instance.state.open, false); - done(); - } - - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + it('renders bsStyle', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 ); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); - let menuItems = ReactTestUtils.scryRenderedComponentsWithType(instance, MenuItem); - assert.equal(menuItems.length, 2); - ReactTestUtils.SimulateNative.click( - ReactTestUtils.findRenderedDOMComponentWithTag(menuItems[1], 'a') - ); + buttonNode.className.should.match(/\bbtn-success\b/); }); - it('should not set onSelect to child with no onSelect prop', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); - let menuItems = ReactTestUtils.scryRenderedComponentsWithType(instance, MenuItem); - assert.notOk(menuItems[0].props.onSelect); - }); + it('forwards onSelect handler to MenuItems', function(done) { + const selectedEvents = []; - describe('when open', function () { - beforeEach(function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); + const onSelect = (event, eventKey) => { + selectedEvents.push(eventKey); - instance.setDropdownState(true); - }); + if (selectedEvents.length === 4) { + selectedEvents.should.eql(['1', '2', '3', '4']); + done(); + } + }; + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 + Item 2 + Item 3 + Item 4 + + ); - it('should close on click', function () { - let evt = document.createEvent('HTMLEvents'); - evt.initEvent('click', true, true); - document.documentElement.dispatchEvent(evt); + const menuItems = ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A'); - assert.notOk(React.findDOMNode(instance).className.match(/\bopen\b/)); + menuItems.forEach(item => { + ReactTestUtils.Simulate.click(item); }); }); - it('Should render li when in nav', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + it('closes when child MenuItem is selected', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 ); + const node = React.findDOMNode(instance); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + const menuItem = React.findDOMNode( + ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A')); - let li = React.findDOMNode(instance); - let button = React.findDOMNode(ReactTestUtils.findRenderedComponentWithType(instance, Button)); - assert.equal(li.nodeName, 'LI'); - assert.ok(li.className.match(/\bdropdown\b/)); - assert.ok(li.className.match(/\btest-class\b/)); - assert.equal(button.nodeName, 'A'); - assert.ok(button.className.match(/\bdropdown-toggle\b/)); - assert.ok(button.lastChild.className.match(/\bcaret\b/)); - assert.equal(button.innerText.trim(), 'Title'); + ReactTestUtils.Simulate.click(buttonNode); + node.className.should.match(/\bopen\b/); + ReactTestUtils.Simulate.click(menuItem); + node.className.should.not.match(/\bopen\b/); }); - it('should render a caret by default', function() { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - + it('does not close when onToggle is controlled', function() { + const handleSelect = () => {}; + + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 + ); - let button = React.findDOMNode(ReactTestUtils.findRenderedComponentWithType(instance, Button)); - let carets = button.getElementsByClassName('caret'); - assert.equal(carets.length, 1); - }); + const node = React.findDOMNode(instance); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); - it('should not render a caret if noCaret prop is given', function() { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); + const menuItem = React.findDOMNode( + ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A')); - let button = React.findDOMNode(ReactTestUtils.findRenderedComponentWithType(instance, Button)); - let carets = button.getElementsByClassName('caret'); - assert.equal(carets.length, 0); + ReactTestUtils.Simulate.click(buttonNode); + node.className.should.match(/\bopen\b/); + ReactTestUtils.Simulate.click(menuItem); + + node.className.should.match(/\bopen\b/); }); - it('should set button class when buttonClassName is given', function() { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); + it('warn about the navItem deprecation', function() { + const props = { + title: 'some title', + navItem: true + }; - let button = React.findDOMNode(ReactTestUtils.findRenderedComponentWithType(instance, Button)); - assert.ok(button.className.match(/\btest-class\b/)); + DropdownButton.propTypes.navItem(props, 'navItem', 'DropdownButton'); + shouldWarn(/navItem.*NavDropdown component/); }); - it('should set onClick on Button', function (done) { - function handleClick() { - done(); - } - - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + it('Should pass props to button', function () { + const instance = ReactTestUtils.renderIntoDocument( + + MenuItem 1 content + MenuItem 2 content ); - let button = ReactTestUtils.findRenderedComponentWithType(instance, Button); - ReactTestUtils.SimulateNative.click( - ReactTestUtils.findRenderedDOMComponentWithClass(button, 'dropdown-toggle') - ); + const buttonNode = React.findDOMNode( + ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + assert.ok(buttonNode.className.match(/\bbtn-primary\b/)); + assert.equal(buttonNode.getAttribute('id'), 'testId'); + assert.ok(buttonNode.disabled); }); }); diff --git a/test/DropdownMenuSpec.js b/test/DropdownMenuSpec.js index 0dad0af4f0..b3e7e56d7f 100644 --- a/test/DropdownMenuSpec.js +++ b/test/DropdownMenuSpec.js @@ -2,106 +2,189 @@ import React from 'react'; import ReactTestUtils from 'react/lib/ReactTestUtils'; import DropdownMenu from '../src/DropdownMenu'; import MenuItem from '../src/MenuItem'; +import keycode from 'keycode'; + +describe('DropdownMenu', function() { + const simpleMenu = ( + + Item 1 + Item 2 + Item 3 + Item 4 + + ); + + it('renders ul with dropdown-menu class', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleMenu); + const node = React.findDOMNode(instance); + + node.tagName.should.equal('UL'); + node.className.should.match(/\bdropdown-menu\b/); + }); -describe('DropdownMenu', function () { - it('Should render menu correctly', function () { - let Parent = React.createClass({ - render(){ - return ( - - MenuItem 1 content - MenuItem 2 content - - ); - } - }); - - let instance = ReactTestUtils.renderIntoDocument(); + it('has role="menu"', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleMenu); + const node = React.findDOMNode(instance); - let node = React.findDOMNode(instance); + node.getAttribute('role').should.equal('menu'); + }); - assert.ok(node.className.match(/\bdropdown-menu\b/)); - assert.equal(node.nodeName, 'UL'); - assert.equal(node.getAttribute('role'), 'menu'); + it('has aria-labelledby=', function() { + const instance1 = ReactTestUtils.renderIntoDocument(); + const instance2 = ReactTestUtils.renderIntoDocument(); + const node1 = React.findDOMNode(instance1); + const node2 = React.findDOMNode(instance2); - let allMenuItems = ReactTestUtils.scryRenderedComponentsWithType(instance, MenuItem); - assert.equal(allMenuItems.length, 2); - assert.equal(allMenuItems[0], instance.refs.item1); - assert.equal(allMenuItems[1], instance.refs.item2); + node1.getAttribute('aria-labelledby').should.equal('herpa'); + node2.getAttribute('aria-labelledby').should.equal('derpa'); }); - it('Should pass props to dropdown', function () { - let instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content + it('forwards onSelect handler to MenuItems', function(done) { + const selectedEvents = []; + const onSelect = (event, eventKey) => { + selectedEvents.push(eventKey); + + if (selectedEvents.length === 4) { + selectedEvents.should.eql(['1', '2', '3', '4']); + done(); + } + }; + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 + Item 2 + Item 3 + Item 4 ); - let node = React.findDOMNode(instance); - assert.ok(node.className.match(/\bnew-fancy-class\b/)); - }); + const menuItems = ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A'); - it('should call onSelect with eventKey when MenuItem is clicked', function (done) { - function handleSelect(eventKey) { - assert.equal(eventKey, '2'); - done(); - } + menuItems.forEach(item => { + ReactTestUtils.Simulate.click(item); + }); + }); - let instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + it('applies pull right', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Item ); + const node = React.findDOMNode(instance); - let menuItems = ReactTestUtils.scryRenderedComponentsWithType(instance, MenuItem); - assert.equal(menuItems.length, 2); - ReactTestUtils.SimulateNative.click( - ReactTestUtils.findRenderedDOMComponentWithTag(menuItems[1], 'a') - ); + node.className.should.match(/\bdropdown-menu-right\b/); }); - it('should call all onSelect handlers when MenuItem is clicked', function (done) { - let i = 0; - function handleSelect(eventKey) { - assert.equal(eventKey, '2'); - i += 1; - if ( i >= 2 ) { - done(); - } - } + describe('focusable state', function() { + let focusableContainer; - let instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); + beforeEach(function() { + focusableContainer = document.createElement('div'); + document.body.appendChild(focusableContainer); + }); - let menuItems = ReactTestUtils.scryRenderedComponentsWithType(instance, MenuItem); - assert.equal(menuItems.length, 2); - ReactTestUtils.SimulateNative.click( - ReactTestUtils.findRenderedDOMComponentWithTag(menuItems[1], 'a') - ); - }); + afterEach(function() { + React.unmountComponentAtNode(focusableContainer); + document.body.removeChild(focusableContainer); + }); - it('should call not preventDefault with no onSelect handlers when MenuItem is clicked', function (done) { - window.__someGlobalTestCallback = function() { - delete window.__someGlobalTestCallback; - done(); - }; + it('clicking anything outside the menu will request close', function() { + const requestClose = sinon.stub(); + const instance = React.render( +
    + + + Item + +
    , focusableContainer); + + const button = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON').getDOMNode(); + + const evt = document.createEvent('MouseEvent'); + evt.initMouseEvent('click', true, true); + button.dispatchEvent(evt); + + requestClose.should.have.been.calledOnce; + requestClose.getCall(0).args.length.should.equal(0); + }); + + describe('Keyboard Navigation', function() { + it('sets focus on next menu item when the key "down" is pressed', function() { + const instance = React.render(simpleMenu, focusableContainer); + + const items = ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A'); + items.length.should.equal(4); + items[0].getDOMNode().focus(); + + for (let i = 1; i < items.length; i++) { + ReactTestUtils.Simulate.keyDown(document.activeElement, { keyCode: keycode('down') }); + document.activeElement.should.equal(items[i].getDOMNode()); + } + }); + + it('with last item is focused when the key "down" is pressed first item gains focus', function() { + const instance = React.render(simpleMenu, focusableContainer); + + const items = ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A'); + items.length.should.equal(4); + items[3].getDOMNode().focus(); + + ReactTestUtils.Simulate.keyDown(document.activeElement, { keyCode: keycode('down') }); + document.activeElement.should.equal(items[0].getDOMNode()); + }); + + it('sets focus on previous menu item when the key "up" is pressed', function() { + const instance = React.render(simpleMenu, focusableContainer); + + const items = ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A'); + items.length.should.equal(4); + items[3].getDOMNode().focus(); + for (let i = 2; i >= 0; i--) { + ReactTestUtils.Simulate.keyDown(document.activeElement, { keyCode: keycode('up') }); + document.activeElement.should.equal(items[i].getDOMNode()); + } + }); + + it('with first item focused when the key "up" is pressed last item gains focus', function() { + const instance = React.render(simpleMenu, focusableContainer); + + const items = ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A'); + items.length.should.equal(4); + items[0].getDOMNode().focus(); + + ReactTestUtils.Simulate.keyDown(document.activeElement, { keyCode: keycode('up') }); + document.activeElement.should.equal(items[3].getDOMNode()); + }); + + ['esc', 'tab'].forEach(key => { + it(`when the key "${key}" is pressed the requestClose prop is invoked with the originating event`, function() { + const requestClose = sinon.spy(); + const instance = React.render( + + Item + , focusableContainer); + + const item = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A').getDOMNode(); + + ReactTestUtils.Simulate.keyDown(item, { keyCode: keycode(key) }); + + requestClose.should.have.been.calledOnce; + requestClose.getCall(0).args[0].keyCode.should.equal(keycode(key)); + }); + }); + }); + }); + + it('Should pass props to dropdown', function () { let instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + + MenuItem 1 content ); - let menuItems = ReactTestUtils.scryRenderedComponentsWithType(instance, MenuItem); - let evt = document.createEvent('HTMLEvents'); - evt.initEvent('click', true, true); - React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(menuItems[1], 'a')) - .dispatchEvent(evt); + let node = React.findDOMNode(instance); + assert.ok(node.className.match(/\bnew-fancy-class\b/)); }); }); diff --git a/test/DropdownSpec.js b/test/DropdownSpec.js new file mode 100644 index 0000000000..24003ca6f5 --- /dev/null +++ b/test/DropdownSpec.js @@ -0,0 +1,447 @@ +import React from 'react'; +import ReactTestUtils from 'react/lib/ReactTestUtils'; +import Dropdown from '../src/Dropdown'; +import DropdownMenu from '../src/DropdownMenu'; +import MenuItem from '../src/MenuItem'; +import { shouldWarn } from './helpers'; +import keycode from 'keycode'; + +class CustomMenu extends React.Component { + render() { + return ( +
    + {this.props.children} +
    + ); + } +} + +describe('Dropdown', function() { + let BaseDropdown = Dropdown.ControlledComponent; + + const dropdownChildren = [ + + Child Title + , + + Item 1 + Item 2 + Item 3 + Item 4 + + ]; + + const simpleDropdown = ( + + {dropdownChildren} + + ); + + it('renders div with dropdown class', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleDropdown); + const node = React.findDOMNode(instance); + + node.tagName.should.equal('DIV'); + node.className.should.match(/\bdropdown\b/); + node.className.should.not.match(/\bdropup\b/); + }); + + it('renders div with dropup class', function() { + const instance = ReactTestUtils.renderIntoDocument( + + {dropdownChildren} + + ); + const node = React.findDOMNode(instance); + + node.tagName.should.equal('DIV'); + node.className.should.not.match(/\bdropdown\b/); + node.className.should.match(/\bdropup\b/); + }); + + it('renders toggle with Dropdown.Toggle', function() { + const instance = ReactTestUtils.renderIntoDocument( + simpleDropdown + ); + + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + buttonNode.innerText.should.match(/Child Title/); + + buttonNode.tagName.should.equal('BUTTON'); + buttonNode.className.should.match(/\bbtn[ $]/); + buttonNode.className.should.match(/\bbtn-default\b/); + buttonNode.className.should.match(/\bdropdown-toggle\b/); + buttonNode.getAttribute('type').should.equal('button'); + buttonNode.getAttribute('aria-expanded').should.equal('false'); + buttonNode.getAttribute('id').should.be.ok; + }); + + + it('renders dropdown toggle button caret', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleDropdown); + const caretNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'caret')); + + caretNode.tagName.should.equal('SPAN'); + }); + + it('does not render toggle button caret', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Child Text + + ); + const caretNode = ReactTestUtils.scryRenderedDOMComponentsWithClass(instance, 'caret'); + + caretNode.length.should.equal(0); + }); + + it('renders custom menu', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Child Text + + + Item 1 + + + ); + + ReactTestUtils.scryRenderedComponentsWithType(instance, DropdownMenu).length.should.equal(0); + ReactTestUtils.scryRenderedComponentsWithType(instance, CustomMenu).length.should.equal(1); + }); + + it('prop validation with multiple menus', function() { + const props = { + title: 'herpa derpa', + children: [( + Child Text + ), ( + + Item 1 + + ), ( + + Item 1 + + )] + }; + + let err = BaseDropdown.propTypes.children(props, 'children', 'DropdownButton'); + err.message.should.match(/Duplicate children.*bsRole: menu/); + }); + + it('only renders one menu', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Child Text + + + Item 1 + + + Item 1 + + + ); + + ReactTestUtils.scryRenderedComponentsWithType(instance, DropdownMenu).length.should.equal(0); + ReactTestUtils.scryRenderedComponentsWithType(instance, CustomMenu).length.should.equal(1); + + shouldWarn(/Duplicate children.*bsRole: menu/); + }); + + + it('forwards pullRight to menu', function() { + const instance = ReactTestUtils.renderIntoDocument( + + {dropdownChildren} + + ); + const menu = ReactTestUtils.findRenderedComponentWithType(instance, DropdownMenu); + + menu.props.pullRight.should.be.true; + }); + + + // NOTE: The onClick event handler is invoked for both the Enter and Space + // keys as well since the component is a button. I cannot figure out how to + // get ReactTestUtils to simulate such though. + it('toggles open/closed when clicked', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleDropdown); + const node = React.findDOMNode(instance); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + node.className.should.not.match(/\bopen\b/); + buttonNode.getAttribute('aria-expanded').should.equal('false'); + + ReactTestUtils.Simulate.click(buttonNode); + + node.className.should.match(/\bopen\b/); + buttonNode.getAttribute('aria-expanded').should.equal('true'); + + ReactTestUtils.Simulate.click(buttonNode); + + node.className.should.not.match(/\bopen\b/); + buttonNode.getAttribute('aria-expanded').should.equal('false'); + }); + + it('when focused and closed toggles open when the key "down" is pressed', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleDropdown); + const node = React.findDOMNode(instance); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + ReactTestUtils.Simulate.keyDown(buttonNode, { keyCode: keycode('down') }); + + node.className.should.match(/\bopen\b/); + buttonNode.getAttribute('aria-expanded').should.equal('true'); + }); + + it('button has aria-haspopup attribute (As per W3C WAI-ARIA Spec)', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleDropdown); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + buttonNode.getAttribute('aria-haspopup').should.equal('true'); + }); + + + it('closes when child MenuItem is selected', function() { + const instance = ReactTestUtils.renderIntoDocument( + simpleDropdown + ); + + const node = React.findDOMNode(instance); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + const menuItem = React.findDOMNode( + ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A')[0]); + + ReactTestUtils.Simulate.click(buttonNode); + node.className.should.match(/\bopen\b/); + ReactTestUtils.Simulate.click(menuItem); + node.className.should.not.match(/\bopen\b/); + }); + + it('does not close when onToggle is controlled', function() { + const handleSelect = () => {}; + + const instance = ReactTestUtils.renderIntoDocument( + + {dropdownChildren} + + ); + + const node = React.findDOMNode(instance); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + const menuItem = React.findDOMNode( + ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A')[0]); + + ReactTestUtils.Simulate.click(buttonNode); + node.className.should.match(/\bopen\b/); + ReactTestUtils.Simulate.click(menuItem); + + node.className.should.match(/\bopen\b/); + }); + + it('is open with explicit prop', function() { + class OpenProp extends React.Component { + constructor(props) { + super(props); + + this.state = { + open: false + }; + } + + render () { + return ( +
    + + {}} + title='Prop open control' + id='test-id' + > + {dropdownChildren} + +
    + ); + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + const outerToggle = ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'outer-button'); + const dropdownNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'dropdown')); + + dropdownNode.className.should.not.match(/\bopen\b/); + ReactTestUtils.Simulate.click(outerToggle); + dropdownNode.className.should.match(/\bopen\b/); + ReactTestUtils.Simulate.click(outerToggle); + dropdownNode.className.should.not.match(/\bopen\b/); + }); + + it('has aria-labelledby same id as toggle button', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleDropdown); + const node = React.findDOMNode(instance); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + const menuNode = node.children[1]; + + buttonNode.getAttribute('id').should.equal(menuNode.getAttribute('aria-labelledby')); + }); + + describe('PropType validation', function() { + + describe('children', function() { + + it('menu is exclusive', function() { + + const props = { + children: [ + , + , + + ] + }; + BaseDropdown.propTypes.children(props, 'children', 'Dropdown') + .message.should.match(/Duplicate children.*bsRole: menu/); + }); + + it('menu is required', function() { + + const props = { + children: [ + + ] + }; + + BaseDropdown.propTypes.children(props, 'children', 'Dropdown') + .message.should.match(/Missing a required child.*bsRole: menu/); + }); + + it('toggles are not exclusive', function() { + + const props = { + children: [ + , + , + + ] + }; + + expect(BaseDropdown.propTypes.children(props, 'children', 'Dropdown')) + .to.not.exist; + }); + + it('toggle is required', function() { + + const props = { + children: [ + + ] + }; + + BaseDropdown.propTypes.children(props, 'children', 'Dropdown') + .message.should.match(/Missing a required child.*bsRole: toggle/); + }); + + }); + + }); + + + describe('focusable state', function() { + let focusableContainer; + + beforeEach(function() { + focusableContainer = document.createElement('div'); + document.body.appendChild(focusableContainer); + }); + + afterEach(function() { + React.unmountComponentAtNode(focusableContainer); + document.body.removeChild(focusableContainer); + }); + + it('when focused and closed sets focus on first menu item when the key "down" is pressed', function() { + const instance = React.render(simpleDropdown, focusableContainer); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + buttonNode.focus(); + + ReactTestUtils.Simulate.keyDown(buttonNode, { keyCode: keycode('down') }); + + const firstMenuItemAnchor = React.findDOMNode( + ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A')[0]); + + document.activeElement.should.equal(firstMenuItemAnchor); + }); + + + it('when focused and open does not toggle closed when the key "down" is pressed', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleDropdown); + const node = React.findDOMNode(instance); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + ReactTestUtils.Simulate.click(buttonNode); + ReactTestUtils.Simulate.keyDown(buttonNode, { keyCode: keycode('down') }); + + node.className.should.match(/\bopen\b/); + buttonNode.getAttribute('aria-expanded').should.equal('true'); + }); + + // This test is more complicated then it appears to need. This is + // because there was an intermittent failure of the test when not structured this way + // The failure occured when all tests in the suite were run together, but not a subset of the tests. + // + // I am fairly confident that the failure is due to a test specific conflict and not an actual bug. + it('when open and the key "esc" is pressed the menu is closed and focus is returned to the button', function() { + const instance = React.render( + + {dropdownChildren} + + , focusableContainer); + + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + const firstMenuItemAnchor = React.findDOMNode(ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A')[0]); + + document.activeElement.should.equal(firstMenuItemAnchor); + + ReactTestUtils.Simulate.keyDown(firstMenuItemAnchor, { type: 'keydown', keyCode: keycode('esc') }); + + document.activeElement.should.equal(buttonNode); + }); + + it('when open and the key "tab" is pressed the menu is closed and focus is progress to the next focusable element', done => { + const instance = React.render( +
    + {simpleDropdown} + +
    , focusableContainer); + + const node = ReactTestUtils.findRenderedComponentWithType(instance, Dropdown); + + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(node, 'BUTTON')); + + ReactTestUtils.Simulate.click(buttonNode); + buttonNode.getAttribute('aria-expanded').should.equal('true'); + + ReactTestUtils.Simulate.keyDown(buttonNode, { key: keycode('tab'), keyCode: keycode('tab') }); + + setTimeout(() => { + buttonNode.getAttribute('aria-expanded').should.equal('false'); + done(); + }); + + + // simulating a tab event doesn't actually shift focus. + // at least that seems to be the case according to SO. + // hence no assert on the input having focus. + }); + }); + +}); diff --git a/test/DropdownToggleSpec.js b/test/DropdownToggleSpec.js new file mode 100644 index 0000000000..ed80410d63 --- /dev/null +++ b/test/DropdownToggleSpec.js @@ -0,0 +1,94 @@ +import React from 'react'; +import ReactTestUtils from 'react/lib/ReactTestUtils'; +import DropdownToggle from '../src/DropdownToggle'; + +describe('DropdownToggle', function() { + const simpleToggle = ; + + it('renders toggle button', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleToggle); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + buttonNode.className.should.match(/\bbtn[ $]/); + buttonNode.className.should.match(/\bbtn-default\b/); + buttonNode.className.should.match(/\bdropdown-toggle\b/); + buttonNode.getAttribute('aria-expanded').should.equal('false'); + }); + + it('renders title prop', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleToggle); + const buttonNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + buttonNode.innerText.should.match(/herpa derpa/); + }); + + it('renders title children', function() { + const instance = ReactTestUtils.renderIntoDocument( + +

    herpa derpa

    +
    + ); + const button = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON'); + const h3Node = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(button, 'H3')); + + h3Node.innerText.should.match(/herpa derpa/); + }); + + it('renders dropdown toggle button caret', function() { + const instance = ReactTestUtils.renderIntoDocument(simpleToggle); + const caretNode = ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'caret').getDOMNode(); + + caretNode.tagName.should.equal('SPAN'); + }); + + it('does not render toggle button caret', function() { + const instance = ReactTestUtils.renderIntoDocument( + + ); + const caretNode = ReactTestUtils.scryRenderedDOMComponentsWithClass(instance, 'caret'); + + caretNode.length.should.equal(0); + }); + + it('forwards onClick handler', function(done) { + const handleClick = (event) => { + event.should.be.ok; + done(); + }; + const instance = ReactTestUtils.renderIntoDocument( + + ); + const button = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON'); + + ReactTestUtils.Simulate.click(button); + }); + + it('forwards id', function() { + const id = 'testid'; + const instance = ReactTestUtils.renderIntoDocument( + + ); + const button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + button.getAttribute('id').should.equal(id); + }); + + it('forwards bsStyle', function() { + const style = 'success'; + const instance = ReactTestUtils.renderIntoDocument( + + ); + const button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + button.className.should.match(/\bbtn-success\b/); + }); + + it('forwards bsSize', function() { + const instance = ReactTestUtils.renderIntoDocument( + + ); + const button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'BUTTON')); + + button.className.should.match(/\bbtn-sm\b/); + }); +}); diff --git a/test/InputSpec.js b/test/InputSpec.js index 340e50df44..b7216de4ec 100644 --- a/test/InputSpec.js +++ b/test/InputSpec.js @@ -144,8 +144,8 @@ describe('Input', function () { it('renders btn-group with dropdown', function() { let instance = ReactTestUtils.renderIntoDocument( - - One + + One
    } /> ); diff --git a/test/MenuItemSpec.js b/test/MenuItemSpec.js index 906454bef4..f1c5c0f223 100644 --- a/test/MenuItemSpec.js +++ b/test/MenuItemSpec.js @@ -1,86 +1,147 @@ import React from 'react'; import ReactTestUtils from 'react/lib/ReactTestUtils'; import MenuItem from '../src/MenuItem'; +import { shouldWarn } from './helpers'; -describe('MenuItem', function () { - it('should output an li', function () { - let instance = ReactTestUtils.renderIntoDocument( - - Title +describe('MenuItem', function() { + it('renders divider', function() { + const instance = ReactTestUtils.renderIntoDocument(); + const node = React.findDOMNode(instance); + + node.className.should.match(/\bdivider\b/); + node.getAttribute('role').should.equal('separator'); + }); + + it('renders divider not children', function() { + const instance = ReactTestUtils.renderIntoDocument( + + Some child ); - assert.equal(React.findDOMNode(instance).nodeName, 'LI'); - assert.equal(React.findDOMNode(instance).getAttribute('role'), 'presentation'); + const node = React.findDOMNode(instance); + + node.className.should.match(/\bdivider\b/); + node.innerHTML.should.not.match(/Some child/); + shouldWarn('Children will not be rendered for dividers'); }); - it('should pass through props', function () { - let instance = ReactTestUtils.renderIntoDocument( + it('renders header', function() { + const instance = ReactTestUtils.renderIntoDocument(Header Text); + const node = React.findDOMNode(instance); + + node.className.should.match(/\bdropdown-header\b/); + node.getAttribute('role').should.equal('heading'); + node.innerHTML.should.match(/Header Text/); + }); + + it('renders menu item link', function(done) { + const instance = ReactTestUtils.renderIntoDocument( - Title + onKeyDown={() => done()} + href='/herpa-derpa'> + Item ); + const node = React.findDOMNode(instance); + const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A').getDOMNode(); - let node = React.findDOMNode(instance); - assert(node.className.match(/\btest-class\b/)); - assert.equal(node.getAttribute('href'), null); - assert.equal(node.getAttribute('title'), null); - assert.ok(node.className.match(/\bactive\b/)); + node.getAttribute('role').should.equal('presentation'); + anchor.getAttribute('role').should.equal('menuitem'); + anchor.getAttribute('tabIndex').should.equal('-1'); + anchor.getAttribute('href').should.equal('/herpa-derpa'); - let anchorNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'a')); - assert.notOk(anchorNode.className.match(/\btest-class\b/)); - assert.equal(anchorNode.getAttribute('href'), '#hi-mom!'); - assert.equal(anchorNode.getAttribute('title'), 'hi mom!'); + anchor.innerHTML.should.match(/Item/); + + ReactTestUtils.Simulate.keyDown(anchor, { keyCode: 1 }); }); - it('should have an anchor', function () { - let instance = ReactTestUtils.renderIntoDocument( - - Title - + it('click handling with onSelect prop', function() { + const handleSelect = (event, eventKey) => { + eventKey.should.equal('1'); + }; + const instance = ReactTestUtils.renderIntoDocument( + Item ); + const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A'); - let anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'a'); - assert.equal(React.findDOMNode(anchor).getAttribute('tabIndex'), '-1'); + ReactTestUtils.Simulate.click(anchor); }); - it('should fire callback on click of link', function (done) { - let selectOp = function (selectedKey) { - assert.equal(selectedKey, '1'); - done(); + it('click handling with onSelect prop (no eventKey)', function() { + const handleSelect = (event, eventKey) => { + expect(eventKey).to.be.undefined; }; - let instance = ReactTestUtils.renderIntoDocument( - - Title - + const instance = ReactTestUtils.renderIntoDocument( + Item ); - let anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'a'); + const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A'); + ReactTestUtils.Simulate.click(anchor); }); - it('should be a divider with no children', function () { - let instance = ReactTestUtils.renderIntoDocument( - - Title - + it('does not fire onSelect when divider is clicked', function() { + const handleSelect = (event, selectedEvent) => { + throw new Error('Should not invoke onSelect with divider flag applied'); + }; + const instance = ReactTestUtils.renderIntoDocument( + ); + ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A').length.should.equal(0); + const li = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'li'); - assert(React.findDOMNode(instance).className.match(/\bdivider\b/), 'Has no divider class'); - assert.equal(React.findDOMNode(instance).innerText, ''); + ReactTestUtils.Simulate.click(li); }); - it('should be a header with no anchor', function () { + it('does not fire onSelect when header is clicked', function() { + const handleSelect = (event, selectedEvent) => { + throw new Error('Should not invoke onSelect with divider flag applied'); + }; + const instance = ReactTestUtils.renderIntoDocument( + Header content + ); + ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'A').length.should.equal(0); + const li = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'li'); + + ReactTestUtils.Simulate.click(li); + }); + + it('disabled link', function() { + const handleSelect = (event, selectEvent) => { + throw new Error('Should not invoke onSelect event'); + }; + const instance = ReactTestUtils.renderIntoDocument( + Text + ); + const node = React.findDOMNode(instance); + const anchor = ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'A'); + + node.className.should.match(/\bdisabled\b/); + + ReactTestUtils.Simulate.click(anchor); + }); + + it('should pass through props', function () { let instance = ReactTestUtils.renderIntoDocument( - + Title ); - assert(React.findDOMNode(instance).className.match(/\bdropdown-header\b/), 'Has no header class'); - assert.equal(React.findDOMNode(instance).innerHTML, 'Title'); + let node = React.findDOMNode(instance); + + assert(node.className.match(/\btest-class\b/)); + assert.equal(node.getAttribute('href'), null); + assert.equal(node.getAttribute('title'), null); + + let anchorNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'a')); + + assert.notOk(anchorNode.className.match(/\btest-class\b/)); + assert.equal(anchorNode.getAttribute('href'), '#hi-mom!'); + assert.equal(anchorNode.getAttribute('title'), 'hi mom!'); }); it('Should set target attribute on anchor', function () { @@ -94,26 +155,14 @@ describe('MenuItem', function () { assert.equal(React.findDOMNode(anchor).getAttribute('target'), '_blank'); }); - it('Should call `onSelect` with target attribute', function (done) { - function handleSelect(key, href, target) { - assert.equal(href, 'link'); - assert.equal(target, '_blank'); - done(); - } + it('should output an li', function () { let instance = ReactTestUtils.renderIntoDocument( - + Title ); - ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithTag(instance, 'a')); + assert.equal(React.findDOMNode(instance).nodeName, 'LI'); + assert.equal(React.findDOMNode(instance).getAttribute('role'), 'presentation'); }); - it('Should be `disabled` link', function () { - let instance = ReactTestUtils.renderIntoDocument( - - Title - - ); - assert.ok(React.findDOMNode(instance).className.match(/\bdisabled\b/)); - }); }); diff --git a/test/NavDropdownSpec.js b/test/NavDropdownSpec.js new file mode 100644 index 0000000000..e70ec453cc --- /dev/null +++ b/test/NavDropdownSpec.js @@ -0,0 +1,65 @@ +import React from 'react'; +import ReactTestUtils from 'react/lib/ReactTestUtils'; +import NavDropdown from '../src/NavDropdown'; +import MenuItem from '../src/MenuItem'; + +describe('NavDropdown', function() { + + it('Should render li when in nav', function () { + const instance = ReactTestUtils.renderIntoDocument( + + MenuItem 1 content + MenuItem 2 content + + ); + + let li = React.findDOMNode(instance); + let button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'dropdown-toggle')); + + assert.equal(li.nodeName, 'LI'); + assert.ok(li.className.match(/\bdropdown\b/)); + assert.ok(li.className.match(/\btest-class\b/)); + assert.equal(button.nodeName, 'A'); + assert.equal(button.innerText.trim(), 'Title'); + }); + + it('is open with explicit prop', function() { + class OpenProp extends React.Component { + constructor(props) { + super(props); + + this.state = { + open: false + }; + } + + render () { + return ( +
    + + {}} + title='Prop open control' + id='test-id'> + Item 1 + +
    + ); + } + } + + const instance = ReactTestUtils.renderIntoDocument(); + const outerToggle = ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'outer-button'); + const dropdownNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'dropdown')); + + dropdownNode.className.should.not.match(/\bopen\b/); + ReactTestUtils.Simulate.click(outerToggle); + dropdownNode.className.should.match(/\bopen\b/); + ReactTestUtils.Simulate.click(outerToggle); + dropdownNode.className.should.not.match(/\bopen\b/); + }); +}); diff --git a/test/SplitButtonSpec.js b/test/SplitButtonSpec.js index c5b152bfa5..c16f0de782 100644 --- a/test/SplitButtonSpec.js +++ b/test/SplitButtonSpec.js @@ -4,216 +4,102 @@ import SplitButton from '../src/SplitButton'; import MenuItem from '../src/MenuItem'; import Button from '../src/Button'; -describe('SplitButton', function () { - let instance; - afterEach(function() { - if (instance && ReactTestUtils.isCompositeComponent(instance) && instance.isMounted()) { - React.unmountComponentAtNode(React.findDOMNode(instance)); - } +describe('SplitButton', function() { + const simple = ( + + Item 1 + Item 2 + Item 3 + Item 4 + + ); + + it('should open the menu when dropdown button is clicked', function () { + const instance = ReactTestUtils.renderIntoDocument(simple); + + const toggleNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'dropdown-toggle')); + const splitButtonNode = React.findDOMNode(instance); + + splitButtonNode.className.should.not.match(/open/); + ReactTestUtils.Simulate.click(toggleNode); + splitButtonNode.className.should.match(/open/); }); - it('Should render button correctly', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); - - let button = React.findDOMNode(instance.refs.button); - let dropdownButton = React.findDOMNode(instance.refs.dropdownButton); - assert.ok(React.findDOMNode(instance).className.match(/\bbtn-group\b/)); - assert.ok(button.className.match(/\bbtn\b/)); - assert.equal(button.nodeName, 'BUTTON'); - assert.equal(button.type, 'button'); - assert.ok(dropdownButton.className.match(/\bdropdown-toggle\b/)); - assert.equal(button.innerText.trim(), 'Title'); - assert.ok(dropdownButton.childNodes[0].className.match(/\bsr-only\b/)); - assert.equal(dropdownButton.childNodes[0].innerText.trim(), 'Toggle dropdown'); - assert.ok(dropdownButton.childNodes[1].className.match(/\bcaret\b/)); - assert.equal(dropdownButton.childNodes[2].style.letterSpacing, '-0.3em'); - assert.equal(dropdownButton.childNodes.length, 3); - }); - - it('Should render menu correctly', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); - - let menu = React.findDOMNode(instance.refs.menu); - assert.ok(menu.className.match(/\bdropdown-menu\b/)); - assert.equal(menu.getAttribute('role'), 'menu'); - assert.equal(menu.firstChild.nodeName, 'LI'); - assert.equal(menu.firstChild.innerText, 'MenuItem 1 content'); - assert.equal(menu.lastChild.nodeName, 'LI'); - assert.equal(menu.lastChild.innerText, 'MenuItem 2 content'); - }); - - it('Should pass dropdownTitle to dropdown button', function () { - let CustomTitle = React.createClass({ render() { return ; } }); - instance = ReactTestUtils.renderIntoDocument( - } dropdownTitle={}> - MenuItem 1 content - MenuItem 2 content - - ); - - assert.ok(ReactTestUtils.findRenderedComponentWithType(instance.refs.button, CustomTitle)); - assert.ok(ReactTestUtils.findRenderedComponentWithType(instance.refs.dropdownButton, CustomTitle)); - }); + it('should not open the menu when other button is clicked', function() { + const instance = ReactTestUtils.renderIntoDocument(simple); - it('Should pass props to button', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); - - let button = React.findDOMNode(instance.refs.button); - assert.ok(button.className.match(/\bbtn-primary\b/)); - }); - - it('Should pass disabled to both buttons', function() { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); + const buttonNode = React.findDOMNode(ReactTestUtils.scryRenderedComponentsWithType(instance, Button)[0]); + const splitButtonNode = React.findDOMNode(instance); - let button = React.findDOMNode(instance.refs.button); - assert.ok(button.disabled); - let dropdownButton = React.findDOMNode(instance.refs.dropdownButton); - assert.ok(dropdownButton.disabled); + splitButtonNode.className.should.not.match(/open/); + ReactTestUtils.Simulate.click(buttonNode); + splitButtonNode.className.should.not.match(/open/); }); - it('Should pass id to button group', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + it('should invoke onClick when SplitButton.Button is clicked (prop)', function(done) { + const instance = ReactTestUtils.renderIntoDocument( + done() }> + Item 1 ); - assert.equal(React.findDOMNode(instance).getAttribute('id'), 'testId'); + const buttonNode = React.findDOMNode(ReactTestUtils.scryRenderedComponentsWithType(instance, Button)[0]); + ReactTestUtils.Simulate.click(buttonNode); }); - it('Should be closed by default', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); - assert.notOk(React.findDOMNode(instance).className.match(/\bopen\b/)); - }); + it('should not invoke onClick when SplitButton.Toggle is clicked (prop)', function(done) { + let onClickSpy = sinon.spy(); - it('Should open when clicked', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 ); - ReactTestUtils.SimulateNative.click(React.findDOMNode(instance.refs.dropdownButton)); + const toggleNode = React.findDOMNode( + ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'dropdown-toggle')); - assert.ok(React.findDOMNode(instance).className.match(/\bopen\b/)); - }); + ReactTestUtils.Simulate.click(toggleNode); - it('should call onSelect with eventKey when MenuItem is clicked', function (done) { - function handleSelect(eventKey) { - assert.equal(eventKey, '2'); + setTimeout(()=> { + onClickSpy.should.not.have.been.called; done(); - } - - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); - - let menuItems = ReactTestUtils.scryRenderedComponentsWithType(instance, MenuItem); - assert.equal(menuItems.length, 2); - ReactTestUtils.SimulateNative.click( - ReactTestUtils.findRenderedDOMComponentWithTag(menuItems[1], 'a') - ); + }, 10); }); - it('Should have dropup class', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content + it('Should pass disabled to both buttons', function () { + const instance = ReactTestUtils.renderIntoDocument( + + Item 1 ); - assert.ok(React.findDOMNode(instance).className.match(/\bdropup\b/)); - }); + const toggleNode = React.findDOMNode( + ReactTestUtils.findRenderedDOMComponentWithClass(instance, 'dropdown-toggle')); - it('Should pass pullRight prop to menu', function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); + const buttonNode = React.findDOMNode( + ReactTestUtils.scryRenderedComponentsWithType(instance, Button)[0]); - assert.ok(instance.refs.menu.props.pullRight); + expect(toggleNode.disabled).to.be.true; + expect(buttonNode.disabled).to.be.true; }); it('Should set target attribute on anchor', function () { - instance = ReactTestUtils.renderIntoDocument( - + const instance = ReactTestUtils.renderIntoDocument( + MenuItem 1 content ); let anchors = ReactTestUtils.scryRenderedDOMComponentsWithTag(instance, 'a'); - assert.equal(anchors.length, 2); let linkElement = React.findDOMNode(anchors[0]); - assert.equal(linkElement.target, '_blank'); - }); - it('Should call `onClick` with target attribute', function (done) { - function handleClick(key, href, target) { - assert.equal(target, '_blank'); - done(); - } - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - - ); - - let buttons = ReactTestUtils.scryRenderedComponentsWithType(instance, Button); - ReactTestUtils.Simulate.click(ReactTestUtils.findRenderedDOMComponentWithTag(buttons[0], 'a')); + assert.equal(linkElement.target, '_blank'); }); - describe('when open', function () { - beforeEach(function () { - instance = ReactTestUtils.renderIntoDocument( - - MenuItem 1 content - MenuItem 2 content - - ); - - instance.setDropdownState(true); - }); - - it('should close when button is clicked', function () { - let evt = document.createEvent('HTMLEvents'); - evt.initEvent('click', true, true); - document.documentElement.dispatchEvent(evt); - - assert.notOk(React.findDOMNode(instance).className.match(/\bopen\b/)); - }); - }); }); diff --git a/tools/.eslintrc b/tools/.eslintrc index 7753f32e77..1ade0594f0 100644 --- a/tools/.eslintrc +++ b/tools/.eslintrc @@ -1,5 +1,13 @@ { + "env": { + "node": true + }, + "parser": "babel-eslint", + "plugins": [ + "lodash" + ], "rules": { + "lodash/import": 0, "no-console": 0 } } diff --git a/tools/buildBabel.js b/tools/buildBabel.js index aae1d57738..52dc3a1d86 100644 --- a/tools/buildBabel.js +++ b/tools/buildBabel.js @@ -4,13 +4,13 @@ import fs from 'fs'; import path from 'path'; import outputFileSync from 'output-file-sync'; -export function buildContent(content, filename, destination, babelOptions={}) { +export function buildContent(content, filename, destination, babelOptions = {}) { babelOptions.filename = filename; const result = transform(content, babelOptions); outputFileSync(destination, result.code, {encoding: 'utf8'}); } -export function buildFile(filename, destination, babelOptions={}) { +export function buildFile(filename, destination, babelOptions = {}) { const content = fs.readFileSync(filename, {encoding: 'utf8'}); // We only have .js files that we need to build if(path.extname(filename) === '.js') { @@ -20,7 +20,7 @@ export function buildFile(filename, destination, babelOptions={}) { } } -export function buildFolder(folderPath, destination, babelOptions={}, firstFolder=true) { +export function buildFolder(folderPath, destination, babelOptions = {}, firstFolder = true) { let stats = fs.statSync(folderPath); if(stats.isFile()) { @@ -32,7 +32,7 @@ export function buildFolder(folderPath, destination, babelOptions={}, firstFolde } } -export function buildGlob(filesGlob, destination, babelOptions={}) { +export function buildGlob(filesGlob, destination, babelOptions = {}) { let files = glob.sync(filesGlob); if (!files.length) { files = [filesGlob]; diff --git a/webpack/.eslintrc b/webpack/.eslintrc new file mode 100644 index 0000000000..7ac5ae9281 --- /dev/null +++ b/webpack/.eslintrc @@ -0,0 +1,12 @@ +{ + "env": { + "node": true + }, + "parser": "babel-eslint", + "plugins": [ + "lodash" + ], + "rules": { + "lodash/import": 0 + } +} diff --git a/webpack/docs.config.js b/webpack/docs.config.js index dcaaac01cb..7964a543fa 100644 --- a/webpack/docs.config.js +++ b/webpack/docs.config.js @@ -22,6 +22,8 @@ if (options.debug) { export default _.extend({}, baseConfig, { + devtool: options.debug ? 'source-map' : null, + entry: { bundle: options.debug ? devEntryBundle : entryFile },