diff --git a/.eslintrc b/.eslintrc index 3eaabe60..0fe21315 100644 --- a/.eslintrc +++ b/.eslintrc @@ -21,6 +21,7 @@ "global-require" : "off", "implicit-arrow-linebreak" : "off", "import/first" : "off", + "import/no-cycle" : "off", "import/no-unresolved" : "error", "import/order" : [ "error", @@ -117,6 +118,7 @@ "react/jsx-wrap-multilines" : [ "error", { "arrow": false } ], "react/no-unused-prop-types" : "warn", "react/jsx-one-expression-per-line" : "off", + "react-hooks/rules-of-hooks" : "error", "require-jsdoc" : [ "error", { @@ -157,22 +159,12 @@ ], "valid-typeof" : "error" }, - "globals" : { - "after" : true, - "afterEach" : true, - "before" : true, - "beforeEach" : true, - "describe" : true, - "expect" : true, - "fetch" : true, - "test" : true, - "jest" : true - }, "plugins" : [ "arca", "compat", "import", "jsx-a11y", - "promise" + "promise", + "react-hooks" ] } diff --git a/.release-it.json b/.release-it.json index 12b42204..55c0f347 100644 --- a/.release-it.json +++ b/.release-it.json @@ -1,6 +1,5 @@ { "scripts" : { - "beforeStart" : "yarn test", "beforeBump" : "yarn build" } } diff --git a/.travis.yml b/.travis.yml index be7e302e..541972bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,4 @@ +if: branch = master language: node_js node_js: - 8.15.0 diff --git a/cfg/dist.js b/cfg/dist.js index 863ed1c5..9235e6f9 100644 --- a/cfg/dist.js +++ b/cfg/dist.js @@ -34,11 +34,6 @@ const distConfig = merge( {}, baseConfig, { 'commonjs2' : 'react-popper', 'window' : 'ReactPopper', }, - componentDriver : { - commonjs : 'nessie-ui/dist/componentDriver', - commonjs2 : 'nessie-ui/dist/componentDriver', - window : 'ComponentDriver', - }, lodash : { 'commonjs' : 'lodash', 'commonjs2' : 'lodash', @@ -79,11 +74,6 @@ components.module.rules[ 1 ].use[ 0 ] = { }, }; -const componentDriver = merge( {}, distConfig, { - entry : path.join( __dirname, '../src/Testing/index.js' ), - output : { filename: 'componentDriver.js' }, -} ); - const componentsJS = merge( {}, distConfig, { entry : path.join( __dirname, '../src/index.js' ), output : { filename: 'componentsJS.js' }, @@ -95,21 +85,8 @@ const componentsJS = merge( {}, distConfig, { ], } ); -const driverSuite = merge( {}, distConfig, { - entry : path.join( __dirname, '../src/drivers.js' ), - output : { filename: 'driverSuite.js' }, - plugins : [ - new MiniCssExtractPlugin( { - allChunks : true, - filename : 'driverSuite.css', - } ), - ], -} ); - module.exports = [ - componentDriver, components, componentsJS, - driverSuite, ]; diff --git a/cfg/jest.config.js b/cfg/jest.config.js deleted file mode 100644 index a596825e..00000000 --- a/cfg/jest.config.js +++ /dev/null @@ -1,60 +0,0 @@ -module.exports = { - // The jest is presumed to be wherever the config - // file is, so here we put it back to the root folder. - rootDir : '../', - - testMatch : - [ - '/src/**/tests.{js,jsx}', - ], - transform : { - '^.+\\.(js|jsx)?$' : 'babel-jest', - }, - - // We should specify that jest should load all dependencies - // from the ROOT node_modules folder. If not, and you have - // a dependency symlinked, there can potentially be two - // versions of a module loaded (such as react). - moduleDirectories : - [ - '/node_modules', - '/src', - ], - - // Tell jest explicitly where to search for source files - // and test files. Otherwise jest will parse any folders - // including local npm caches etc. - roots : - [ - '/src', - ], - - setupFiles : - [ - '/src/Testing/setupTestEnvironment.js', - ], - - moduleNameMapper : Object.assign( - {}, - // Map module aliases to directories - { - 'nessie-ui' : '/src/index', - 'componentDriver' : '/src/Testing/index', - }, - // Mock assets - { - '\\.(html|jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2)$' : - '/src/Testing/mocks/fileMock.js', - '\\.(css|less|scss)$' : 'identity-obj-proxy', - 'createCssMap' : - '/src/Testing/mocks/createCssMapMock.js', - } /* eslint-disable-line comma-dangle */ - ), - - verbose : true, - - transformIgnorePatterns : - [ - 'node_modules/(?!flounder)', - ], -}; diff --git a/package.json b/package.json index fba7d2ad..b78476b9 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,6 @@ "loch-ness:clean": "rimraf .lochness", "loch-ness": "loch --libName=Nessie --lib=./ --dist=./dist/index.dev.js", "start": "yarn loch-ness:build && concurrently --kill-others \"yarn webpack:dev-watch\" \"yarn loch-ness\"", - "test:watch": "jest --watch --config=cfg/jest.config.js", - "test": "jest --maxWorkers=4 --config=cfg/jest.config.js", "webpack:dev-watch": "webpack --env=dev --watch", "webpack:dev": "webpack --env=dev", "webpack:dist-watch": "webpack --env=dist --watch", @@ -63,15 +61,12 @@ "autoprefixer": "^9.4.7", "babel-core": "^7.0.0-bridge.0", "babel-eslint": "^10.0.1", - "babel-jest": "^24.1.0", "babel-loader": "^8.0.5", "babel-plugin-transform-react-remove-prop-types": "^0.4.24", "classnames": "^2.2.6", "concurrently": "^4.1.0", "css-loader": "^2.1.0", "cssnano": "^4.1.8", - "enzyme": "^3.8.0", - "enzyme-adapter-react-16": "^1.9.1", "eslint": "^5.13.0", "eslint-config-airbnb": "^17.1.0", "eslint-formatter-pretty": "^2.1.1", @@ -81,8 +76,7 @@ "eslint-plugin-jsx-a11y": "^6.2.1", "eslint-plugin-promise": "^4.0.1", "eslint-plugin-react": "^7.12.4", - "identity-obj-proxy": "^3.0.0", - "jest": "^24.1.0", + "eslint-plugin-react-hooks": "^1.0.1", "loch-ness": "3.0.0-alpha.3", "mini-css-extract-plugin": "^0.5.0", "postcss": "^7.0.13", @@ -107,7 +101,6 @@ "prop-types": "^15.5.8", "react": "^16.8.1", "react-dom": "^16.8.1", - "react-test-renderer": "^16.8.1", "release-it": "^10.1.0", "replace": "^1.0.1", "rimraf": "^2.6.3", diff --git a/src/Addons/withDropdown/index.jsx b/src/Addons/withDropdown/index.jsx deleted file mode 100644 index acd3acf1..00000000 --- a/src/Addons/withDropdown/index.jsx +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2017-2019 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import React from 'react'; -import PropTypes from 'prop-types'; - -import { Popup } from '../..'; -import { buildDisplayName } from '../../utils'; -import ThemeContext from '../../Theming/ThemeContext'; -import { createCssMap } from '../../Theming'; - - -const withDropdown = Component => -{ - class ComponentWithDropdown extends React.Component - { - static contextType = ThemeContext; - - static propTypes = { - ...Component.propTypes, - /** - * Show/hide the dropdown - */ - dropdownIsOpen : PropTypes.bool, - /** - * Position of dropdown relative to component - */ - dropdownPosition : PropTypes.oneOf( [ 'bottom', 'top' ] ), - /** - * Props to pass directly to the Dropdown component - */ - dropdownProps : PropTypes.objectOf( PropTypes.any ), - /** - * Ref object to receive a ref to the outer wrapper div - */ - wrapperRef : PropTypes.object, - }; - - static defaultProps = { - ...Component.defaultProps, - dropdownIsOpen : false, - dropdownPosition : 'bottom', - dropdownProps : undefined, - wrapperRef : undefined, - }; - - render() - { - const { - cssMap = createCssMap( this.context.withDropdown, this.props ), - dropdownIsOpen, - dropdownPosition, - dropdownProps, - wrapperRef, - ...componentProps - } = this.props; - - return ( -
- - -
- ); - } - } - - ComponentWithDropdown.displayName = - buildDisplayName( ComponentWithDropdown, Component ); - - return ComponentWithDropdown; -}; - -export default withDropdown; diff --git a/src/Addons/withDropdown/withDropdown.css b/src/Addons/withDropdown/withDropdown.css deleted file mode 100644 index 67945e02..00000000 --- a/src/Addons/withDropdown/withDropdown.css +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -@import "../../proto/definitions/variables.css"; - -.default -{ - position: relative; - - .dropdown - { - position: absolute; - left: 0; - z-index: 999; - } - - &:not( .open ) - { - .dropdown - { - display: none; - } - } - - & > * - { - margin-bottom: 0; - } -} - - -.position__bottom -{ - .dropdown - { - bottom: 0; - transform: translateY( 100% ) translateY( var( --spacing-1) ); - } -} - -.position__top -{ - .dropdown - { - top: 0; - transform: translateY( -100% ) translateY( calc( -1 * var( --spacing-1) ) ); - } -} diff --git a/src/Button/button.css b/src/Button/button.css index a208641c..e6cade05 100644 --- a/src/Button/button.css +++ b/src/Button/button.css @@ -71,7 +71,6 @@ width: 100%; height: 100%; - } } diff --git a/src/Button/driver.js b/src/Button/driver.js deleted file mode 100644 index 7bce49be..00000000 --- a/src/Button/driver.js +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import { createCssMap } from '../Theming'; - - -const ERR = { - BUTTON_ERR : ( label, event, state ) => - `Button '${label}' cannot simulate ${event} since it is ${state}`, -}; - - -export default class ButtonDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - get instance() - { - return this.wrapper.instance(); - } - - get cssMap() - { - const { instance } = this; - return instance.props.cssMap || - createCssMap( instance.context.Button, instance.props ); - } - - get button() - { - return this.wrapper.find( `.${this.cssMap.main}` ).first(); - } - - - click() - { - const label = this.wrapper.find( `.${this.cssMap.label}` ).text(); - - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.BUTTON_ERR( label, 'click', 'disabled' ) ); - } - - if ( this.wrapper.props().isReadOnly ) - { - throw new Error( ERR.BUTTON_ERR( label, 'click', 'read only' ) ); - } - - if ( this.wrapper.props().isLoading ) - { - throw new Error( ERR.BUTTON_ERR( label, 'click', 'loading' ) ); - } - - this.button.simulate( 'click' ); - return this; - } - - mouseOver() - { - const label = this.wrapper.find( `.${this.cssMap.label}` ).text(); - - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.BUTTON_ERR( label, 'mouseOver', 'disabled' ) ); - } - - if ( this.wrapper.props().isLoading ) - { - throw new Error( ERR.BUTTON_ERR( label, 'mouseOver', 'loading' ) ); - } - - this.button.simulate( 'mouseOver' ); - return this; - } - - mouseOut() - { - const label = this.wrapper.find( `.${this.cssMap.label}` ).text(); - - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.BUTTON_ERR( label, 'mouseOut', 'disabled' ) ); - } - - if ( this.wrapper.props().isLoading ) - { - throw new Error( ERR.BUTTON_ERR( label, 'mouseOut', 'loading' ) ); - } - - this.button.simulate( 'mouseOut' ); - return this; - } -} diff --git a/src/Button/index.jsx b/src/Button/index.jsx index 167aae20..07e094fd 100644 --- a/src/Button/index.jsx +++ b/src/Button/index.jsx @@ -7,150 +7,144 @@ * */ -import React from 'react'; +import React, { + useImperativeHandle, + useRef, + forwardRef, +} from 'react'; import PropTypes from 'prop-types'; import { Icon, Spinner } from '..'; -import { attachEvents, generateId } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { attachEvents, useTheme } from '../utils'; -export default class Button extends React.Component -{ - static contextType = ThemeContext; +const componentName = 'Button'; - static propTypes = - { - /** - * Label text (React node; overrides label prop) - */ - children : PropTypes.node, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Icon position relative to label - */ - iconPosition : PropTypes.oneOf( [ 'left', 'right' ] ), - /** - * Icon type to display (see https://feathericons.com/) - */ - iconType : PropTypes.string, - /** - * Component identifier - */ - id : PropTypes.string, - /** - * Display as disabled - */ - isDisabled : PropTypes.bool, - /** - * Display as loading - */ - isLoading : PropTypes.bool, - /** - * Label text - */ - label : PropTypes.string, - /** - * click callback function: ( { id } ) => ... - */ - onClick : PropTypes.func, - /** - * mouse out callback function: ( { id } ) => ... - */ - onMouseOut : PropTypes.func, - /** - * mouse over callback function: ( { id } ) => ... - */ - onMouseOver : PropTypes.func, - /** - * Role/style - */ - role : PropTypes.oneOf( [ - 'default', - 'secondary', - 'subtle', - 'promoted', - 'critical', - 'control', - ] ), - }; +const Button = forwardRef( ( props, ref ) => +{ + const buttonRef = useRef(); - static defaultProps = - { - children : undefined, - cssMap : undefined, - iconPosition : 'left', - iconType : 'none', - id : undefined, - isDisabled : false, - isLoading : false, - label : undefined, - onClick : undefined, - onMouseOut : undefined, - onMouseOver : undefined, - role : 'default', - }; + useImperativeHandle( ref, () => ( { + focus : () => buttonRef.current.focus(), + } ) ); - static displayName = 'Button'; + const { + children, + iconType, + id, + isDisabled, + isLoading, + label, + } = props; - buttonRef = React.createRef(); + const cssMap = useTheme( componentName, props ); - constructor( props ) - { - super(); - this.state = { id: props.id || generateId( 'Button' ) }; - } + return ( + + ); +} ); - focus() - { - this.buttonRef.current.focus(); - } +Button.displayName = componentName; - render() - { - const { - children, - cssMap = createCssMap( this.context.Button, this.props ), - iconType, - isDisabled, - isLoading, - label, - } = this.props; +Button.propTypes = +{ + /** + * Label text (React node; overrides label prop) + */ + children : PropTypes.node, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Icon position relative to label + */ + iconPosition : PropTypes.oneOf( [ 'left', 'right' ] ), + /** + * Icon type to display (see https://feathericons.com/) + */ + iconType : PropTypes.string, + /** + * Component identifier + */ + id : PropTypes.string, + /** + * Display as disabled + */ + isDisabled : PropTypes.bool, + /** + * Display as loading + */ + isLoading : PropTypes.bool, + /** + * Label text + */ + label : PropTypes.string, + /** + * click callback function: ( { id } ) => ... + */ + onClick : PropTypes.func, + /** + * mouse out callback function: ( { id } ) => ... + */ + onMouseOut : PropTypes.func, + /** + * mouse over callback function: ( { id } ) => ... + */ + onMouseOver : PropTypes.func, + /** + * Role/style + */ + role : PropTypes.oneOf( [ + 'default', + 'secondary', + 'subtle', + 'promoted', + 'critical', + 'control', + ] ), +}; - const { id } = this.state; +Button.defaultProps = +{ + children : undefined, + cssMap : undefined, + iconPosition : 'left', + iconType : 'none', + id : undefined, + isDisabled : false, + isLoading : false, + label : undefined, + onClick : undefined, + onMouseOut : undefined, + onMouseOver : undefined, + role : 'default', +}; - return ( - - ); - } -} +export default Button; diff --git a/src/Button/tests.jsx b/src/Button/tests.jsx deleted file mode 100644 index b0af24bf..00000000 --- a/src/Button/tests.jsx +++ /dev/null @@ -1,356 +0,0 @@ -/* - * Copyright (c) 2017-2019 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { mount, shallow } from 'enzyme'; - -import { Button, Icon, Spinner } from '..'; - -describe( 'Button', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( + ); +}; - static propTypes = { - children : PropTypes.node, - className : PropTypes.string, - cssMap : PropTypes.objectOf( PropTypes.string ), - forceHover : PropTypes.bool, - isDisabled : PropTypes.bool, - isSelected : PropTypes.bool, - label : PropTypes.string, - onClick : PropTypes.func, - value : PropTypes.string, - type : PropTypes.oneOf( [ 'day', 'month' ] ), - }; +DatePickerItem.propTypes = { + children : PropTypes.node, + className : PropTypes.string, + cssMap : PropTypes.objectOf( PropTypes.string ), + isDisabled : PropTypes.bool, + isSelected : PropTypes.bool, + label : PropTypes.string, + onClick : PropTypes.func, + value : PropTypes.string, + type : PropTypes.oneOf( [ 'day', 'month' ] ), +}; - static defaultProps = { - children : undefined, - className : undefined, - cssMap : undefined, - forceHover : false, - isDisabled : false, - isSelected : false, - label : undefined, - onClick : undefined, - value : undefined, - type : 'day', - }; +DatePickerItem.defaultProps = { + children : undefined, + className : undefined, + cssMap : undefined, + isDisabled : false, + isSelected : false, + label : undefined, + onClick : undefined, + value : undefined, + type : 'day', +}; - render() - { - const { - children, - cssMap = createCssMap( this.context.DatePickerItem, this.props ), - isDisabled, - isSelected, - label, - value, - } = this.props; +DatePickerItem.displayName = componentName; - return ( - - ); - } -} +export default DatePickerItem; diff --git a/src/DatePicker/TimeInput.jsx b/src/DatePicker/TimeInput.jsx index 4cced09f..cf6ec6b8 100644 --- a/src/DatePicker/TimeInput.jsx +++ b/src/DatePicker/TimeInput.jsx @@ -7,97 +7,100 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import { createEventHandler, generateId } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { + createEventHandler, + useTheme, +} from '../utils'; -export default class TimeInput extends React.Component +const componentName = 'TimeInput'; + +const TimeInput = props => { - static contextType = ThemeContext; + const cssMap = useTheme( componentName, props ); + + const { + hourIsDisabled, + hourIsReadOnly, + hourPlaceholder, + hourValue, + id, + isDisabled, + isReadOnly, + minuteIsDisabled, + minuteIsReadOnly, + minutePlaceholder, + minuteValue, + onChangeHour, + onChangeMinute, + } = props; - static propTypes = { - className : PropTypes.string, - cssMap : PropTypes.objectOf( PropTypes.string ), - forceHover : PropTypes.bool, - hourIsDisabled : PropTypes.bool, - hourIsReadOnly : PropTypes.bool, - hourPlaceholder : PropTypes.string, - hourValue : PropTypes.string, - id : PropTypes.string, - isDisabled : PropTypes.bool, - isReadOnly : PropTypes.bool, - minuteIsDisabled : PropTypes.bool, - minuteIsReadOnly : PropTypes.bool, - minutePlaceholder : PropTypes.string, - minuteValue : PropTypes.string, - onChangeHour : PropTypes.func, - onChangeMinute : PropTypes.func, - }; + return ( +
+ + : + +
+ ); +}; - static defaultProps = { - className : undefined, - cssMap : undefined, - forceHover : false, - hourIsDisabled : false, - hourIsReadOnly : false, - hourPlaceholder : 'HH', - hourValue : undefined, - id : undefined, - isDisabled : false, - isReadOnly : false, - minuteIsDisabled : false, - minuteIsReadOnly : false, - minutePlaceholder : 'mm', - minuteValue : undefined, - onChangeHour : undefined, - onChangeMinute : undefined, - }; +TimeInput.propTypes = +{ + className : PropTypes.string, + cssMap : PropTypes.objectOf( PropTypes.string ), + hourIsDisabled : PropTypes.bool, + hourIsReadOnly : PropTypes.bool, + hourPlaceholder : PropTypes.string, + hourValue : PropTypes.string, + id : PropTypes.string, + isDisabled : PropTypes.bool, + isReadOnly : PropTypes.bool, + minuteIsDisabled : PropTypes.bool, + minuteIsReadOnly : PropTypes.bool, + minutePlaceholder : PropTypes.string, + minuteValue : PropTypes.string, + onChangeHour : PropTypes.func, + onChangeMinute : PropTypes.func, +}; + +TimeInput.defaultProps = +{ + className : undefined, + cssMap : undefined, + hourIsDisabled : false, + hourIsReadOnly : false, + hourPlaceholder : 'HH', + hourValue : undefined, + id : undefined, + isDisabled : false, + isReadOnly : false, + minuteIsDisabled : false, + minuteIsReadOnly : false, + minutePlaceholder : 'mm', + minuteValue : undefined, + onChangeHour : undefined, + onChangeMinute : undefined, +}; - render() - { - const { - cssMap = createCssMap( this.context.TimeInput, this.props ), - hourIsDisabled, - hourIsReadOnly, - hourPlaceholder, - hourValue, - id = generateId( 'TimeInput' ), - isDisabled, - isReadOnly, - minuteIsDisabled, - minuteIsReadOnly, - minutePlaceholder, - minuteValue, - onChangeHour, - onChangeMinute, - } = this.props; +TimeInput.displayName = componentName; - return ( -
- - : - -
- ); - } -} +export default TimeInput; diff --git a/src/DatePicker/driver.js b/src/DatePicker/driver.js deleted file mode 100644 index 5cdec100..00000000 --- a/src/DatePicker/driver.js +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import { IconButton } from 'nessie-ui'; - -import TimeInput from './TimeInput'; -import DatePickerItem from './DatePickerItem'; -import { createCssMap } from '../Theming'; - -const ERR = { - ITEM_ERR : ( label, state ) => - `Item '${label}' cannot be clicked since it is ${state}`, - NAV_ERR : ( el, state ) => - `${el} cannot simulate click since it is ${state}`, - NO_INPUT : () => - 'There’s no input because is not ', - TIMEINPUT_ERR : ( event, state ) => - `TimeInput cannot simulate ${event} since it is ${state}`, -}; - - -export default class DatePickerDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - get instance() - { - return this.wrapper.instance(); - } - - get timeInput() - { - return this.wrapper.find( TimeInput ).prop( 'cssMap' ) || - createCssMap( this.instance.context.TimeInput ); - } - - get prev() - { - return this.wrapper.find( IconButton ).findWhere( node => - node.props().iconType === 'arrow-left' ); - } - - get next() - { - return this.wrapper.find( IconButton ).findWhere( node => - node.props().iconType === 'arrow-right' ); - } - - get hour() - { - return this.timeInput.hour; - } - - get min() - { - return this.timeInput.min; - } - - - clickItem( index = 0 ) - { - const dateItem = this.wrapper.find( DatePickerItem ) - .at( index ); - const { label } = dateItem.props(); - - if ( dateItem.isDisabled ) - { - throw new Error( ERR.ITEM_ERR( label, 'disabled' ) ); - } - - dateItem.simulate( 'click' ); - return this; - } - - clickPrev() - { - if ( this.wrapper.props().prevIsDisabled ) - { - throw new Error( ERR.NAV_ERR( 'Previous', 'disabled' ) ); - } - - this.prev.driver().click(); - return this; - } - - clickNext() - { - if ( this.wrapper.props().nextIsDisabled ) - { - throw new Error( ERR.NAV_ERR( 'Next', 'disabled' ) ); - } - - this.next.driver().click(); - return this; - } - - keyPressHourInput( key ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'keyPress', 'disabled' ) ); - } - - if ( this.wrapper.props().mode !== 'default' ) - { - throw new Error( ERR.NO_INPUT() ); - } - - this.wrapper.find( `.${this.hour}` ).simulate( 'keyPress', { key } ); - return this; - } - - keyPressMinuteInput( key ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'keyPress', 'disabled' ) ); - } - - if ( this.wrapper.props().mode !== 'default' ) - { - throw new Error( ERR.NO_INPUT() ); - } - - this.wrapper.find( `.${this.min}` ).simulate( 'keyPress', { key } ); - return this; - } - - blurHourInput() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'blur', 'disabled' ) ); - } - - if ( this.wrapper.props().mode !== 'default' ) - { - throw new Error( ERR.NO_INPUT() ); - } - - this.wrapper.find( `.${this.hour}` ).simulate( 'blur' ); - return this; - } - - blurMinuteInput() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'blur', 'disabled' ) ); - } - - if ( this.wrapper.props().mode !== 'default' ) - { - throw new Error( ERR.NO_INPUT() ); - } - - this.wrapper.find( `.${this.min}` ).simulate( 'blur' ); - return this; - } - - focusHourInput() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'focus', 'disabled' ) ); - } - - if ( this.wrapper.props().mode !== 'default' ) - { - throw new Error( ERR.NO_INPUT() ); - } - - this.wrapper.find( `.${this.hour}` ).simulate( 'focus' ); - return this; - } - - focusMinuteInput() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'focus', 'disabled' ) ); - } - - if ( this.wrapper.props().mode !== 'default' ) - { - throw new Error( ERR.NO_INPUT() ); - } - - this.wrapper.find( `.${this.min}` ).simulate( 'focus' ); - return this; - } - - changeHourInput( val ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'change', 'disabled' ) ); - } - - if ( this.wrapper.props().isReadOnly ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'change', 'read only' ) ); - } - - if ( this.wrapper.props().mode !== 'default' ) - { - throw new Error( ERR.NO_INPUT() ); - } - - const node = this.wrapper.find( `.${this.hour}` ).instance(); - node.value = val; - - this.wrapper.find( `.${this.hour}` ).simulate( 'change' ); - return this; - } - - changeMinuteInput( val ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'change', 'disabled' ) ); - } - - if ( this.wrapper.props().isReadOnly ) - { - throw new Error( ERR.TIMEINPUT_ERR( 'change', 'read only' ) ); - } - - if ( this.wrapper.props().mode !== 'default' ) - { - throw new Error( ERR.NO_INPUT() ); - } - - const node = this.wrapper.find( `.${this.min}` ).instance(); - node.value = val; - - this.wrapper.find( `.${this.min}` ).simulate( 'change' ); - return this; - } -} diff --git a/src/DatePicker/index.jsx b/src/DatePicker/index.jsx index ae9b0def..886f924d 100644 --- a/src/DatePicker/index.jsx +++ b/src/DatePicker/index.jsx @@ -7,167 +7,165 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import DatePickerItem from './DatePickerItem'; -import DatePickerHeader from './DatePickerHeader'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -import { attachEvents } from '../utils'; +import DatePickerItem from './DatePickerItem'; +import DatePickerHeader from './DatePickerHeader'; +import { attachEvents, useTheme } from '../utils'; -export default class DatePicker extends React.Component +const componentName = 'DatePicker'; + +const DatePicker = props => { - static contextType = ThemeContext; + const cssMap = useTheme( componentName, props ); + + const { + hasTimeInput, + headers, + hourIsDisabled, + hourIsReadOnly, + hourPlaceholder, + hourValue, + isDisabled, + isReadOnly, + items, + label, + minuteIsDisabled, + minuteIsReadOnly, + minutePlaceholder, + minuteValue, + month, + nextIsDisabled, + onChangeHour, + onChangeMinute, + onClickItem, + onClickNext, + onClickPrev, + prevIsDisabled, + type, + year, + } = props; - static propTypes = { - className : PropTypes.string, - cssMap : PropTypes.objectOf( PropTypes.string ), - headers : PropTypes.arrayOf( PropTypes - .objectOf( PropTypes.string ) ), - hourIsDisabled : PropTypes.bool, - hourIsReadOnly : PropTypes.bool, - hourPlaceholder : PropTypes.string, - hourValue : PropTypes.string, - isDisabled : PropTypes.bool, - isReadOnly : PropTypes.bool, - items : PropTypes.arrayOf( PropTypes - .arrayOf( PropTypes.object ) ), - hasTimeInput : PropTypes.bool, - label : PropTypes.string, - minuteIsDisabled : PropTypes.bool, - minuteIsReadOnly : PropTypes.bool, - minutePlaceholder : PropTypes.string, - minuteValue : PropTypes.string, - month : PropTypes.string, - nextIsDisabled : PropTypes.bool, - onChangeHour : PropTypes.func, - onChangeMinute : PropTypes.func, - onClickItem : PropTypes.func, - onClickNext : PropTypes.func, - onClickPrev : PropTypes.func, - prevIsDisabled : PropTypes.bool, - type : PropTypes.oneOf( [ 'day', 'month' ] ), - year : PropTypes.string, - }; + return ( +
+ - static defaultProps = { - className : undefined, - cssMap : undefined, - hasTimeInput : true, - headers : undefined, - hourIsDisabled : false, - hourIsReadOnly : false, - hourPlaceholder : undefined, - hourValue : undefined, - isDisabled : false, - isReadOnly : false, - items : undefined, - label : undefined, - minuteIsDisabled : false, - minuteIsReadOnly : false, - minutePlaceholder : undefined, - minuteValue : undefined, - month : undefined, - nextIsDisabled : false, - onChangeHour : undefined, - onChangeMinute : undefined, - onClickItem : undefined, - onClickNext : undefined, - onClickPrev : undefined, - prevIsDisabled : false, - type : 'day', - year : undefined, - }; + { items && + + { headers && + + + { headers.map( ( header, i ) => + ) } + + + } + + { items.map( ( item, i ) => + + { item.map( ( item, j ) => + ) } + ) } + +
+ + { header.label } + +
+ { item.value && + + } +
+ } +
+ ); +}; - static displayName = 'DatePicker'; +DatePicker.propTypes = { + className : PropTypes.string, + cssMap : PropTypes.objectOf( PropTypes.string ), + headers : PropTypes.arrayOf( PropTypes + .objectOf( PropTypes.string ) ), + hourIsDisabled : PropTypes.bool, + hourIsReadOnly : PropTypes.bool, + hourPlaceholder : PropTypes.string, + hourValue : PropTypes.string, + isDisabled : PropTypes.bool, + isReadOnly : PropTypes.bool, + items : PropTypes.arrayOf( PropTypes + .arrayOf( PropTypes.object ) ), + hasTimeInput : PropTypes.bool, + label : PropTypes.string, + minuteIsDisabled : PropTypes.bool, + minuteIsReadOnly : PropTypes.bool, + minutePlaceholder : PropTypes.string, + minuteValue : PropTypes.string, + month : PropTypes.string, + nextIsDisabled : PropTypes.bool, + onChangeHour : PropTypes.func, + onChangeMinute : PropTypes.func, + onClickItem : PropTypes.func, + onClickNext : PropTypes.func, + onClickPrev : PropTypes.func, + prevIsDisabled : PropTypes.bool, + type : PropTypes.oneOf( [ 'day', 'month' ] ), + year : PropTypes.string, +}; - render() - { - const { - cssMap = createCssMap( this.context.DatePicker, this.props ), - hasTimeInput, - headers, - hourIsDisabled, - hourIsReadOnly, - hourPlaceholder, - hourValue, - isDisabled, - isReadOnly, - items, - label, - minuteIsDisabled, - minuteIsReadOnly, - minutePlaceholder, - minuteValue, - month, - nextIsDisabled, - onChangeHour, - onChangeMinute, - onClickItem, - onClickNext, - onClickPrev, - prevIsDisabled, - type, - year, - } = this.props; +DatePicker.defaultProps = { + className : undefined, + cssMap : undefined, + hasTimeInput : true, + headers : undefined, + hourIsDisabled : false, + hourIsReadOnly : false, + hourPlaceholder : undefined, + hourValue : undefined, + isDisabled : false, + isReadOnly : false, + items : undefined, + label : undefined, + minuteIsDisabled : false, + minuteIsReadOnly : false, + minutePlaceholder : undefined, + minuteValue : undefined, + month : undefined, + nextIsDisabled : false, + onChangeHour : undefined, + onChangeMinute : undefined, + onClickItem : undefined, + onClickNext : undefined, + onClickPrev : undefined, + prevIsDisabled : false, + type : 'day', + year : undefined, +}; - return ( -
- +DatePicker.displayName = componentName; - { items && - - { headers && - - - { headers.map( ( header, i ) => - ) } - - - } - - { items.map( ( item, i ) => - - { item.map( ( item, j ) => - ) } - ) } - -
- - { header.label } - -
- { item.value && - - } -
- } -
- ); - } -} +export default DatePicker; diff --git a/src/DatePicker/tests.jsx b/src/DatePicker/tests.jsx deleted file mode 100644 index 6a37bb23..00000000 --- a/src/DatePicker/tests.jsx +++ /dev/null @@ -1,841 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers, max-len */ - -import React from 'react'; -import { mount } from 'enzyme'; - -import { DatePicker } from '..'; - -describe( 'DatePickerDriver', () => -{ - let wrapper; - let driver; - - beforeEach( () => - { - wrapper = mount( ); - driver = wrapper.driver(); - } ); - - - describe( 'clickItem()', () => - { - test( 'should trigger onClickItem callback prop once', () => - { - const onClickItem = jest.fn(); - wrapper.setProps( { - onClickItem, - headers : [ - { label: 'Mon', title: 'Monday' }, - { label: 'Tue', title: 'Tuesday' }, - { label: 'Wed', title: 'Wednesday' }, - { label: 'Thu', title: 'Thursday' }, - { label: 'Fri', title: 'Friday' }, - { label: 'Sat', title: 'Saturday' }, - { label: 'Sun', title: 'Sunday' }, - ], - items : [ - [ - { - label : '01', value : '1', isCurrent : false, isSelected : false, - }, - { - label : '02', value : '2', isCurrent : false, isSelected : false, - }, - { - label : '03', value : '3', isCurrent : false, isSelected : false, - }, - { - label : '04', value : '4', isCurrent : false, isSelected : false, - }, - { - label : '05', value : '5', isCurrent : true, isSelected : true, - }, - { - label : '06', value : '6', isCurrent : false, isSelected : false, - }, - { - label : '07', value : '7', isCurrent : false, isSelected : false, - }, - ], - ], - } ); - - driver.clickItem(); - expect( onClickItem ).toBeCalledTimes( 1 ); - } ); - } ); - - - describe( 'clickNext()', () => - { - test( 'should trigger onClickNext callback prop once', () => - { - const onClickNext = jest.fn(); - wrapper.setProps( { - onClickNext, - } ); - - driver.clickNext(); - expect( onClickNext ).toBeCalledTimes( 1 ); - } ); - - - describe( 'nextIsDisabled', () => - { - test( 'should throw the expected error when nextIsDisabled', () => - { - const expectedError = - 'Next cannot simulate click since it is disabled'; - wrapper.setProps( { nextIsDisabled: true } ); - - expect( () => driver.clickNext() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onClickNext callback prop when \ -nextIsDisabled', () => - { - const onClickNext = jest.fn(); - wrapper.setProps( { - onClickNext, - nextIsDisabled : true, - } ); - - try - { - driver.clickNext(); - } - catch ( error ) - { - expect( onClickNext ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'clickPrev()', () => - { - test( 'should trigger onClickPrev callback prop once', () => - { - const onClickPrev = jest.fn(); - wrapper.setProps( { - onClickPrev, - } ); - - driver.clickPrev(); - expect( onClickPrev ).toBeCalledTimes( 1 ); - } ); - - - describe( 'prevIsDisabled', () => - { - test( 'should throw the expected error when prevIsDisabled', () => - { - const expectedError = - 'Previous cannot simulate click since it is disabled'; - wrapper.setProps( { prevIsDisabled: true } ); - - expect( () => driver.clickPrev() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onClickPrev callback prop when \ -prevIsDisabled', () => - { - const onClickPrev = jest.fn(); - wrapper.setProps( { - onClickPrev, - prevIsDisabled : true, - } ); - - try - { - driver.clickPrev(); - } - catch ( error ) - { - expect( onClickPrev ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'blurHourInput()', () => - { - test( 'should trigger onBlur callback once', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { onBlur, mode: 'default' } ); - - driver.blurHourInput(); - expect( onBlur ).toBeCalledTimes( 1 ); - } ); - - - describe( 'mode is not ', () => - { - test( 'should throw an error if mode is not ', () => - { - const expectedError = - 'There’s no input because is not '; - wrapper.setProps( { mode: 'date' } ); - - expect( () => driver.blurHourInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onBlur callback prop if mode is not \ -', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { - onBlur, - mode : 'date', - } ); - - try - { - driver.blurHourInput(); - } - catch ( error ) - { - expect( onBlur ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isDisabled', () => - { - test( 'should throw the expected error when isDisabled', () => - { - const expectedError = - 'TimeInput cannot simulate blur since it is disabled'; - wrapper.setProps( { - isDisabled : true, - } ); - - expect( () => driver.blurHourInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onBlur callback prop when isDisabled', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { - onBlur, - isDisabled : true, - } ); - - try - { - driver.blurHourInput(); - } - catch ( error ) - { - expect( onBlur ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'focusHourInput()', () => - { - test( 'should trigger onFocus callback once', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { onFocus, mode: 'default' } ); - - driver.focusHourInput(); - expect( onFocus ).toBeCalledTimes( 1 ); - } ); - - - describe( 'mode is not ', () => - { - test( 'should throw an error if mode is not ', () => - { - const expectedError = - 'There’s no input because is not '; - wrapper.setProps( { mode: 'date' } ); - - expect( () => driver.focusHourInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onFocus callback prop if mode is not \ -', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { - onFocus, - mode : 'date', - } ); - - try - { - driver.focusHourInput(); - } - catch ( error ) - { - expect( onFocus ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isDisabled', () => - { - test( 'should throw the expected error when isDisabled', () => - { - const expectedError = - 'TimeInput cannot simulate focus since it is disabled'; - wrapper.setProps( { - isDisabled : true, - } ); - - expect( () => driver.focusHourInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onFocus callback prop when isDisabled', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { - onFocus, - isDisabled : true, - } ); - - try - { - driver.focusHourInput(); - } - catch ( error ) - { - expect( onFocus ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'changeHourInput()', () => - { - test( 'should trigger onChange callback once', () => - { - const onChange = jest.fn(); - wrapper.setProps( { onChange, mode: 'default' } ); - - driver.changeHourInput(); - expect( onChange ).toBeCalledTimes( 1 ); - } ); - - describe( 'mode is not ', () => - { - test( 'should throw an error if mode is not ', () => - { - const expectedError = - 'There’s no input because is not '; - wrapper.setProps( { mode: 'date' } ); - - expect( () => driver.changeHourInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onChange callback prop if mode is not \ -', () => - { - const onChange = jest.fn(); - wrapper.setProps( { - onChange, - mode : 'date', - } ); - - try - { - driver.changeHourInput(); - } - catch ( error ) - { - expect( onChange ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isDisabled', () => - { - test( 'should throw the expected error when isDisabled', () => - { - const expectedError = - 'Input cannot simulate change since it is disabled'; - wrapper.setProps( { - isDisabled : true, - } ); - - expect( () => driver.changeHourInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onChange callback prop when isDisabled', () => - { - const onChange = jest.fn(); - wrapper.setProps( { - onChange, - isDisabled : true, - } ); - - try - { - driver.changeHourInput(); - } - catch ( error ) - { - expect( onChange ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isReadOnly', () => - { - test( 'should throw the expected error when isReadOnly', () => - { - const expectedError = - 'Input cannot simulate change since it is read only'; - wrapper.setProps( { - isReadOnly : true, - } ); - - expect( () => driver.changeHourInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onChange callback prop when isReadOnly', () => - { - const onChange = jest.fn(); - wrapper.setProps( { - onChange, - isReadOnly : true, - } ); - - try - { - driver.changeHourInput(); - } - catch ( error ) - { - expect( onChange ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'keyPressHourInput()', () => - { - test( 'should trigger onKeyPress callback once', () => - { - const onKeyPress = jest.fn(); - wrapper.setProps( { onKeyPress, mode: 'default' } ); - - driver.keyPressHourInput(); - expect( onKeyPress ).toBeCalledTimes( 1 ); - } ); - - - describe( 'mode is not ', () => - { - test( 'should throw an error if mode is not ', () => - { - const expectedError = - 'There’s no input because is not '; - wrapper.setProps( { mode: 'date' } ); - - expect( () => driver.keyPressHourInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onKeyPress callback prop if mode is not \ -', () => - { - const onKeyPress = jest.fn(); - wrapper.setProps( { - onKeyPress, - mode : 'date', - } ); - - try - { - driver.keyPressHourInput(); - } - catch ( error ) - { - expect( onKeyPress ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isDisabled', () => - { - test( 'should throw the expected error when isDisabled', () => - { - const expectedError = - 'TimeInput cannot simulate keyPress since it is disabled'; - wrapper.setProps( { - isDisabled : true, - } ); - - expect( () => driver.keyPressHourInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onKeyPress callback prop when isDisabled', () => - { - const onKeyPress = jest.fn(); - wrapper.setProps( { - onKeyPress, - isDisabled : true, - } ); - - try - { - driver.keyPressHourInput(); - } - catch ( error ) - { - expect( onKeyPress ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'blurMinuteInput()', () => - { - test( 'should trigger onBlur callback once', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { onBlur, mode: 'default' } ); - - driver.blurMinuteInput(); - expect( onBlur ).toBeCalledTimes( 1 ); - } ); - - - describe( 'mode is not ', () => - { - test( 'should throw an error if mode is not ', () => - { - const expectedError = - 'There’s no input because is not '; - wrapper.setProps( { mode: 'month' } ); - - expect( () => driver.blurMinuteInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onBlur callback prop if mode is not \ -', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { onBlur, mode: 'month' } ); - - try - { - driver.blurMinuteInput(); - } - catch ( error ) - { - expect( onBlur ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isDisabled', () => - { - test( 'should throw the expected error when isDisabled', () => - { - const expectedError = - 'TimeInput cannot simulate blur since it is disabled'; - wrapper.setProps( { - isDisabled : true, - } ); - - expect( () => driver.blurMinuteInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onBlur callback prop when isDisabled', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { onBlur, isDisabled: true } ); - - try - { - driver.blurMinuteInput(); - } - catch ( error ) - { - expect( onBlur ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'focusMinuteInput()', () => - { - test( 'should trigger onFocus callback once', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { onFocus, mode: 'default' } ); - - driver.focusMinuteInput(); - expect( onFocus ).toBeCalledTimes( 1 ); - } ); - - - describe( 'mode is not ', () => - { - test( 'should throw an error if mode is not ', () => - { - const expectedError = - 'There’s no input because is not '; - wrapper.setProps( { mode: 'month' } ); - - expect( () => driver.focusMinuteInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onFocus callback prop when is not \ -', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { onFocus, mode: 'month' } ); - - try - { - driver.focusMinuteInput(); - } - catch ( error ) - { - expect( onFocus ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isDisabled', () => - { - test( 'should throw the expected error when isDisabled', () => - { - const expectedError = - 'TimeInput cannot simulate focus since it is disabled'; - wrapper.setProps( { - isDisabled : true, - } ); - - expect( () => driver.focusMinuteInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onFocus callback prop when isDisabled', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { onFocus, isDisabled: true } ); - - try - { - driver.focusMinuteInput(); - } - catch ( error ) - { - expect( onFocus ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'changeMinuteInput()', () => - { - test( 'should trigger onChange callback once', () => - { - const onChange = jest.fn(); - wrapper.setProps( { onChange, mode: 'default' } ); - - driver.changeMinuteInput(); - expect( onChange ).toBeCalledTimes( 1 ); - } ); - - - describe( 'mode is not ', () => - { - test( 'should throw an error if mode is not ', () => - { - const expectedError = - 'There’s no input because is not '; - wrapper.setProps( { mode: 'date' } ); - - expect( () => driver.changeMinuteInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onChange callback prop when is \ -not ', () => - { - const onChange = jest.fn(); - wrapper.setProps( { onChange, mode: 'date' } ); - - try - { - driver.changeMinuteInput(); - } - catch ( error ) - { - expect( onChange ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isDisabled', () => - { - test( 'should throw the expected error when isDisabled', () => - { - const expectedError = - 'TimeInput cannot simulate change since it is disabled'; - wrapper.setProps( { - isDisabled : true, - } ); - - expect( () => driver.changeMinuteInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onChange callback prop when isDisabled', () => - { - const onChange = jest.fn(); - wrapper.setProps( { - onChange, - isDisabled : true, - } ); - - try - { - driver.changeMinuteInput(); - } - catch ( error ) - { - expect( onChange ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isReadOnly', () => - { - test( 'should throw the expected error when isReadOnly', () => - { - const expectedError = - 'TimeInput cannot simulate change since it is read only'; - wrapper.setProps( { - isReadOnly : true, - } ); - - expect( () => driver.changeMinuteInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onChange callback prop when isReadOnly', () => - { - const onChange = jest.fn(); - wrapper.setProps( { - onChange, - isReadOnly : true, - } ); - - try - { - driver.changeMinuteInput(); - } - catch ( error ) - { - expect( onChange ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'keyPressMinuteInput()', () => - { - test( 'should trigger onKeyPress callback once', () => - { - const onKeyPress = jest.fn(); - wrapper.setProps( { onKeyPress, mode: 'default' } ); - - driver.keyPressMinuteInput(); - expect( onKeyPress ).toBeCalledTimes( 1 ); - } ); - - - describe( 'mode is not ', () => - { - test( 'should throw an error if mode is not ', () => - { - const expectedError = - 'There’s no input because is not '; - wrapper.setProps( { mode: 'date' } ); - - expect( () => driver.keyPressMinuteInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onKeyPress callback prop when is \ -not ', () => - { - const onKeyPress = jest.fn(); - wrapper.setProps( { onKeyPress, mode: 'date' } ); - - try - { - driver.keyPressMinuteInput(); - } - catch ( error ) - { - expect( onKeyPress ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isDisabled', () => - { - test( 'should throw the expected error when isDisabled', () => - { - const expectedError = - 'TimeInput cannot simulate keyPress since it is disabled'; - wrapper.setProps( { - isDisabled : true, - } ); - - expect( () => driver.keyPressMinuteInput() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onKeyPress callback prop when isDisabled', () => - { - const onKeyPress = jest.fn(); - wrapper.setProps( { - onKeyPress, - isDisabled : true, - } ); - - try - { - driver.keyPressMinuteInput(); - } - catch ( error ) - { - expect( onKeyPress ).not.toBeCalled(); - } - } ); - } ); - } ); -} ); diff --git a/src/DateTimeInput/driver.js b/src/DateTimeInput/driver.js deleted file mode 100644 index 4c4baf52..00000000 --- a/src/DateTimeInput/driver.js +++ /dev/null @@ -1,233 +0,0 @@ -// /* -// * Copyright (c) 2018 dunnhumby Germany GmbH. -// * All rights reserved. -// * -// * This source code is licensed under the MIT license found in the LICENSE file -// * in the root directory of this source tree. -// * -// */ -// -// import { DatePicker, IconButton, TextInput } from 'nessie-ui'; -// -// import { createCssMap } from '../Theming'; -// -// -// const ERR = { -// INPUT_ERR : ( event, state ) => -// `Main input cannot simulate ${event} since it is ${state}`, -// }; -// -// -// export default class DateTimeInputDriver -// { -// constructor( wrapper ) -// { -// this.wrapper = wrapper; -// } -// -// get instance() -// { -// return this.wrapper.instance(); -// } -// -// get cssMap() -// { -// const { instance } = this; -// return instance.props.cssMap || -// createCssMap( instance.context.DateTimeInput, instance.props ); -// } -// -// get mainInput() -// { -// return this.wrapper.find( TextInput ); -// } -// -// get calendar() -// { -// return this.wrapper.find( DatePicker ); -// } -// -// get icon() -// { -// return this.wrapper.find( IconButton ).findWhere( node => -// node.props().iconType === 'calendar' ); -// } -// -// get prev() -// { -// return this.wrapper.find( `.${this.cssMap.prev}` ); -// } -// -// get next() -// { -// return this.wrapper.find( `.${this.cssMap.next}` ); -// } -// -// -// blurMainInput() -// { -// if ( this.wrapper.props().isDisabled ) -// { -// throw new Error( ERR.INPUT_ERR( 'blur', 'disabled' ) ); -// } -// -// this.mainInput.simulate( 'blur' ); -// return this; -// } -// -// focusMainInput() -// { -// if ( this.wrapper.props().isDisabled ) -// { -// throw new Error( ERR.INPUT_ERR( 'focus', 'disabled' ) ); -// } -// -// this.mainInput.simulate( 'focus' ); -// return this; -// } -// -// blurHourInput() -// { -// this.calendar.driver().blurHourInput(); -// return this; -// } -// -// focusHourInput() -// { -// this.calendar.driver().focusHourInput(); -// return this; -// } -// -// blurMinuteInput() -// { -// this.calendar.driver().blurMinuteInput(); -// return this; -// } -// -// focusMinuteInput() -// { -// this.calendar.driver().focusMinuteInput(); -// return this; -// } -// -// clickCellByIndex( index = 0 ) -// { -// this.wrapper.find( DatePicker ).driver().clickItem( index ); -// return this; -// } -// -// clickCellByValue( value ) -// { -// const day = this.wrapper.find( DatePicker ) -// .findWhere( n => n.prop( 'value' ) === value ).first(); -// -// day.simulate( 'click' ); -// return this; -// } -// -// clickPrev() -// { -// this.calendar.driver().clickPrev(); -// return this; -// } -// -// clickNext() -// { -// this.calendar.driver().clickNext(); -// return this; -// } -// -// clickIcon() -// { -// this.icon.driver().click(); -// return this; -// } -// -// changeMainInput( val ) -// { -// if ( this.wrapper.props().isDisabled ) -// { -// throw new Error( ERR.INPUT_ERR( 'change', 'disabled' ) ); -// } -// -// if ( this.wrapper.props().isReadOnly ) -// { -// throw new Error( ERR.INPUT_ERR( 'change', 'read only' ) ); -// } -// -// const node = this.mainInput.instance(); -// node.value = val; -// -// this.mainInput.simulate( 'change' ); -// return this; -// } -// -// changeHourInput( val ) -// { -// this.calendar.driver().changeHourInput( val ); -// return this; -// } -// -// changeMinuteInput( val ) -// { -// this.calendar.driver().changeMinuteInput( val ); -// return this; -// } -// -// keyDownMainInput( keyCode ) -// { -// if ( this.wrapper.props().isDisabled ) -// { -// throw new Error( ERR.INPUT_ERR( 'keyDown', 'disabled' ) ); -// } -// -// this.mainInput.simulate( 'keyDown', { keyCode, which: keyCode } ); -// return this; -// } -// -// keyUpMainInput( keyCode ) -// { -// if ( this.wrapper.props().isDisabled ) -// { -// throw new Error( ERR.INPUT_ERR( 'keyUp', 'disabled' ) ); -// } -// -// this.mainInput.simulate( 'keyUp', { keyCode, which: keyCode } ); -// return this; -// } -// -// keyPressMainInput( keyCode ) -// { -// if ( this.wrapper.props().isDisabled ) -// { -// throw new Error( ERR.INPUT_ERR( 'keyPress', 'disabled' ) ); -// } -// -// this.mainInput.simulate( 'keyPress', { keyCode, which: keyCode } ); -// return this; -// } -// -// keyPressHourInput( keyCode ) -// { -// this.calendar.driver().keyPressHourInput( keyCode ); -// return this; -// } -// -// keyPressMinuteInput( keyCode ) -// { -// this.calendar.driver().keyPressMinuteInput( keyCode ); -// return this; -// } -// -// mouseOver() -// { -// this.wrapper.simulate( 'mouseOver' ); -// return this; -// } -// -// mouseOut() -// { -// this.wrapper.simulate( 'mouseOut' ); -// return this; -// } -// } diff --git a/src/DateTimeInput/index.jsx b/src/DateTimeInput/index.jsx index 27519007..26f9ce15 100644 --- a/src/DateTimeInput/index.jsx +++ b/src/DateTimeInput/index.jsx @@ -7,19 +7,26 @@ * */ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import moment from 'moment'; -import _ from 'lodash'; +import React, { + useCallback, + useImperativeHandle, + useRef, + useState, +} from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; +import _ from 'lodash'; -import { generateId } from '../utils'; -import copy from './copy.json'; +import copy from './copy.json'; -import { DatePicker } from '..'; +import { DatePicker } from '..'; -import TextInputWithIcon from '../TextInputWithIcon'; -import Popup from '../Popup'; -import PopperWrapper from '../PopperWrapper'; +import TextInputWithIcon from '../TextInputWithIcon'; +import Popup from '../Popup'; +import PopperWrapper from '../PopperWrapper'; + + +const componentName = 'DateTimeInput'; const DISPLAY_FORMATTING = { month : 'YYYY/MM', @@ -151,221 +158,256 @@ function setPrecision( mode ) return DISPLAY_FORMATTING[ format ]; } -export default class DateTimeInput extends Component + +const useTimestamp = ( defaultValue, value ) => { - static propTypes = - { - /** - * Extra CSS class name - */ - className : PropTypes.string, - /** - * id of the DOM element used as container for popup datepicker - */ - container : PropTypes.string, - /** - * Date time format - */ - format : PropTypes.string, - /** - * Display as error/invalid - */ - hasError : PropTypes.bool, - /** - * Hour input placeholder text - */ - hourPlaceholder : PropTypes.string, - /** - * Component id - */ - id : PropTypes.string, - /** - * Main input placeholder text - */ - inputPlaceholder : PropTypes.string, - /** - * Display as disabled - */ - isDisabled : PropTypes.bool, - /** - * Display as read-only - */ - isReadOnly : PropTypes.bool, - /** - * Maximum timestamp selectable - */ - max : PropTypes.number, - /** - * Minimum timestamp selectable - */ - min : PropTypes.number, - /** - * Minute input placeholder text - */ - minutePlaceholder : PropTypes.string, - /** - * Picker mode - */ - mode : PropTypes.oneOf( [ 'default', 'date', 'month' ] ), - /** - * Change callback: ( { value } ) => ... - */ - onChange : PropTypes.func, - /** - * Selected timestamp - */ - value : PropTypes.number, - }; + const [ timestamp, setTimestamp ] = useState( defaultValue ); - static defaultProps = + const setter = ( newValue ) => { - className : undefined, - container : undefined, - format : undefined, - hasError : false, - hourPlaceholder : undefined, - id : undefined, - inputPlaceholder : undefined, - isDisabled : false, - isReadOnly : false, - max : undefined, - min : undefined, - minutePlaceholder : undefined, - mode : 'default', - onChange : undefined, - value : undefined, + if ( value === undefined ) + { + setTimestamp( newValue ); + } }; - static displayName = 'DateTimeInput'; + return [ value || timestamp, setter ]; +}; - wrapperRef = React.createRef(); - constructor() - { - super(); - - this.state = { - editingHourInputValue : undefined, - editingMainInputValue : undefined, - editingMinuteInputValue : undefined, - editingTimestamp : undefined, - gridStartTimestamp : undefined, - id : undefined, - isOpen : undefined, - timestamp : undefined, - }; - - this.handleChangeHour = this.handleChangeHour.bind( this ); - this.handleChangeInput = this.handleChangeInput.bind( this ); - this.handleChangeMinute = this.handleChangeMinute.bind( this ); - this.handleClickCell = this.handleClickCell.bind( this ); - this.handleClickIcon = this.handleClickIcon.bind( this ); - this.handleClickNext = this.handleClickNext.bind( this ); - this.handleClickOutSide = this.handleClickOutSide.bind( this ); - this.handleClickPrev = this.handleClickPrev.bind( this ); - this.purgeEdits = this.purgeEdits.bind( this ); - } +const DateTimeInput = React.forwardRef( ( props, ref ) => +{ + const inputRef = useRef(); + + useImperativeHandle( ref, () => ( { + focus : () => inputRef.current.focus(), + } ) ); - static getDerivedStateFromProps( props, state ) + const [ editingHourInputValue, setEditingHourInputValue ] = + useState( undefined ); + const [ editingMainInputValue, setEditingMainInputValue ] = + useState( undefined ); + const [ editingMinuteInputValue, setEditingMinuteInputValue ] = + useState( undefined ); + const [ gridStartTimestamp, setGridStartTimestamp ] = useState( undefined ); + const [ timestamp, setTimestamp ] = useTimestamp( undefined, props.value ); + + const isOpen = Boolean( gridStartTimestamp ); + + + const handleClickCell = useCallback( ( { value } ) => { - let timestamp; + const { isReadOnly } = props; - if ( props.value ) + if ( !isReadOnly ) { - timestamp = props.value; + setTimestamp( value ); + const { onChange } = props; + + if ( typeof onChange === 'function' ) + { + onChange( { id, value } ); + } } - else if ( props.value === null ) + + purgeEdits(); + }, [ props.id, props.isReadOnly, props.onChange, timestamp ] ); + + + const handleClickNext = useCallback( () => + { + if ( !canGotoNext() ) return; + + setGridStartTimestamp( $m( gridStartTimestamp ) + .add( 1, props.mode === 'month' ? 'year' : 'month' ) + .valueOf() ); + }, [ gridStartTimestamp, props.mode ] ); + + + const handleClickPrev = useCallback( () => + { + if ( !canGotoPrev() ) return; + + setGridStartTimestamp( $m( gridStartTimestamp ) + .add( -1, props.mode === 'month' ? 'year' : 'month' ) + .valueOf() ); + }, [ gridStartTimestamp, props.mode ] ); + + + const handleClickIcon = useCallback( () => + { + if ( isOpen ) { - timestamp = undefined; + close(); } else { - timestamp = state.editingTimestamp || state.timestamp; + open(); } + }, [ inputRef.current, isOpen ] ); - return { - id : props.id || state.id || generateId( 'DateTimeInput' ), - isOpen : Boolean( state.gridStartTimestamp ), + + const handleChangeInput = useCallback( ( { value } ) => + { + const trimmed = value.replace( /\s+/g, ' ' ); + const min = props.min || now(); + + let newTimestamp = tryParseInputValue( + trimmed, timestamp, - }; - } + props.format, + ); + + if ( newTimestamp < min ) + { + newTimestamp = min; + } + + if ( props.max && newTimestamp > props.max ) + { + newTimestamp = props.max; + } + + setEditingHourInputValue( !value ? undefined : formatHours( value ) ); + setEditingMainInputValue( !value ? undefined : value ); + setEditingMinuteInputValue( !value ? undefined : + formatMinutes( value ) ); + setTimestamp( !value ? undefined : newTimestamp ); + }, [ props.format, props.max, props.min, timestamp ] ); + - handleClickNext() + const handleChangeHour = useCallback( ( { value } ) => { - if ( !this.canGotoNext() ) return; + const trimmed = value.trim().replace( /\s+/g, ' ' ); + let digits = Number( trimmed ); - this.setState( prevState => ( { - gridStartTimestamp : $m( prevState.gridStartTimestamp ) - .add( 1, this.props.mode === 'month' ? 'year' : 'month' ) - .valueOf(), - } ) ); - } + setEditingHourInputValue( value ); + + if ( /^\d\d?$/.test( trimmed ) && digits >= 0 && digits <= 23 ) + { + const newTimestamp = $m( timestamp ).set( 'hour', digits ) + .valueOf(); + + setTimestamp( newTimestamp ); + setEditingMainInputValue( formatDateTime( + newTimestamp, + props.format || setPrecision( props.mode ), + ) ); + } + else + { + digits = _.isNumber( timestamp ) && $m( timestamp ).hour(); - handleClickPrev() + if ( !_.isNaN( digits ) ) + { + const newTimestamp = $m( timestamp ).set( 'hour', digits ) + .valueOf(); + + setTimestamp( newTimestamp ); + setEditingMainInputValue( formatDateTime( + newTimestamp, + props.format || setPrecision( props.mode ), + ) ); + } + } + }, [ props.format, props.mode, timestamp ] ); + + + const handleChangeMinute = useCallback( ( { value } ) => { - if ( !this.canGotoPrev() ) return; + const trimmed = value.trim().replace( /\s+/g, ' ' ); + let digits = Number( trimmed ); - this.setState( prevState => ( { - gridStartTimestamp : $m( prevState.gridStartTimestamp ) - .add( -1, this.props.mode === 'month' ? 'year' : 'month' ) - .valueOf(), - } ) ); - } + setEditingMinuteInputValue( value ); + + if ( /^\d\d?$/.test( trimmed ) && digits >= 0 && digits <= 59 ) + { + const newTimestamp = $m( timestamp ).set( 'minute', digits ) + .valueOf(); + + setTimestamp( newTimestamp ); + setEditingMainInputValue( formatDateTime( + newTimestamp, + props.format || setPrecision( props.mode ), + ) ); + } + else + { + digits = _.isNumber( timestamp ) && $m( timestamp ).minute(); + + if ( !_.isNaN( digits ) ) + { + const newTimestamp = $m( timestamp ).set( 'minute', digits ) + .valueOf(); + + setTimestamp( newTimestamp ); + setEditingMainInputValue( formatDateTime( + newTimestamp, + props.format || setPrecision( props.mode ), + ) ); + } + } + }, [ props.format, props.mode, timestamp ] ); - handleClickOutSide() + const handleOnBlur = useCallback( () => { - this.close(); - } + if ( !gridStartTimestamp ) + { + purgeEdits(); + } + }, [] ); - canGotoNext() + const canGotoNext = useCallback( () => { - const { max } = this.props; - const nextGridStart = $m( this.state.gridStartTimestamp ) - .add( 1, this.props.mode === 'month' ? 'year' : 'month' ).valueOf(); + const { max } = props; + const nextGridStart = $m( gridStartTimestamp ) + .add( 1, props.mode === 'month' ? 'year' : 'month' ).valueOf(); + + return !_.isNumber( max ) || ( nextGridStart <= max ); + }, [ gridStartTimestamp, props.mode, props.max ] ); - return !_.isNumber( max ) || - ( nextGridStart <= max ); - } - canGotoPrev() + const canGotoPrev = useCallback( () => { - const min = this.props.min || now(); - const prevGridStart = $m( this.state.gridStartTimestamp ) - .add( -1, this.props.mode === 'month' ? 'year' : 'month' ) + const min = props.min || now(); + const prevGridStart = $m( gridStartTimestamp ) + .add( -1, props.mode === 'month' ? 'year' : 'month' ) .valueOf(); const endOfPrev = $m( prevGridStart ) - .add( 1, this.props.mode === 'month' ? 'year' : 'month' ) + .add( 1, props.mode === 'month' ? 'year' : 'month' ) .valueOf(); - return !_.isNumber( min ) || - endOfPrev > min; - } + return !_.isNumber( min ) || endOfPrev > min; + }, [ gridStartTimestamp, props.mode, props.min ] ); - canEditHourOrMinute() - { - return _.isNumber( this.state.timestamp ); - } - isUnitSelectable( timestamp, unit, allowFraction ) + const canEditHourOrMinute = useCallback( () => + _.isNumber( timestamp ), [ timestamp ] ); + + + const isUnitSelectable = useCallback( ( + newTimestamp = timestamp, + unit, + allowFraction, + ) => { - const { max } = this.props; - const min = this.props.min || now(); + const { max } = props; + const min = props.min || now(); - if ( timestamp > max ) return false; + if ( newTimestamp > max ) return false; - if ( !allowFraction ) return timestamp >= min; + if ( !allowFraction ) return newTimestamp >= min; + + return $m( newTimestamp ).add( 1, unit ) > min; + }, [ timestamp, props.max, props.min ] ); - return $m( timestamp ).add( 1, unit ) > min; - } - dayMatrix() + const dayMatrix = () => { - const startMonth = this.state.gridStartTimestamp; + const startMonth = gridStartTimestamp; if ( !startMonth ) return; - const { timestamp } = this.state; - const offset = ( $m( startMonth ).weekday() + 6 ) % 7; const daysInMonth = $m( startMonth ).daysInMonth(); @@ -376,10 +418,10 @@ export default class DateTimeInput extends Component const value = hasDate ? $m( startMonth ).add( dayIndex, 'day' ).valueOf() : null; - const isDisabled = hasDate && !this.isUnitSelectable( + const isDisabled = hasDate && !isUnitSelectable( value, 'day', - this.props.mode === 'default', + props.mode === 'default', ); const isCurrent = hasDate && @@ -396,22 +438,21 @@ export default class DateTimeInput extends Component } ); return _.chunk( days, 7 ); - } + }; - monthMatrix() + + const monthMatrix = () => { - const startYear = this.state.gridStartTimestamp; + const startYear = gridStartTimestamp; if ( !startYear ) return; - const { timestamp } = this.state; - const months = _.range( 0, 12 ).map( month => { const label = copy.shortMonths[ month ]; const value = $m( startYear ).add( month, 'month' ).valueOf(); - const isDisabled = !this.isUnitSelectable( value, 'month' ); + const isDisabled = !isUnitSelectable( value, 'month' ); const isCurrent = isTimestampEqual( value, now(), 'month' ); const isSelected = _.isNumber( timestamp ) && @@ -426,310 +467,213 @@ export default class DateTimeInput extends Component } ); return _.chunk( months, 4 ); - } - - monthLabel() - { - const month = $m( this.state.gridStartTimestamp ).month(); - return copy.months[ month ]; - } + }; - yearLabel() - { - return $m( this.state.gridStartTimestamp ).year().toString(); - } - handleClickCell( { value } ) - { - const { isReadOnly } = this.props; + const monthLabel = copy.months[ $m( gridStartTimestamp ).month() ]; - if ( !isReadOnly ) - { - this.setState( { timestamp: value } ); - const { onChange } = this.props; - const { id } = this.state; - if ( typeof onChange === 'function' ) - { - onChange( { id, value } ); - } - } + const yearLabel = () => $m( gridStartTimestamp ).year().toString(); - this.purgeEdits(); - } - purgeEdits() + const purgeEdits = useCallback( () => { - this.setState( { - editingHourInputValue : undefined, - editingMainInputValue : undefined, - editingMinuteInputValue : undefined, - editingTimestamp : undefined, - } ); - } + setEditingHourInputValue( undefined ); + setEditingMainInputValue( undefined ); + setEditingMinuteInputValue( undefined ); + }, [] ); - handleClickIcon() - { - if ( this.state.isOpen ) - { - this.close(); - } - else - { - this.open(); - } - } - handleChangeInput( { value } ) + const open = useCallback( () => { - const trimmed = value.replace( /\s+/g, ' ' ); - const min = this.props.min || now(); + const { min } = props; + let newTimestamp; - this.setState( prevState => - { - let timestamp = tryParseInputValue( - trimmed, - prevState.timestamp, - this.props.format, - ); + newTimestamp = _.isNumber( timestamp ) ? timestamp : now(); - if ( timestamp < min ) - { - timestamp = min; - } + newTimestamp = ( _.isNumber( min ) && min > timestamp ) ? + min : timestamp; - if ( this.props.max && - timestamp > this.props.max ) - { - timestamp = this.props.max; - } + setGridStartTimestamp( $m( newTimestamp ) + .startOf( props.mode === 'month' ? 'year' : 'month' ) + .valueOf() ); + }, [ props.min, props.mode, timestamp ] ); - return { - editingHourInputValue : !value ? undefined : - formatHours( value ), - editingMainInputValue : !value ? undefined : value, - editingMinuteInputValue : !value ? undefined : - formatMinutes( value ), - editingTimestamp : !value ? undefined : timestamp, - timestamp : !value ? undefined : timestamp, - }; - } ); - } - handleChangeHour( { value } ) + const close = useCallback( () => { - const trimmed = value.trim().replace( /\s+/g, ' ' ); - - let digits = Number( trimmed ); - - this.setState( { editingHourInputValue: value } ); - - if ( /^\d\d?$/.test( trimmed ) && digits >= 0 && digits <= 23 ) - { - this.setState( prevState => - { - const timestamp = $m( prevState.timestamp ) - .set( 'hour', digits ).valueOf(); - - return { - editingTimestamp : timestamp, - editingMainInputValue : formatDateTime( - timestamp, - this.props.format || setPrecision( this.props.mode ), - ), - }; - } ); - } - else - { - digits = _.isNumber( this.state.timestamp ) && - $m( this.state.timestamp ).hour(); - - if ( !_.isNaN( digits ) ) - { - this.setState( prevState => - { - const timestamp = $m( prevState.timestamp ) - .set( 'hour', digits ).valueOf(); - - return { - editingTimestamp : timestamp, - editingMainInputValue : formatDateTime( - timestamp, - this.props.format || - setPrecision( this.props.mode ), - ), - }; - } ); + purgeEdits(); + setGridStartTimestamp( null ); + }, [] ); + + + const { + className, + container, + format, + hasError, + hourPlaceholder, + id, + inputPlaceholder, + isDisabled, + minutePlaceholder, + mode, + } = props; + + const datePicker = ( + = 0 && digits <= 59 ) - { - this.setState( prevState => - { - const timestamp = $m( prevState.timestamp ) - .set( 'minute', digits ).valueOf(); - - return { - editingTimestamp : timestamp, - editingMainInputValue : formatDateTime( - timestamp, - this.props.format || setPrecision( this.props.mode ), - ), - }; - } ); - } - else - { - digits = _.isNumber( this.state.timestamp ) && - $m( this.state.timestamp ).minute(); - - if ( !_.isNaN( digits ) ) - { - this.setState( prevState => - { - const timestamp = $m( prevState.timestamp ) - .set( 'minute', digits ).valueOf(); - - return { - editingTimestamp : timestamp, - editingMainInputValue : formatDateTime( - timestamp, - this.props.format || - setPrecision( this.props.mode ), - ), - }; - } ); + isDisabled = { isDisabled } + items = { mode === 'month' ? + monthMatrix() : dayMatrix() } - } - } - - open() - { - const { min } = this.props; - let { timestamp } = this.state; - - timestamp = _.isNumber( timestamp ) ? timestamp : now(); - - timestamp = ( _.isNumber( min ) && - min > timestamp ) ? min : timestamp; - - this.setState( { - gridStartTimestamp : $m( timestamp ) - .startOf( this.props.mode === 'month' ? 'year' : 'month' ) - .valueOf(), - timestamp, - } ); - } - - close() - { - this.purgeEdits(); - this.setState( { gridStartTimestamp: null } ); - } - - render() - { - const { - className, - container, - hasError, - hourPlaceholder, - id = generateId( 'DateTimeInput' ), - inputPlaceholder, - isDisabled, - minutePlaceholder, - mode, - } = this.props; - - const { - editingHourInputValue, - editingMainInputValue, - editingMinuteInputValue, - isOpen, - timestamp, - } = this.state; - - const datePicker = ( - - ); + minuteIsReadOnly = { !canEditHourOrMinute() } + minutePlaceholder = { minutePlaceholder } + minuteValue = { editingMinuteInputValue || + formatMinutes( timestamp ) + } + mode = { mode } + month = { mode !== 'month' && monthLabel } + nextIsDisabled = { !canGotoNext() } + onChangeHour = { handleChangeHour } + onChangeMinute = { handleChangeMinute } + onClickItem = { handleClickCell } + onClickNext = { handleClickNext } + onClickPrev = { handleClickPrev } + prevIsDisabled = { !canGotoPrev() } + type = { mode === 'month' ? 'month' : 'day' } + year = { yearLabel() } /> + ); + + const popperChildren = ( + + ); + + const popperPopup = ( + + { datePicker } + + ); + + return ( + + { popperChildren } + + ); +} ); + +DateTimeInput.propTypes = +{ + /** + * Extra CSS class name + */ + className : PropTypes.string, + /** + * id of the DOM element used as container for popup datepicker + */ + container : PropTypes.string, + /** + * Date time format + */ + format : PropTypes.string, + /** + * Display as error/invalid + */ + hasError : PropTypes.bool, + /** + * Hour input placeholder text + */ + hourPlaceholder : PropTypes.string, + /** + * Component id + */ + id : PropTypes.string, + /** + * Main input placeholder text + */ + inputPlaceholder : PropTypes.string, + /** + * Display as disabled + */ + isDisabled : PropTypes.bool, + /** + * Display as read-only + */ + isReadOnly : PropTypes.bool, + /** + * Maximum timestamp selectable + */ + max : PropTypes.number, + /** + * Minimum timestamp selectable + */ + min : PropTypes.number, + /** + * Minute input placeholder text + */ + minutePlaceholder : PropTypes.string, + /** + * Picker mode + */ + mode : PropTypes.oneOf( [ 'default', 'date', 'month' ] ), + /** + * Change callback: ( { value } ) => ... + */ + onChange : PropTypes.func, + /** + * Selected timestamp + */ + value : PropTypes.number, +}; - const popperChildren = ( - - ); +DateTimeInput.defaultProps = +{ + className : undefined, + container : undefined, + format : undefined, + hasError : false, + hourPlaceholder : undefined, + id : undefined, + inputPlaceholder : undefined, + isDisabled : false, + isReadOnly : false, + max : undefined, + min : undefined, + minutePlaceholder : undefined, + mode : 'default', + onChange : undefined, + value : undefined, +}; - const popperPopup = ( - - { datePicker } - - ); +DateTimeInput.displayName = componentName; - return ( - - { popperChildren } - - ); - } -} +export default DateTimeInput; diff --git a/src/DateTimeInput/tests.disabled.jsx b/src/DateTimeInput/tests.disabled.jsx deleted file mode 100644 index 0b60bba9..00000000 --- a/src/DateTimeInput/tests.disabled.jsx +++ /dev/null @@ -1,585 +0,0 @@ -// /* -// * Copyright (c) 2018 dunnhumby Germany GmbH. -// * All rights reserved. -// * -// * This source code is licensed under the MIT license found in the LICENSE file -// * in the root directory of this source tree. -// * -// */ -// -// /* eslint-disable no-magic-numbers */ -// -// import React from 'react'; -// import { mount } from 'enzyme'; -// -// import { DateTimeInput } from '..'; -// -// describe( 'DateTimeInputDriver', () => -// { -// let wrapper; -// let driver; -// -// beforeEach( () => -// { -// wrapper = mount( ); -// driver = wrapper.driver(); -// } ); -// -// -// describe( 'blurMainInput()', () => -// { -// test( 'should call onBlur once', () => -// { -// const onBlur = jest.fn(); -// wrapper.setProps( { onBlur } ); -// -// driver.blurMainInput(); -// expect( onBlur ).toBeCalledTimes( 1 ); -// } ); -// -// describe( 'isDisabled', () => -// { -// test( 'should throw the expected error when isDisabled', () => -// { -// const expectedError = -// 'Main input cannot simulate blur since it is disabled'; -// wrapper.setProps( { -// isDisabled : true, -// } ); -// -// expect( () => driver.blurMainInput() ).toThrow( expectedError ); -// } ); -// -// test( -// 'should not trigger onBlur callback prop when isDisabled', -// () => -// { -// const onBlur = jest.fn(); -// wrapper.setProps( { -// onBlur, -// isDisabled : true, -// } ); -// -// try -// { -// driver.blurMainInput(); -// } -// catch ( error ) -// { -// expect( onBlur ).not.toBeCalled(); -// } -// }, -// ); -// } ); -// } ); -// -// describe( 'focusMainInput()', () => -// { -// test( 'should call onFocus once', () => -// { -// const onFocus = jest.fn(); -// wrapper.setProps( { onFocus } ); -// -// driver.focusMainInput(); -// expect( onFocus ).toBeCalledTimes( 1 ); -// } ); -// -// describe( 'isDisabled', () => -// { -// test( 'should throw the expected error when isDisabled', () => -// { -// const expectedError = -// 'Main input cannot simulate focus since it is disabled'; -// wrapper.setProps( { -// isDisabled : true, -// } ); -// -// expect( () => driver.focusMainInput() ) -// .toThrow( expectedError ); -// } ); -// -// test( -// 'should not trigger onFocus callback prop when isDisabled', -// () => -// { -// const onFocus = jest.fn(); -// wrapper.setProps( { -// onFocus, -// isDisabled : true, -// } ); -// -// try -// { -// driver.focusMainInput(); -// } -// catch ( error ) -// { -// expect( onFocus ).not.toBeCalled(); -// } -// }, -// ); -// } ); -// } ); -// -// -// describe( 'blurHourInput()', () => -// { -// test( 'should call onBlur once', () => -// { -// const onBlur = jest.fn(); -// wrapper.setProps( { onBlur } ); -// -// driver.blurHourInput(); -// expect( onBlur ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// describe( 'focusHourInput()', () => -// { -// test( 'should call onFocus once', () => -// { -// const onFocus = jest.fn(); -// wrapper.setProps( { onFocus } ); -// -// driver.focusHourInput(); -// expect( onFocus ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'blurMinuteInput()', () => -// { -// test( 'should call onBlur once', () => -// { -// const onBlur = jest.fn(); -// wrapper.setProps( { onBlur } ); -// -// driver.blurMinuteInput(); -// expect( onBlur ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// describe( 'focusMinuteInput()', () => -// { -// test( 'should call onFocus once', () => -// { -// const onFocus = jest.fn(); -// wrapper.setProps( { onFocus } ); -// -// driver.focusMinuteInput(); -// expect( onFocus ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'clickCellByIndex()', () => -// { -// let onClickCell; -// -// beforeEach( () => -// { -// onClickCell = jest.fn(); -// wrapper.setProps( { -// weeks : [ [ { value: '1' }, { value: '2' }, { value: '3' } ] ], -// onClickCell, -// } ); -// } ); -// -// test( 'should fire onClickCell exactly once', () => -// { -// driver.clickCellByIndex( 1 ); -// expect( onClickCell ).toBeCalledTimes( 1 ); -// } ); -// -// test( 'should click on cell with given index', () => -// { -// driver.clickCellByIndex( 1 ); -// expect( onClickCell.mock.calls[ 0 ][ 0 ] ).toBe( '2' ); -// } ); -// } ); -// -// describe( 'clickCellByValue()', () => -// { -// let onClickCell; -// -// beforeEach( () => -// { -// onClickCell = jest.fn(); -// wrapper.setProps( { -// weeks : [ [ { value: '1' }, { value: '2' }, { value: '3' } ] ], -// onClickCell, -// } ); -// } ); -// -// test( 'should fire onClickCell exactly once', () => -// { -// driver.clickCellByValue( '1' ); -// expect( onClickCell ).toBeCalledTimes( 1 ); -// } ); -// -// test( 'should click on cell with given value', () => -// { -// driver.clickCellByValue( '3' ); -// expect( onClickCell.mock.calls[ 0 ][ 0 ] ).toBe( '3' ); -// } ); -// } ); -// -// -// describe( 'clickPrev()', () => -// { -// test( 'should fire onClickPrev exactly once', () => -// { -// const onClickPrev = jest.fn(); -// wrapper.setProps( { -// onClickPrev, -// } ); -// -// driver.clickPrev(); -// expect( onClickPrev ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'clickNext()', () => -// { -// test( 'should fire onClickNext exactly once', () => -// { -// const onClickNext = jest.fn(); -// wrapper.setProps( { -// onClickNext, -// } ); -// -// driver.clickNext(); -// expect( onClickNext ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'clickIcon()', () => -// { -// test( 'should fire onClickIcon exactly once', () => -// { -// const onClickIcon = jest.fn(); -// wrapper.setProps( { -// onClickIcon, -// } ); -// -// driver.clickIcon(); -// expect( onClickIcon ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'changeMainInput( val )', () => -// { -// test( 'should trigger onChange callback prop once', () => -// { -// const onChange = jest.fn(); -// wrapper.setProps( { -// onChange, -// } ); -// -// driver.changeMainInput(); -// expect( onChange ).toBeCalledTimes( 1 ); -// } ); -// -// -// describe( 'isDisabled', () => -// { -// test( 'should throw the expected error when isDisabled', () => -// { -// const expectedError = -// 'Main input cannot simulate change since it is disabled'; -// wrapper.setProps( { -// isDisabled : true, -// } ); -// -// expect( () => driver.changeMainInput() ) -// .toThrow( expectedError ); -// } ); -// -// test( -// 'should not trigger onChange callback prop when isDisabled', -// () => -// { -// const onChange = jest.fn(); -// wrapper.setProps( { -// onChange, -// isDisabled : true, -// } ); -// -// try -// { -// driver.changeMainInput(); -// } -// catch ( error ) -// { -// expect( onChange ).not.toBeCalled(); -// } -// }, -// ); -// } ); -// -// -// describe( 'isReadOnly', () => -// { -// test( 'should throw the expected error when isReadOnly', () => -// { -// const expectedError = -// 'Main input cannot simulate change since it is read only'; -// wrapper.setProps( { -// isReadOnly : true, -// } ); -// -// expect( () => driver.changeMainInput() ) -// .toThrow( expectedError ); -// } ); -// -// test( -// 'should not trigger onChange callback prop when isReadOnly', -// () => -// { -// const onChange = jest.fn(); -// wrapper.setProps( { -// onChange, -// isReadOnlyInput : true, -// } ); -// -// try -// { -// driver.changeMainInput(); -// } -// catch ( error ) -// { -// expect( onChange ).not.toBeCalled(); -// } -// }, -// ); -// } ); -// } ); -// -// -// describe( 'changeHourInput()', () => -// { -// test( 'should trigger onChange callback once', () => -// { -// const onChange = jest.fn(); -// wrapper.setProps( { onChange } ); -// -// driver.changeHourInput(); -// expect( onChange ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'changeMinuteInput()', () => -// { -// test( 'should trigger onChange callback once', () => -// { -// const onChange = jest.fn(); -// wrapper.setProps( { onChange } ); -// -// driver.changeMinuteInput(); -// expect( onChange ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'keyDownMainInput( keyCode )', () => -// { -// test( 'should call onKeyDown once', () => -// { -// const onKeyDown = jest.fn(); -// wrapper.setProps( { onKeyDown } ); -// -// driver.keyDownMainInput(); -// expect( onKeyDown ).toBeCalledTimes( 1 ); -// } ); -// -// describe( 'isDisabled', () => -// { -// test( 'should throw the expected error when isDisabled', () => -// { -// const expectedError = -// 'Main input cannot simulate keyDown since it is disabled'; -// wrapper.setProps( { -// isDisabled : true, -// } ); -// -// expect( () => driver.keyDownMainInput() ) -// .toThrow( expectedError ); -// } ); -// -// test( -// 'should not trigger onKeyDown callback prop when isDisabled', -// () => -// { -// const onKeyDown = jest.fn(); -// wrapper.setProps( { -// onKeyDown, -// isDisabled : true, -// } ); -// -// try -// { -// driver.keyDownMainInput(); -// } -// catch ( error ) -// { -// expect( onKeyDown ).not.toBeCalled(); -// } -// }, -// ); -// } ); -// } ); -// -// -// describe( 'keyUpMainInput( keyCode )', () => -// { -// test( 'should call onKeyUp once', () => -// { -// const onKeyUp = jest.fn(); -// wrapper.setProps( { onKeyUp } ); -// -// driver.keyUpMainInput(); -// expect( onKeyUp ).toBeCalledTimes( 1 ); -// } ); -// -// describe( 'isDisabled', () => -// { -// test( 'should throw the expected error when isDisabled', () => -// { -// const expectedError = -// 'Main input cannot simulate keyUp since it is disabled'; -// wrapper.setProps( { -// isDisabled : true, -// } ); -// -// expect( () => driver.keyUpMainInput() ) -// .toThrow( expectedError ); -// } ); -// -// test( -// 'should not trigger onKeyUp callback prop when isDisabled', -// () => -// { -// const onKeyUp = jest.fn(); -// wrapper.setProps( { -// onKeyUp, -// isDisabled : true, -// } ); -// -// try -// { -// driver.keyUpMainInput(); -// } -// catch ( error ) -// { -// expect( onKeyUp ).not.toBeCalled(); -// } -// }, -// ); -// } ); -// } ); -// -// -// describe( 'keyPressMainInput( keyCode )', () => -// { -// test( 'should call onKeyPress once', () => -// { -// const onKeyPress = jest.fn(); -// wrapper.setProps( { onKeyPress } ); -// -// driver.keyPressMainInput(); -// expect( onKeyPress ).toBeCalledTimes( 1 ); -// } ); -// -// describe( 'isDisabled', () => -// { -// test( 'should throw the expected error when isDisabled', () => -// { -// const expectedError = -// 'Main input cannot simulate keyPress since it is disabled'; -// wrapper.setProps( { -// isDisabled : true, -// } ); -// -// expect( () => driver.keyPressMainInput() ) -// .toThrow( expectedError ); -// } ); -// -// test( -// 'should not trigger onKeyPress callback prop when isDisabled', -// () => -// { -// const onKeyPress = jest.fn(); -// wrapper.setProps( { -// onKeyPress, -// isDisabled : true, -// } ); -// -// try -// { -// driver.keyPressMainInput(); -// } -// catch ( error ) -// { -// expect( onKeyPress ).not.toBeCalled(); -// } -// }, -// ); -// } ); -// } ); -// -// -// describe( 'keyPressHourInput()', () => -// { -// test( 'should trigger onKeyPress callback once', () => -// { -// const onKeyPress = jest.fn(); -// wrapper.setProps( { onKeyPress } ); -// -// driver.keyPressHourInput(); -// expect( onKeyPress ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'keyPressMinuteInput()', () => -// { -// test( 'should trigger onKeyPress callback once', () => -// { -// const onKeyPress = jest.fn(); -// wrapper.setProps( { onKeyPress } ); -// -// driver.keyPressMinuteInput(); -// expect( onKeyPress ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'mouseOver()', () => -// { -// test( 'should trigger onMouseOver callback once', () => -// { -// const onMouseOver = jest.fn(); -// wrapper.setProps( { onMouseOver } ); -// -// driver.mouseOver(); -// expect( onMouseOver ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'mouseOut()', () => -// { -// test( 'should trigger onMouseOut callback once', () => -// { -// const onMouseOut = jest.fn(); -// wrapper.setProps( { onMouseOut } ); -// -// driver.mouseOut(); -// expect( onMouseOut ).toBeCalledTimes( 1 ); -// } ); -// } ); -// } ); diff --git a/src/DefaultTheme.js b/src/DefaultTheme.js index 64462070..2ba86af3 100644 --- a/src/DefaultTheme.js +++ b/src/DefaultTheme.js @@ -40,7 +40,6 @@ import textInputClasses from './TextInput/textInput.css'; import textInputWithIconClasses from './TextInputWithIcon/textInputWithIcon.css'; import timeInputClasses from './DatePicker/timeInput.css'; import tooltipClasses from './Tooltip/tooltip.css'; -import withDropdownClasses from './Addons/withDropdown/withDropdown.css'; export default { @@ -49,7 +48,6 @@ export default { 'default', { disabled : props.isDisabled, - fakeHovered : props.forceHover, loading : props.isLoading && !props.isDisabled, }, `iconPosition__${props.iconPosition}`, @@ -75,7 +73,6 @@ export default { { disabled : props.isDisabled, error : !props.isDisabled && props.hasError, - fakeHovered : !props.isDisabled && props.forceHover, }, props.className, ), @@ -105,7 +102,6 @@ export default { 'default', { disabled : props.isDisabled, - fakeHovered : props.forceHover, selected : props.isSelected, }, `type__${props.type}`, @@ -149,7 +145,6 @@ export default { { background : props.hasBackground, disabled : props.isDisabled, - fakeHovered : props.forceHover, }, `role__${props.role}`, `size__${props.size}`, @@ -324,7 +319,6 @@ export default { TimeInput : props => ( { main : classNames.bind( timeInputClasses )( 'default', - { fakeHovered: props.forceHover }, props.className, ), ...timeInputClasses, @@ -339,13 +333,4 @@ export default { ), ...tooltipClasses, } ), - withDropdown : props => ( { - main : classNames.bind( withDropdownClasses )( - 'default', - { open: props.dropdownIsOpen }, - `position__${props.dropdownPosition}`, - props.className, - ), - ...withDropdownClasses, - } ), }; diff --git a/src/Grid/index.jsx b/src/Grid/index.jsx index 7e79280e..06c6a347 100644 --- a/src/Grid/index.jsx +++ b/src/Grid/index.jsx @@ -7,136 +7,123 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -import { attachEvents } from '../utils'; +import { attachEvents, useTheme } from '../utils'; +const componentName = 'Grid'; -export default class Grid extends React.Component +const Grid = props => { - static contextType = ThemeContext; + const { + autoCols, + autoRows, + children, + columns, + customColumns, + customRows, + rows, + } = props; - static propTypes = - { - /** - * Vertical alignment of the grid items - */ - align : PropTypes.oneOf( [ - 'start', - 'center', - 'end', - 'stretch', - ] ), - /** - * Defines the size of implicitly set columns - */ - autoCols : PropTypes.string, - /** - * Controls where to auto place new grid items if their place is - * undefined - */ - autoFlow : PropTypes.oneOf( [ 'row', 'col' ] ), - /** - * Defines the size of implicitly set rows - */ - autoRows : PropTypes.string, - /** - * Grid content (Columns) - */ - children : PropTypes.node, - /** - * CSS class name - */ - className : PropTypes.string, - /** - * Column gap - */ - columnGap : PropTypes.oneOf( [ 'none', 'S', 'M', 'L' ] ), - /** - * Number of columns - should be an integer > 0 - */ - columns : PropTypes.number, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Custom sizes of columns - */ - customColumns : PropTypes.string, - /** - * Custom sizes of rows - */ - customRows : PropTypes.string, - /** - * Horizontal alignment of the grid items - */ - justify : PropTypes.oneOf( [ - 'start', - 'center', - 'end', - 'stretch', - ] ), - /** - * Row gap - */ - rowGap : PropTypes.oneOf( [ 'none', 'S', 'M', 'L' ] ), - /** - * Number of rows - should be an integer > 0 - */ - rows : PropTypes.number, - }; + const cssMap = useTheme( componentName, props ); - static defaultProps = - { - align : 'stretch', - autoCols : undefined, - autoFlow : 'row', - autoRows : undefined, - children : undefined, - className : undefined, - columnGap : 'M', - columns : undefined, - cssMap : undefined, - customColumns : undefined, - customRows : undefined, - justify : 'stretch', - rowGap : 'M', - rows : undefined, - }; + return ( +
+ { children } +
+ ); +}; - static displayName = 'Grid'; +Grid.propTypes = +{ + /** + * Vertical alignment of the grid items + */ + align : PropTypes.oneOf( [ 'start', 'center', 'end', 'stretch' ] ), + /** + * Defines the size of implicitly set columns + */ + autoCols : PropTypes.string, + /** + * Controls where to auto place new grid items if their place is + * undefined + */ + autoFlow : PropTypes.oneOf( [ 'row', 'col' ] ), + /** + * Defines the size of implicitly set rows + */ + autoRows : PropTypes.string, + /** + * Grid content (Columns) + */ + children : PropTypes.node, + /** + * CSS class name + */ + className : PropTypes.string, + /** + * Column gap + */ + columnGap : PropTypes.oneOf( [ 'none', 'S', 'M', 'L' ] ), + /** + * Number of columns - should be an integer > 0 + */ + columns : PropTypes.number, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Custom sizes of columns + */ + customColumns : PropTypes.string, + /** + * Custom sizes of rows + */ + customRows : PropTypes.string, + /** + * Horizontal alignment of the grid items + */ + justify : PropTypes.oneOf( [ 'start', 'center', 'end', 'stretch' ] ), + /** + * Row gap + */ + rowGap : PropTypes.oneOf( [ 'none', 'S', 'M', 'L' ] ), + /** + * Number of rows - should be an integer > 0 + */ + rows : PropTypes.number, +}; + +Grid.defaultProps = +{ + align : 'stretch', + autoCols : undefined, + autoFlow : 'row', + autoRows : undefined, + children : undefined, + className : undefined, + columnGap : 'M', + columns : undefined, + cssMap : undefined, + customColumns : undefined, + customRows : undefined, + justify : 'stretch', + rowGap : 'M', + rows : undefined, +}; - render() - { - const { - autoCols, - autoRows, - children, - columns, - cssMap = createCssMap( this.context.Grid, this.props ), - customColumns, - customRows, - rows, - } = this.props; +Grid.displayName = componentName; - return ( -
- { children } -
- ); - } -} +export default Grid; diff --git a/src/Grid/tests.jsx b/src/Grid/tests.jsx deleted file mode 100644 index d5833a70..00000000 --- a/src/Grid/tests.jsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import { Grid, GridItem } from '..'; - - -describe( 'Grid', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - describe( 'render', () => - { - test( 'should render the children of the Grid', () => - { - wrapper.setProps( { - children : [ - , - , - ], - } ); - - expect( wrapper.find( GridItem ) ).toHaveLength( 2 ); - } ); - } ); - - test( 'should have “main” as default className', () => - { - expect( wrapper.prop( 'className' ) ).toEqual( 'main' ); - } ); -} ); diff --git a/src/GridItem/index.jsx b/src/GridItem/index.jsx index 301bb0c0..7a0b9b16 100644 --- a/src/GridItem/index.jsx +++ b/src/GridItem/index.jsx @@ -7,85 +7,85 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -import { attachEvents } from '../utils'; +import { attachEvents, useTheme } from '../utils'; -export default class GridItem extends React.Component +const componentName = 'GridItem'; + +const GridItem = props => { - static contextType = ThemeContext; + const { + children, + colSpan, + rowSpan, + } = props; + + const cssMap = useTheme( componentName, props ); - static propTypes = - { - /** - * Vertical alignment of the GridItem content - */ - align : PropTypes.oneOf( [ - 'start', - 'center', - 'end', - 'stretch', - ] ), - /** - * GridItem content - */ - children : PropTypes.node, - /** - * CSS class name - */ - className : PropTypes.string, - /** - * GridItem column span - should be an integer > 0 - */ - colSpan : PropTypes.number, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Horizontal alignment of the GridItem content - */ - justify : PropTypes.oneOf( [ 'start', 'center', 'end', 'stretch' ] ), - /** - * GridItem row span - should be an integer > 0 - */ - rowSpan : PropTypes.number, - }; + return ( +
+ { children } +
+ ); +}; - static defaultProps = - { - align : 'start', - children : undefined, - className : undefined, - colSpan : undefined, - cssMap : undefined, - justify : 'start', - rowSpan : undefined, - }; +GridItem.propTypes = +{ + /** + * Vertical alignment of the GridItem content + */ + align : PropTypes.oneOf( [ + 'start', + 'center', + 'end', + 'stretch', + ] ), + /** + * GridItem content + */ + children : PropTypes.node, + /** + * CSS class name + */ + className : PropTypes.string, + /** + * GridItem column span - should be an integer > 0 + */ + colSpan : PropTypes.number, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Horizontal alignment of the GridItem content + */ + justify : PropTypes.oneOf( [ 'start', 'center', 'end', 'stretch' ] ), + /** + * GridItem row span - should be an integer > 0 + */ + rowSpan : PropTypes.number, +}; + +GridItem.defaultProps = +{ + align : 'start', + children : undefined, + className : undefined, + colSpan : undefined, + cssMap : undefined, + justify : 'start', + rowSpan : undefined, +}; - render() - { - const { - children, - colSpan, - cssMap = createCssMap( this.context.GridItem, this.props ), - rowSpan, - } = this.props; +GridItem.displayName = componentName; - return ( -
- { children } -
- ); - } -} +export default GridItem; diff --git a/src/GridItem/tests.jsx b/src/GridItem/tests.jsx deleted file mode 100644 index a4cd0f33..00000000 --- a/src/GridItem/tests.jsx +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import GridItem from './index'; - - -describe( 'GridItem', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( dummy text ); - } ); - - describe( 'render()', () => - { - test( 'should be rendered with `children` prop', () => - { - wrapper.setProps( { children: 'Cthulhu' } ); - expect( wrapper.text() ).toEqual( 'Cthulhu' ); - } ); - } ); -} ); diff --git a/src/Icon/index.jsx b/src/Icon/index.jsx index c23040a1..4260cf4d 100644 --- a/src/Icon/index.jsx +++ b/src/Icon/index.jsx @@ -7,85 +7,83 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -import { attachEvents } from '../utils'; +import { attachEvents, useTheme } from '../utils'; -export default class Icon extends React.Component +const componentName = 'Icon'; + +const Icon = props => { - static contextType = ThemeContext; + const { + children, + label, + type, + } = props; + + const cssMap = useTheme( componentName, props ); - static propTypes = - { - /** - * Icon label (overrides label prop) - */ - children : PropTypes.string, - /** - * CSS class name - */ - className : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Icon label - */ - label : PropTypes.string, - /** - * Icon role - */ - role : PropTypes.oneOf( [ - 'default', - 'critical', - 'promoted', - 'warning', - ] ), - /** - * Icon size - */ - size : PropTypes.oneOf( [ 'S', 'M', 'L', 'XL' ] ), - /** - * Icon to show (see https://feathericons.com/) - */ - type : PropTypes.string, - }; + return ( + + { ( type !== 'none' ) && + } + + ); +}; - static defaultProps = - { - children : undefined, - className : undefined, - cssMap : undefined, - label : undefined, - role : 'default', - size : 'S', - type : 'none', - }; +Icon.propTypes = +{ + /** + * Icon label (overrides label prop) + */ + children : PropTypes.string, + /** + * CSS class name + */ + className : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Icon label + */ + label : PropTypes.string, + /** + * Icon role + */ + role : PropTypes.oneOf( [ + 'default', + 'critical', + 'promoted', + 'warning', + ] ), + /** + * Icon size + */ + size : PropTypes.oneOf( [ 'S', 'M', 'L', 'XL' ] ), + /** + * Icon to show (see https://feathericons.com/) + */ + type : PropTypes.string, +}; - static displayName = 'Icon'; +Icon.defaultProps = +{ + children : undefined, + className : undefined, + cssMap : undefined, + label : undefined, + role : 'default', + size : 'S', + type : 'none', +}; - render() - { - const { - children, - cssMap = createCssMap( this.context.Icon, this.props ), - label, - type, - } = this.props; +Icon.displayName = componentName; - return ( - - { ( type !== 'none' ) && - } - - ); - } -} +export default Icon; diff --git a/src/Icon/tests.jsx b/src/Icon/tests.jsx deleted file mode 100644 index e8cd2b19..00000000 --- a/src/Icon/tests.jsx +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import { Icon } from '..'; - -describe( 'Icon', () => -{ - beforeEach( () => - { - shallow( ); - } ); - - test( 'should have size S by default', () => - { - expect( Icon.defaultProps.size ).toBe( 'S' ); - } ); -} ); diff --git a/src/IconButton/driver.js b/src/IconButton/driver.js deleted file mode 100644 index e5081fde..00000000 --- a/src/IconButton/driver.js +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -const ERR = { - ICONBUTTON_ERR : ( label, event, state ) => - `Button '${label}' cannot simulate ${event} since it is ${state}`, -}; - -export default class IconButtonDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - click() - { - const props = this.wrapper.props(); - const { label } = props; - - if ( props.isDisabled ) - { - throw new Error( ERR - .ICONBUTTON_ERR( label, 'click', 'disabled' ) ); - } - - this.wrapper.simulate( 'click' ); - return this; - } - - mouseOver() - { - const props = this.wrapper.props(); - const { label } = props; - - if ( props.isDisabled ) - { - throw new Error( ERR - .ICONBUTTON_ERR( label, 'mouseOver', 'disabled' ) ); - } - - this.wrapper.simulate( 'mouseOver' ); - return this; - } - - mouseOut() - { - const props = this.wrapper.props(); - const { label } = props; - - if ( props.isDisabled ) - { - throw new Error( ERR - .ICONBUTTON_ERR( label, 'mouseOut', 'disabled' ) ); - } - - this.wrapper.simulate( 'mouseOut' ); - return this; - } - - focus() - { - const props = this.wrapper.props(); - const { label } = props; - - if ( props.isDisabled ) - { - throw new Error( ERR.ICONBUTTON_ERR( label, 'focus', 'disabled' ) ); - } - - this.wrapper.simulate( 'focus' ); - return this; - } - - blur() - { - const props = this.wrapper.props(); - const { label } = props; - - if ( props.isDisabled ) - { - throw new Error( ERR.ICONBUTTON_ERR( label, 'blur', 'disabled' ) ); - } - - this.wrapper.simulate( 'blur' ); - return this; - } -} diff --git a/src/IconButton/index.jsx b/src/IconButton/index.jsx index 65621c8a..c5b9787a 100644 --- a/src/IconButton/index.jsx +++ b/src/IconButton/index.jsx @@ -12,138 +12,131 @@ import PropTypes from 'prop-types'; import { Icon } from '..'; -import { attachEvents, generateId } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { attachEvents, useTheme } from '../utils'; -const killFocus = e => e.preventDefault(); +const componentName = 'IconButton'; + +const killFocus = e => e.preventDefault(); -export default class IconButton extends React.Component +const IconButton = props => { - static contextType = ThemeContext; + const { + buttonRef, + children, + iconSize, + iconType, + id, + isDisabled, + isFocusable, + label, + value, + } = props; - static propTypes = - { - /** - * Callback that receives a ref to the + ); +}; - static displayName = 'IconButton'; +IconButton.propTypes = +{ + /** + * Callback that receives a ref to the - ); - } -} +export default IconButton; diff --git a/src/IconButton/tests.jsx b/src/IconButton/tests.jsx deleted file mode 100644 index 0d969886..00000000 --- a/src/IconButton/tests.jsx +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { shallow, mount } from 'enzyme'; - -import { Icon, IconButton } from '..'; - -describe( 'IconButton', () => -{ - let wrapper; - let instance; - - beforeEach( () => - { - wrapper = shallow( ); - instance = wrapper.instance(); - } ); - - describe( 'constructor( props )', () => - { - test( 'should have name IconButton', () => - { - expect( instance.constructor.name ).toBe( 'IconButton' ); - } ); - } ); - - describe( 'render()', () => - { - test( 'should contain exactly one Icon', () => - { - expect( wrapper.find( Icon ) ).toHaveLength( 1 ); - } ); - } ); - - describe( 'props', () => - { - describe( 'iconSize', () => - { - test( 'should be "S" by default', () => - { - expect( instance.props.iconSize ).toBe( 'S' ); - } ); - - test( 'should be passed to the Icon as size', () => - { - wrapper.setProps( { iconSize: 'L' } ); - expect( wrapper.find( Icon ).prop( 'size' ) ).toBe( 'L' ); - } ); - } ); - - describe( 'iconType', () => - { - test( 'should be undefiend by default', () => - { - expect( instance.props.iconType ).toBeUndefined(); - } ); - - test( 'should be passed to the Icon as type', () => - { - wrapper.setProps( { iconType: 'plus' } ); - expect( wrapper.find( Icon ).prop( 'type' ) ).toBe( 'plus' ); - } ); - } ); - } ); -} ); - -describe( 'IconButtonDriver', () => -{ - let wrapper; - let driver; - - beforeEach( () => - { - wrapper = mount( ); - driver = wrapper.driver(); - } ); - - describe( 'click', () => - { - test( 'should trigger onClick callback prop once', () => - { - const onClick = jest.fn(); - wrapper.setProps( { onClick } ); - - driver.click(); - expect( onClick ).toBeCalledTimes( 1 ); - } ); - - - describe( 'isDisabled', () => - { - test( 'throws the expected error when isDisabled', () => - { - const expectedError = 'Button \'Tekeli-li\' cannot simulate \ -click since it is disabled'; - wrapper.setProps( { isDisabled: true, label: 'Tekeli-li' } ); - - expect( () => driver.click() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onClick when isDisabled', () => - { - const onClick = jest.fn(); - wrapper.setProps( { - onClick, - isDisabled : true, - label : 'Tekeli-li', - } ); - - try - { - driver.click(); - } - catch ( error ) - { - expect( onClick ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'blur()', () => - { - test( 'should trigger onBlur callback prop once', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { onBlur } ); - - driver.blur(); - expect( onBlur ).toBeCalledTimes( 1 ); - } ); - - - describe( 'isDisabled', () => - { - test( 'throws the expected error when isDisabled', () => - { - const expectedError = 'Button \'Tekeli-li\' cannot simulate \ -blur since it is disabled'; - wrapper.setProps( { isDisabled: true, label: 'Tekeli-li' } ); - - expect( () => driver.blur() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onBlur when isDisabled', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { - onBlur, - isDisabled : true, - label : 'Tekeli-li', - } ); - - try - { - driver.blur(); - } - catch ( error ) - { - expect( onBlur ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'focus()', () => - { - test( 'should trigger onFocus callback prop once', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { onFocus } ); - - driver.focus(); - expect( onFocus ).toBeCalledTimes( 1 ); - } ); - - - describe( 'isDisabled', () => - { - test( 'throws the expected error when isDisabled', () => - { - const expectedError = 'Button \'Tekeli-li\' cannot simulate \ -focus since it is disabled'; - wrapper.setProps( { isDisabled: true, label: 'Tekeli-li' } ); - - expect( () => driver.focus() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onFocus when isDisabled', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { - onFocus, - isDisabled : true, - label : 'Tekeli-li', - } ); - - try - { - driver.focus(); - } - catch ( error ) - { - expect( onFocus ).not.toBeCalled(); - } - } ); - } ); - } ); -} ); diff --git a/src/ListBox/ListBoxOption.jsx b/src/ListBox/ListBoxOption.jsx index a9d78eb1..b45fecaf 100644 --- a/src/ListBox/ListBoxOption.jsx +++ b/src/ListBox/ListBoxOption.jsx @@ -11,121 +11,126 @@ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ /* eslint-disable jsx-a11y/no-noninteractive-tabindex */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import { Icon, Text } from '..'; +import { Icon, Text } from '..'; -import { attachEvents, generateId, mapAria } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { + attachEvents, + mapAria, + useId, + useTheme, +} from '../utils'; -export default class ListBoxOption extends React.Component +const componentName = 'ListBoxOption'; + +const ListBoxOption = props => { - static contextType = ThemeContext; - - static propTypes = { - aria : PropTypes.objectOf( PropTypes.string ), - children : PropTypes.node, - className : PropTypes.string, - cssMap : PropTypes.objectOf( PropTypes.string ), - description : PropTypes.string, - iconSize : PropTypes.oneOf( [ 'S', 'M', 'L', 'XL', 'XXL' ] ), - iconType : PropTypes.string, - id : PropTypes.string, - isActive : PropTypes.bool, - isDisabled : PropTypes.bool, - isSelected : PropTypes.bool, - onClick : PropTypes.func, - onMouseOut : PropTypes.func, - onMouseOver : PropTypes.func, - text : PropTypes.string, - value : PropTypes.string, - }; - - static defaultProps = { - aria : undefined, - children : undefined, - className : undefined, - cssMap : undefined, - description : undefined, - iconSize : undefined, - iconType : 'none', - id : undefined, - isActive : false, - isDisabled : false, - isSelected : false, - onClick : undefined, - onMouseOut : undefined, - onMouseOver : undefined, - text : undefined, - value : undefined, - }; - - render() + const { + aria, + children, + description, + iconSize, + iconType, + isSelected, + text, + value, + } = props; + + const cssMap = useTheme( componentName, props ); + const id = useId( componentName, props ); + + let label; + + if ( children ) { - const { - aria, - children, - cssMap = createCssMap( this.context.ListBoxOption, this.props ), - description, - iconSize, - iconType, - id = generateId( 'ListBoxOption' ), - isSelected, - text, - value, - } = this.props; - - let label; - - if ( children ) - { - label = children; - } - else - { - label = typeof text !== 'undefined' ? text : value; - label = String( label ); - } - - label = typeof label === 'string' ? ( - - { label } - ) : label; - - return ( -
  • - { ( iconType && iconType !== 'none' ) && - - } -
    - { label } - { description && - - { description } - } -
    -
  • - ); + label = children; + } + else + { + label = typeof text !== 'undefined' ? text : value; + label = String( label ); } -} + + label = typeof label === 'string' ? ( + + { label } + ) : label; + + return ( +
  • + { ( iconType && iconType !== 'none' ) && + + } +
    + { label } + { description && + + { description } + } +
    +
  • + ); +}; + +ListBoxOption.propTypes = { + aria : PropTypes.objectOf( PropTypes.string ), + children : PropTypes.node, + className : PropTypes.string, + cssMap : PropTypes.objectOf( PropTypes.string ), + description : PropTypes.string, + iconSize : PropTypes.oneOf( [ 'S', 'M', 'L', 'XL', 'XXL' ] ), + iconType : PropTypes.string, + id : PropTypes.string, + isActive : PropTypes.bool, + isDisabled : PropTypes.bool, + isSelected : PropTypes.bool, + onClick : PropTypes.func, + onMouseOut : PropTypes.func, + onMouseOver : PropTypes.func, + text : PropTypes.string, + value : PropTypes.string, +}; + +ListBoxOption.defaultProps = { + aria : undefined, + children : undefined, + className : undefined, + cssMap : undefined, + description : undefined, + iconSize : undefined, + iconType : 'none', + id : undefined, + isActive : false, + isDisabled : false, + isSelected : false, + onClick : undefined, + onMouseOut : undefined, + onMouseOver : undefined, + text : undefined, + value : undefined, +}; + +ListBoxOption.displayName = componentName; + +export default ListBoxOption; diff --git a/src/ListBox/ListBoxOptionGroup.jsx b/src/ListBox/ListBoxOptionGroup.jsx index 07e6a736..d26232a4 100644 --- a/src/ListBox/ListBoxOptionGroup.jsx +++ b/src/ListBox/ListBoxOptionGroup.jsx @@ -7,67 +7,65 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import { Text } from '..'; +import { Text } from '..'; -import { mapAria } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { mapAria, useTheme } from '../utils'; -export default class ListBoxOptionGroup extends React.Component + +const componentName = 'ListBoxOptionGroup'; + +const ListBoxOptionGroup = props => { - static contextType = ThemeContext; + const { + aria, + children, + header, + options, + } = props; + + const cssMap = useTheme( componentName, props ); + + return ( +
  • +
    + { header } +
    +
      + { children || options } +
    +
  • + ); +}; - static propTypes = { - aria : PropTypes.objectOf( PropTypes.string ), - children : PropTypes.node, - className : PropTypes.string, - cssMap : PropTypes.objectOf( PropTypes.string ), - header : PropTypes.string, - options : PropTypes.arrayOf( PropTypes.object ), - }; +ListBoxOptionGroup.propTypes = { + aria : PropTypes.objectOf( PropTypes.string ), + children : PropTypes.node, + className : PropTypes.string, + cssMap : PropTypes.objectOf( PropTypes.string ), + header : PropTypes.string, + options : PropTypes.arrayOf( PropTypes.object ), +}; - static defaultProps = { - aria : undefined, - children : undefined, - className : undefined, - cssMap : undefined, - header : undefined, - options : undefined, - }; +ListBoxOptionGroup.defaultProps = { + aria : undefined, + children : undefined, + className : undefined, + cssMap : undefined, + header : undefined, + options : undefined, +}; - render() - { - const { - aria, - children, - cssMap = createCssMap( - this.context.ListBoxOptionGroup, - this.props, - ), - header, - options, - } = this.props; +ListBoxOptionGroup.displayName = componentName; - return ( -
  • -
    - { header } -
    -
      - { children || options } -
    -
  • - ); - } -} +export default ListBoxOptionGroup; diff --git a/src/ListBox/driver.js b/src/ListBox/driver.js deleted file mode 100644 index 02bf6292..00000000 --- a/src/ListBox/driver.js +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import ListBoxOption from './ListBoxOption'; - -export default class ListBoxDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - clickOption( index = 0 ) - { - const option = this.wrapper.find( ListBoxOption ).at( index ); - - option.simulate( 'click' ); - return this; - } - - mouseOverOption( index = 0 ) - { - const option = this.wrapper.find( ListBoxOption ).at( index ); - - option.simulate( 'mouseOver' ); - return this; - } - - mouseOutOption( index = 0 ) - { - const option = this.wrapper.find( ListBoxOption ).at( index ); - - option.simulate( 'mouseOut' ); - return this; - } - - keyPress( keyCode ) - { - this.wrapper.simulate( 'keyPress', { keyCode, which: keyCode } ); - return this; - } -} diff --git a/src/ListBox/index.jsx b/src/ListBox/index.jsx index c74a0f21..59638062 100644 --- a/src/ListBox/index.jsx +++ b/src/ListBox/index.jsx @@ -16,126 +16,124 @@ import PropTypes from 'prop-types'; import { buildOptions, updateOptions } from './utils'; import { attachEvents, - generateId, killFocus, mapAria, + useTheme, } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -export default class ListBox extends React.Component -{ - static contextType = ThemeContext; +const componentName = 'ListBox'; - static propTypes = { - aria : PropTypes.objectOf( PropTypes.string ), - /** - * Highlights option - */ - activeOption : PropTypes.string, - children : PropTypes.node, - /** - * Extra CSS class name - */ - className : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - isFocusable : PropTypes.bool, - isMultiselect : PropTypes.bool, - /** - * ListBox ID - */ - id : PropTypes.string, - /** - * Array of strings or objects (to build the options) - */ - options : PropTypes.arrayOf( PropTypes.object ), - /** - * onClickOption callback function ( e ) => { ... } - */ - onClickOption : PropTypes.func, - /** - * onMouseOutOption callback function ( e ) => { ... } - */ - onMouseOutOption : PropTypes.func, - /** - * onMouseOverOption callback function ( e ) => { ... } - */ - onMouseOverOption : PropTypes.func, - selection : PropTypes.oneOfType( [ - PropTypes.string, - PropTypes.arrayOf( PropTypes.string ), - ] ), - }; +const ListBox = props => +{ + const { + aria, + activeOption, + children, + id, + isFocusable, + isMultiselect, + onClickOption, + onMouseOutOption, + onMouseOverOption, + options, + selection, + } = props; - static defaultProps = { - activeOption : undefined, - aria : undefined, - children : undefined, - className : undefined, - cssMap : undefined, - id : undefined, - isFocusable : true, - isMultiselect : false, - onClickOption : undefined, - onMouseOutOption : undefined, - onMouseOverOption : undefined, - options : undefined, - selection : undefined, - }; + const cssMap = useTheme( componentName, props ); - static displayName = 'ListBox'; + let realSelection = selection; - render() + if ( Array.isArray( selection ) ) { - const { - aria, - activeOption, - children, - cssMap = createCssMap( this.context.ListBox, this.props ), - isFocusable, - isMultiselect, - id = generateId( 'ListBox' ), - onClickOption, - onMouseOutOption, - onMouseOverOption, - options, - selection, - } = this.props; + realSelection = isMultiselect ? selection : selection[ 0 ]; + } + return ( +
      + { updateOptions( + children || buildOptions( options ), + { + activeOption, + onClickOption, + onMouseOutOption, + onMouseOverOption, + selection : realSelection, + }, + ) } +
    + ); +}; - let realSelection = selection; +ListBox.propTypes = { + aria : PropTypes.objectOf( PropTypes.string ), + /** + * Highlights option + */ + activeOption : PropTypes.string, + children : PropTypes.node, + /** + * Extra CSS class name + */ + className : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + isFocusable : PropTypes.bool, + isMultiselect : PropTypes.bool, + /** + * ListBox ID + */ + id : PropTypes.string, + /** + * Array of strings or objects (to build the options) + */ + options : PropTypes.arrayOf( PropTypes.object ), + /** + * onClickOption callback function ( e ) => { ... } + */ + onClickOption : PropTypes.func, + /** + * onMouseOutOption callback function ( e ) => { ... } + */ + onMouseOutOption : PropTypes.func, + /** + * onMouseOverOption callback function ( e ) => { ... } + */ + onMouseOverOption : PropTypes.func, + selection : PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.arrayOf( PropTypes.string ), + ] ), +}; - if ( Array.isArray( selection ) ) - { - realSelection = isMultiselect ? selection : selection[ 0 ]; - } - return ( -
      - { updateOptions( - children || buildOptions( options ), - { - activeOption, - onClickOption, - onMouseOutOption, - onMouseOverOption, - selection : realSelection, - }, - ) } -
    - ); - } -} +ListBox.defaultProps = { + activeOption : undefined, + aria : undefined, + children : undefined, + className : undefined, + cssMap : undefined, + id : undefined, + isFocusable : true, + isMultiselect : false, + onClickOption : undefined, + onMouseOutOption : undefined, + onMouseOverOption : undefined, + options : undefined, + selection : undefined, +}; + +ListBox.displayName = componentName; + +export default ListBox; diff --git a/src/ListBox/tests.jsx b/src/ListBox/tests.jsx deleted file mode 100644 index dac098d3..00000000 --- a/src/ListBox/tests.jsx +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { shallow, mount } from 'enzyme'; - -import { ListBox } from '..'; - -describe( 'ListBox', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - describe( 'render()', () => - { - test( 'should accept a single ListBox as children', () => - { - wrapper.setProps( { children: } ); - expect( wrapper.find( ListBox ) ).toHaveLength( 1 ); - } ); - - test( 'should accept an array of ListBoxes as children', () => - { - wrapper.setProps( { children: [ , ] } ); - expect( wrapper.find( ListBox ) ).toHaveLength( 2 ); - } ); - } ); -} ); - - -describe( 'ListBoxDriver', () => -{ - let wrapper; - let driver; - - beforeEach( () => - { - wrapper = mount( ); - driver = wrapper.driver(); - } ); - - describe( 'clickOption( index )', () => - { - test( 'should trigger onClickOption once when clicked on \ -ListBoxOption at given index', () => - { - const onClickOption = jest.fn(); - wrapper.setProps( { - onClickOption, - options : [ { - 'text' : 'Option', - }, - { - 'text' : 'Option with description', - 'value' : 'value2', - 'description' : 'Option description', - }, - { - 'text' : 'Disabled option', - 'value' : 'value3', - 'isDisabled' : true, - }, - { - header : 'Subsection 1', - options : [ - { - 'text' : 'Subsection option 1', - }, - { - 'text' : 'Subsection option 2', - }, - { - 'text' : 'Subsection description', - 'description' : 'Option description', - 'value' : 'value12', - }, - ], - } ], - } ); - - driver.clickOption( 1 ); - expect( onClickOption ).toBeCalledTimes( 1 ); - } ); - } ); - - - describe( 'mouseOverOption( index )', () => - { - test( 'should trigger onMouseOverOption once when hovered on \ -ListBoxOption at given index', () => - { - const onMouseOverOption = jest.fn(); - wrapper.setProps( { - onMouseOverOption, - options : [ { - 'text' : 'Option', - }, - { - 'text' : 'Option with description', - 'value' : 'value2', - 'description' : 'Option description', - }, - { - 'text' : 'Disabled option', - 'value' : 'value3', - 'isDisabled' : true, - }, - { - header : 'Subsection 1', - options : [ - { - 'text' : 'Subsection option 1', - }, - { - 'text' : 'Subsection option 2', - }, - { - 'text' : 'Subsection description', - 'description' : 'Option description', - 'value' : 'value12', - }, - ], - } ], - } ); - - driver.mouseOverOption( 1 ); - expect( onMouseOverOption ).toBeCalledTimes( 1 ); - } ); - } ); - - - describe( 'mouseOutOption( index )', () => - { - test( 'should trigger onMouseOutOption once when hovered on \ -ListBoxOption at given index', () => - { - const onMouseOutOption = jest.fn(); - wrapper.setProps( { - onMouseOutOption, - options : [ { - 'text' : 'Option', - }, - { - 'text' : 'Option with description', - 'value' : 'value2', - 'description' : 'Option description', - }, - { - 'text' : 'Disabled option', - 'value' : 'value3', - 'isDisabled' : true, - }, - { - header : 'Subsection 1', - options : [ - { - 'text' : 'Subsection option 1', - }, - { - 'text' : 'Subsection option 2', - }, - { - 'text' : 'Subsection description', - 'description' : 'Option description', - 'value' : 'value12', - }, - ], - } ], - } ); - - driver.mouseOutOption( 0 ); - expect( onMouseOutOption ).toBeCalledTimes( 1 ); - } ); - } ); - - - describe( 'keyPress', () => - { - test( 'should trigger onKeyPress once', () => - { - const onKeyPress = jest.fn(); - wrapper.setProps( { - onKeyPress, - options : [ { - 'text' : 'Option', - }, - { - 'text' : 'Option with description', - 'value' : 'value2', - 'description' : 'Option description', - }, - { - 'text' : 'Disabled option', - 'value' : 'value3', - 'isDisabled' : true, - } ], - } ); - - driver.keyPress(); - expect( onKeyPress ).toBeCalledTimes( 1 ); - } ); - } ); -} ); diff --git a/src/Modal/driver.js b/src/Modal/driver.js deleted file mode 100644 index a661d1be..00000000 --- a/src/Modal/driver.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2017-2019 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import { createCssMap } from '../Theming'; - - -export default class ModalDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - get instance() - { - return this.wrapper.instance(); - } - - get cssMap() - { - const { instance } = this; - return instance.props.cssMap || - createCssMap( instance.context.Modal, instance.props ); - } - - get overlay() - { - return this.wrapper.find( `.${this.cssMap.main}` ); - } - - clickOverlay() - { - this.overlay.simulate( 'click' ); - return this; - } -} diff --git a/src/Modal/index.jsx b/src/Modal/index.jsx index b3726ebd..19b04bca 100644 --- a/src/Modal/index.jsx +++ b/src/Modal/index.jsx @@ -7,81 +7,71 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import { attachEvents } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { attachEvents, useTheme } from '../utils'; -export default class Modal extends React.Component -{ - static contextType = ThemeContext; - - static propTypes = - { - /** - * Dialog Content - */ - children : PropTypes.node, - /** - * Extra CSS class name - */ - className : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Overlay onClick callback function - */ - onClickOverlay : PropTypes.func, - }; - - static defaultProps = - { - children : undefined, - className : undefined, - cssMap : undefined, - onClickOverlay : undefined, - }; - - static displayName = 'Modal'; - - constructor() - { - super(); - this.handleClickOverlay = this.handleClickOverlay.bind( this ); - } +const componentName = 'Modal'; - handleClickOverlay( { target, currentTarget } ) +const Modal = ( props ) => +{ + const handleClickOverlay = ( { target, currentTarget } ) => { if ( target !== currentTarget ) return; - const { onClickOverlay } = this.props; + const { onClickOverlay } = props; if ( onClickOverlay ) { onClickOverlay(); } - } + }; - render() - { - const { - children, - cssMap = createCssMap( this.context.Modal, this.props ), - } = this.props; + const { children } = props; - return ( -
    -
    - { children } -
    + const cssMap = useTheme( componentName, props ); + + return ( +
    +
    + { children }
    - ); - } -} +
    + ); +}; + +Modal.propTypes = +{ + /** + * Dialog Content + */ + children : PropTypes.node, + /** + * Extra CSS class name + */ + className : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Overlay onClick callback function + */ + onClickOverlay : PropTypes.func, +}; + +Modal.defaultProps = +{ + children : undefined, + className : undefined, + cssMap : undefined, + onClickOverlay : undefined, +}; + +Modal.displayName = componentName; + +export default Modal; diff --git a/src/Modal/tests.jsx b/src/Modal/tests.jsx deleted file mode 100644 index 1a534f61..00000000 --- a/src/Modal/tests.jsx +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2017-2019 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { mount, shallow } from 'enzyme'; - -import { Modal } from '..'; - - -describe( 'Modal', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - test( 'should contain child elements', () => - { - wrapper.setProps( { - children : boom, - } ); - - const children = wrapper.find( '.content' ).children(); - expect( children.html() ).toBe( 'boom' ); - } ); -} ); - - -describe( 'ModalDriver', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = mount( ); - } ); - - test( - 'should trigger `onClickOverlay` once callback when overlay clicked', - () => - { - const onClickOverlay = jest.fn(); - wrapper.setProps( { isVisible: true, onClickOverlay } ); - - wrapper.driver().clickOverlay(); - expect( onClickOverlay ).toHaveBeenCalledTimes( 1 ); - }, - ); -} ); diff --git a/src/PasswordInput/driver.js b/src/PasswordInput/driver.js deleted file mode 100644 index 277753f1..00000000 --- a/src/PasswordInput/driver.js +++ /dev/null @@ -1,108 +0,0 @@ -/* - * Copyright (c) 2017-2019 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import { IconButton, TextInput } from 'nessie-ui'; - -const ERR = { - PASS_ERR : ( event, state ) => `PasswordInput cannot simulate ${event} \ -since it is ${state}`, -}; - -export default class PasswordInputDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - - blur() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.PASS_ERR( 'blur', 'disabled' ) ); - } - - this.wrapper.find( TextInput ).driver().blur(); - return this; - } - - focus() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.PASS_ERR( 'focus', 'disabled' ) ); - } - - this.wrapper.find( TextInput ).driver().focus(); - return this; - } - - change( val ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.PASS_ERR( 'change', 'disabled' ) ); - } - - if ( this.wrapper.props().isReadOnly ) - { - throw new Error( ERR.PASS_ERR( 'change', 'read only' ) ); - } - - this.wrapper.find( TextInput ).driver().change( val ); - return this; - } - - keyPress( keyCode ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.PASS_ERR( 'keyPress', 'disabled' ) ); - } - - this.wrapper.find( TextInput ).driver().keyPress( keyCode ); - return this; - } - - mouseOver() - { - this.wrapper.simulate( 'mouseOver' ); - return this; - } - - mouseOut() - { - this.wrapper.simulate( 'mouseOut' ); - return this; - } - - clickIcon() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.PASS_ERR( 'clickIcon', 'disabled' ) ); - } - - this.wrapper.find( IconButton ).driver().click(); - return this; - } - - mouseOverIcon() - { - this.wrapper.find( IconButton ).driver().mouseOver(); - return this; - } - - mouseOutIcon() - { - this.wrapper.find( IconButton ).driver().mouseOut(); - return this; - } -} diff --git a/src/PasswordInput/index.jsx b/src/PasswordInput/index.jsx index 7b8a0454..f8651272 100644 --- a/src/PasswordInput/index.jsx +++ b/src/PasswordInput/index.jsx @@ -7,145 +7,37 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { + useState, + useCallback, + forwardRef, +} from 'react'; +import PropTypes from 'prop-types'; -import { TextInputWithIcon } from '..'; +import { TextInputWithIcon } from '..'; -import { generateId } from '../utils'; +const componentName = 'PasswordInput'; -export default class PasswordInput extends React.Component +const PasswordInput = forwardRef( ( props, ref ) => { - static propTypes = - { - /** - * ARIA properties - */ - aria : PropTypes.objectOf( PropTypes.oneOfType( [ - PropTypes.bool, - PropTypes.number, - PropTypes.string, - ] ) ), - /** - * Extra CSS class name - */ - className : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Default input string value - */ - defaultValue : PropTypes.string, - /** - * Display as hover when required from another component - */ - forceHover : PropTypes.bool, - /** - * Display as error/invalid - */ - hasError : PropTypes.bool, - /** - * Display Button icon as disabled - */ - iconButtonIsDisabled : PropTypes.bool, - /** - * Alignment of the icon - */ - iconPosition : PropTypes.oneOf( [ 'left', 'right' ] ), - /** - * Component id - */ - id : PropTypes.string, - /** - * Callback that receives the native : ( ref ) => { ... } - */ - inputRef : PropTypes.func, - /** - * Display as disabled - */ - isDisabled : PropTypes.bool, - /** - * Display as read-only - */ - isReadOnly : PropTypes.bool, - /** - * HTML name attribute - */ - name : PropTypes.string, - /** - * Input change callback function - */ - onChangeInput : PropTypes.func, - /** - * Icon click callback function - */ - onClickIcon : PropTypes.func, - /** - * Show password as plain text - */ - passwordIsVisible : PropTypes.func, - /** - * Placeholder text - */ - placeholder : PropTypes.string, - /** - * Input text alignment - */ - textAlign : PropTypes.oneOf( [ 'auto', 'left', 'right' ] ), - /** - * Input string value - */ - value : PropTypes.string, - }; - - static defaultProps = - { - aria : undefined, - className : undefined, - cssMap : undefined, - defaultValue : undefined, - forceHover : false, - hasError : false, - iconButtonIsDisabled : undefined, - iconPosition : 'right', - id : undefined, - inputRef : undefined, - isDisabled : false, - isReadOnly : false, - name : undefined, - onChangeInput : undefined, - onClickIcon : undefined, - passwordIsVisible : false, - placeholder : undefined, - textAlign : 'auto', - value : undefined, - }; + const [ passwordIsVisibleState, + setPasswordIsVisibleState ] = useState( false ); - constructor( props ) - { - super(); + const passwordIsVisible = + props.passwordIsVisible || passwordIsVisibleState; - this.state = { - id : props.id || generateId( 'PasswordInput' ), - passwordIsVisible : false, - }; + const { + id, + onClickIcon, + } = props; - this.handleClickIcon = this.handleClickIcon.bind( this ); - } - - handleClickIcon( payload, e ) + const handleClickIcon = useCallback( ( payload, e ) => { - const { onClickIcon } = this.props; - let nessieDefaultPrevented = false; if ( typeof onClickIcon === 'function' ) { - const { id } = this.state; - onClickIcon( { id, @@ -160,31 +52,127 @@ export default class PasswordInput extends React.Component if ( !nessieDefaultPrevented ) { - this.setState( prevState => ( { - passwordIsVisible : !prevState.passwordIsVisible, - } ) ); + setPasswordIsVisibleState( !passwordIsVisibleState ); } - } - - render() - { - const { props } = this; - const { id } = this.state; - - const passwordIsVisible = - this.props.passwordIsVisible || this.state.passwordIsVisible; - - return ( - - ); - } -} + }, [ id, onClickIcon, passwordIsVisibleState ] ); + + return ( + + ); +} ); + +PasswordInput.displayComponent = componentName; + +PasswordInput.propTypes = +{ + /** + * ARIA properties + */ + aria : PropTypes.objectOf( PropTypes.oneOfType( [ + PropTypes.bool, + PropTypes.number, + PropTypes.string, + ] ) ), + /** + * Extra CSS class name + */ + className : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Default input string value + */ + defaultValue : PropTypes.string, + /** + * Display as error/invalid + */ + hasError : PropTypes.bool, + /** + * Display Button icon as disabled + */ + iconButtonIsDisabled : PropTypes.bool, + /** + * Alignment of the icon + */ + iconPosition : PropTypes.oneOf( [ 'left', 'right' ] ), + /** + * Component id + */ + id : PropTypes.string, + /** + * Callback that receives the native : ( ref ) => { ... } + */ + inputRef : PropTypes.func, + /** + * Display as disabled + */ + isDisabled : PropTypes.bool, + /** + * Display as read-only + */ + isReadOnly : PropTypes.bool, + /** + * HTML name attribute + */ + name : PropTypes.string, + /** + * Input change callback function + */ + onChangeInput : PropTypes.func, + /** + * Icon click callback function + */ + onClickIcon : PropTypes.func, + /** + * Show password as plain text + */ + passwordIsVisible : PropTypes.bool, + /** + * Placeholder text + */ + placeholder : PropTypes.string, + /** + * Input text alignment + */ + textAlign : PropTypes.oneOf( [ 'auto', 'left', 'right' ] ), + /** + * Input string value + */ + value : PropTypes.string, +}; + +PasswordInput.defaultProps = +{ + aria : undefined, + className : undefined, + cssMap : undefined, + defaultValue : undefined, + hasError : false, + iconButtonIsDisabled : undefined, + iconPosition : 'right', + id : undefined, + inputRef : undefined, + isDisabled : false, + isReadOnly : false, + name : undefined, + onChangeInput : undefined, + onClickIcon : undefined, + passwordIsVisible : false, + placeholder : undefined, + textAlign : 'auto', + value : undefined, +}; + +export default PasswordInput; diff --git a/src/PasswordInput/tests.jsx b/src/PasswordInput/tests.jsx deleted file mode 100644 index 30244c41..00000000 --- a/src/PasswordInput/tests.jsx +++ /dev/null @@ -1,362 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { mount, shallow } from 'enzyme'; - -import { PasswordInput, TextInputWithIcon } from '..'; - -describe( 'PasswordInput', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - test( 'should contain exactly one TextInputWithIcon', () => - { - expect( wrapper.find( TextInputWithIcon ) ).toHaveLength( 1 ); - } ); - - test( 'it should pass inputType "password" by default', () => - { - expect( wrapper.find( TextInputWithIcon ).prop( 'inputType' ) ) - .toBe( 'password' ); - } ); - - test( 'it should pass iconType "eye" by default', () => - { - expect( wrapper.find( TextInputWithIcon ).prop( 'iconType' ) ) - .toBe( 'eye' ); - } ); - - test( 'it should pass autoCapitalize "off"', () => - { - expect( wrapper.find( TextInputWithIcon ) - .prop( 'autoCapitalize' ) ).toBe( 'off' ); - } ); - - test( 'it should pass autoComplete "off"', () => - { - expect( wrapper.find( TextInputWithIcon ).prop( 'autoComplete' ) ) - .toBe( 'off' ); - } ); - - test( 'it should pass autoCorrect "off"', () => - { - expect( wrapper.find( TextInputWithIcon ).prop( 'autoCorrect' ) ) - .toBe( 'off' ); - } ); - - test( 'it should pass spellCheck false', () => - { - expect( wrapper.find( TextInputWithIcon ).prop( 'spellCheck' ) ) - .toBe( false ); - } ); - - describe( 'when passwordIsVisible', () => - { - beforeEach( () => - { - wrapper.setProps( { passwordIsVisible: true } ); - } ); - - test( 'it should pass inputType "text"', () => - { - expect( wrapper.find( TextInputWithIcon ).prop( 'inputType' ) ) - .toBe( 'text' ); - } ); - - test( 'it should pass iconType "eye-off"', () => - { - expect( wrapper.find( TextInputWithIcon ).prop( 'iconType' ) ) - .toBe( 'eye-off' ); - } ); - } ); -} ); - -describe( 'PasswordInputDriver', () => -{ - let wrapper; - let driver; - - beforeEach( () => - { - wrapper = mount( ); - driver = wrapper.driver(); - } ); - - describe( 'focus()', () => - { - test( 'should fire the onFocus callback prop once', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { onFocus } ); - - driver.focus(); - expect( onFocus ).toBeCalledTimes( 1 ); - } ); - - - describe( 'isDisabled', () => - { - test( 'throws the expected error when isDisabled', () => - { - const expectedError = 'PasswordInput cannot simulate focus \ -since it is disabled'; - wrapper.setProps( { isDisabled: true, label: 'Cthulhu' } ); - - expect( () => driver.focus() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onFocus when isDisabled', () => - { - const onFocus = jest.fn(); - wrapper.setProps( { onFocus, isDisabled: true } ); - - try - { - driver.focus(); - } - catch ( error ) - { - expect( onFocus ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'blur()', () => - { - test( 'should trigger onBlur callback prop once', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { onBlur } ); - - driver.blur(); - expect( onBlur ).toBeCalledTimes( 1 ); - } ); - - - describe( 'isDisabled', () => - { - test( 'throws the expected error when isDisabled', () => - { - const expectedError = 'PasswordInput cannot simulate blur \ -since it is disabled'; - wrapper.setProps( { isDisabled: true, label: 'Cthulhu' } ); - - expect( () => driver.blur() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onBlur when isDisabled', () => - { - const onBlur = jest.fn(); - wrapper.setProps( { onBlur, isDisabled: true } ); - - try - { - driver.blur(); - } - catch ( error ) - { - expect( onBlur ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'change( val )', () => - { - test( 'should fire the onChange callback prop once', () => - { - const onChange = jest.fn(); - wrapper.setProps( { - onChange, - } ); - - driver.change( 'Azathoth' ); - expect( onChange ).toBeCalledTimes( 1 ); - } ); - - - describe( 'isDisabled', () => - { - test( 'throws the expected error when isDisabled', () => - { - const expectedError = 'PasswordInput cannot simulate change \ -since it is disabled'; - wrapper.setProps( { isDisabled: true, label: 'Cthulhu' } ); - - expect( () => driver.change( 'Azathoth' ) ) - .toThrow( expectedError ); - } ); - - test( 'should not trigger onChange when isDisabled', () => - { - const onChange = jest.fn(); - wrapper.setProps( { onChange, isDisabled: true } ); - - try - { - driver.change( 'Azathoth' ); - } - catch ( error ) - { - expect( onChange ).not.toBeCalled(); - } - } ); - } ); - - - describe( 'isReadOnly', () => - { - test( 'throws the expected error when isReadOnly', () => - { - const expectedError = 'PasswordInput cannot simulate change \ -since it is read only'; - wrapper.setProps( { isReadOnly: true, label: 'Tekeli-li' } ); - - expect( () => driver.change( 'Azathoth' ) ) - .toThrow( expectedError ); - } ); - - test( 'should not trigger onChange when isReadOnly', () => - { - const onChange = jest.fn(); - wrapper.setProps( { onChange, isReadOnly: true } ); - - try - { - driver.change( 'Azathoth' ); - } - catch ( error ) - { - expect( onChange ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'keyPress()', () => - { - test( 'should fire the onKeyPress callback prop once', () => - { - const onKeyPress = jest.fn(); - wrapper.setProps( { onKeyPress } ); - - driver.keyPress(); - expect( onKeyPress ).toBeCalledTimes( 1 ); - } ); - - - describe( 'isDisabled', () => - { - test( 'throws the expected error when isDisabled', () => - { - const expectedError = 'PasswordInput cannot simulate keyPress \ -since it is disabled'; - wrapper.setProps( { isDisabled: true, label: 'Cthulhu' } ); - - expect( () => driver.keyPress() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onKeyPress when isDisabled', () => - { - const onKeyPress = jest.fn(); - wrapper.setProps( { onKeyPress, isDisabled: true } ); - - try - { - driver.keyPress(); - } - catch ( error ) - { - expect( onKeyPress ).not.toBeCalled(); - } - } ); - } ); - } ); - - - describe( 'mouseOver()', () => - { - test( 'should trigger onMouseOver callback once', () => - { - const onMouseOver = jest.fn(); - wrapper.setProps( { onMouseOver } ); - - driver.mouseOver(); - expect( onMouseOver ).toBeCalledTimes( 1 ); - } ); - } ); - - - describe( 'mouseOut()', () => - { - test( 'should trigger onMouseOut callback once', () => - { - const onMouseOut = jest.fn(); - wrapper.setProps( { onMouseOut } ); - - driver.mouseOut(); - expect( onMouseOut ).toBeCalledTimes( 1 ); - } ); - } ); - - - describe( 'clickIcon()', () => - { - test( 'should trigger onClickIcon callback once', () => - { - const onClickIcon = jest.fn(); - wrapper.setProps( { - onClickIcon, - } ); - - driver.clickIcon(); - expect( onClickIcon ).toBeCalledTimes( 1 ); - } ); - - - describe( 'isDisabled', () => - { - test( 'throws the expected error when isDisabled', () => - { - const expectedError = 'PasswordInput cannot simulate clickIcon \ -since it is disabled'; - wrapper.setProps( { isDisabled: true, label: 'Cthulhu' } ); - - expect( () => driver.clickIcon() ).toThrow( expectedError ); - } ); - - test( 'should not trigger onClickIcon when isDisabled', () => - { - const onClickIcon = jest.fn(); - wrapper.setProps( { onClickIcon, isDisabled: true } ); - - try - { - driver.clickIcon(); - } - catch ( error ) - { - expect( onClickIcon ).not.toBeCalled(); - } - } ); - } ); - } ); -} ); diff --git a/src/PopperWrapper/index.jsx b/src/PopperWrapper/index.jsx index 402c2b2e..c8a2d66c 100644 --- a/src/PopperWrapper/index.jsx +++ b/src/PopperWrapper/index.jsx @@ -9,225 +9,174 @@ /* global document, addEventListener, removeEventListener */ -import React, { Component } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import { Manager, Reference, Popper } from 'react-popper'; import PropTypes from 'prop-types'; -export default class PopperWrapper extends Component -{ - static propTypes = - { - /** - * Reference node to attach popper - */ - children : PropTypes.node, - /** - * id of the DOM element used as container - */ - container : PropTypes.string, - /** - * Show / Hide popper - */ - isVisible : PropTypes.bool, - /** - * pop up width matches reference width - */ - matchRefWidth : PropTypes.bool, - /** - * Click Outside callback: ( e ) => ... - */ - onClickOutside : PropTypes.func, - /** - * Popper content node - */ - popper : PropTypes.node, - /** - * Popper offset - */ - popperOffset : PropTypes.oneOf( [ 'S', 'M', 'L', 'XL', 'none' ] ), - /** - * Popper position - */ - popperPosition : PropTypes.oneOf( [ - 'auto', - 'auto-start', - 'auto-end', - 'top', - 'top-start', - 'top-end', - 'bottom', - 'bottom-start', - 'bottom-end', - 'left', - 'left-start', - 'left-end', - 'right', - 'right-start', - 'right-end', - ] ), - } - - static defaultProps = - { - children : undefined, - container : undefined, - isVisible : false, - matchRefWidth : undefined, - onClickOutside : undefined, - popper : undefined, - popperOffset : 'none', - popperPosition : 'auto', - } - - static displayName = 'PopperWrapper' - - referenceRef = React.createRef(); - popperRef = React.createRef(); - - constructor() - { - super(); - - this.handleClickOutSide = this.handleClickOutSide.bind( this ); - } +const componentName = 'PopperWrapper'; - componentDidMount() +const PopperWrapper = ( props ) => +{ + const { + children, + container, + isVisible, + matchRefWidth, + onClickOutside, + popper, + popperOffset, + popperPosition, + } = props; + + const referenceRef = useRef(); + const popperRef = useRef(); + const scheduleUpdateRef = useRef(); + + useEffect( () => { - if ( this.props.isVisible && this.props.onClickOutside ) + if ( isVisible && scheduleUpdateRef.current ) { - addEventListener( 'mousedown', this.handleClickOutSide, false ); + scheduleUpdateRef.current(); } - } + }, [ isVisible, popperOffset ] ); - componentDidUpdate( prevProps ) + useEffect( () => { - if ( this.props.isVisible ) + if ( isVisible && onClickOutside ) { - this.scheduleUpdate(); - } + addEventListener( 'mousedown', handleClickOutSide ); - if ( prevProps.isVisible && prevProps.onClickOutside ) - { - if ( this.props.isVisible ) + return () => { - if ( this.props.onClickOutside ) - { - if ( prevProps.onClickOutside !== - this.props.onClickOutside ) - { - removeEventListener( - 'mousedown', - this.handleClickOutSide, - false, - ); - - addEventListener( - 'mousedown', - this.handleClickOutSide, - false, - ); - } - } - else - { - removeEventListener( - 'mousedown', - this.handleClickOutSide, - false, - ); - } - } - else - { - removeEventListener( - 'mousedown', - this.handleClickOutSide, - false, - ); - } + removeEventListener( 'mousedown', handleClickOutSide ); + }; } - else if ( this.props.isVisible && this.props.onClickOutside ) - { - addEventListener( 'mousedown', this.handleClickOutSide, false ); - } - } - - componentWillUnmount() - { - if ( this.props.onClickOutside ) - { - removeEventListener( 'mousedown', this.handleClickOutSide, false ); - } - } + }, [ scheduleUpdateRef.current, isVisible, onClickOutside ] ); - handleClickOutSide( e ) + const handleClickOutSide = useCallback( ( e ) => { - if ( !( this.referenceRef.current.contains( e.target ) || - this.popperRef.current.contains( e.target ) ) ) + if ( !( referenceRef.current.contains( e.target ) || + popperRef.current.contains( e.target ) ) ) { - this.props.onClickOutside(); + onClickOutside(); } - } - - render() - { - const { - children, - container, - isVisible, - matchRefWidth, - popper, - popperOffset, - popperPosition, - } = this.props; - - const offset = { - 'S' : '8px', - 'M' : '16px', - 'L' : '24px', - 'XL' : '32px', - 'none' : undefined, - }[ popperOffset ]; - - return ( - - this.referenceRef.current = ref } > - { ( { ref } ) => ( -
    - { children } -
    - ) } -
    - { isVisible && ReactDOM.createPortal( - this.popperRef.current = ref } - modifiers = { offset ? { - offset : { - offset : `0, ${offset}`, - }, - } : offset }> - { ( { ref, style, scheduleUpdate } ) => - { - this.scheduleUpdate = scheduleUpdate; - - return ( -
    - { popper } -
    ); - } } -
    , - document.getElementById( container ) || document.body, + }, [ onClickOutside ] ); + + const offset = { + 'S' : '8px', + 'M' : '16px', + 'L' : '24px', + 'XL' : '32px', + 'none' : undefined, + }[ popperOffset ]; + + return ( + + referenceRef.current = ref }> + { ( { ref } ) => ( +
    + { children } +
    ) } -
    - ); - } -} + + { isVisible && ReactDOM.createPortal( + popperRef.current = ref } + modifiers = { offset ? { + offset : { + offset : `0, ${offset}`, + }, + } : offset }> + { ( { ref, style, scheduleUpdate } ) => + { + scheduleUpdateRef.current = scheduleUpdate; + + return ( +
    + { popper } +
    ); + } } +
    , + document.getElementById( container ) || document.body, + ) } +
    + ); +}; + + +PopperWrapper.propTypes = +{ + /** + * Reference node to attach popper + */ + children : PropTypes.node, + /** + * id of the DOM element used as container + */ + container : PropTypes.string, + /** + * Show / Hide popper + */ + isVisible : PropTypes.bool, + /** + * pop up width matches reference width + */ + matchRefWidth : PropTypes.bool, + /** + * Click Outside callback: ( e ) => ... + */ + onClickOutside : PropTypes.func, + /** + * Popper content node + */ + popper : PropTypes.node, + /** + * Popper offset + */ + popperOffset : PropTypes.oneOf( [ 'S', 'M', 'L', 'XL', 'none' ] ), + /** + * Popper position + */ + popperPosition : PropTypes.oneOf( [ + 'auto', + 'auto-start', + 'auto-end', + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'left-start', + 'left-end', + 'right', + 'right-start', + 'right-end', + ] ), +}; + +PopperWrapper.defaultProps = +{ + children : undefined, + container : undefined, + isVisible : false, + matchRefWidth : undefined, + onClickOutside : undefined, + popper : undefined, + popperOffset : 'none', + popperPosition : 'auto', +}; + +PopperWrapper.displayComponent = componentName; + +export default PopperWrapper; diff --git a/src/Popup/index.jsx b/src/Popup/index.jsx index fd9d9d09..dff62860 100644 --- a/src/Popup/index.jsx +++ b/src/Popup/index.jsx @@ -7,48 +7,47 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -import { attachEvents } from '../utils'; +import { attachEvents, useTheme } from '../utils'; -export default class Popup extends React.Component + +const componentName = 'Popup'; + +const Popup = props => { - static contextType = ThemeContext; - - static propTypes = { - children : PropTypes.node, - className : PropTypes.string, - cssMap : PropTypes.objectOf( PropTypes.string ), - hasError : PropTypes.bool, - padding : PropTypes.oneOf( [ 'none', 'S', 'M', 'L' ] ), - size : PropTypes.oneOf( [ 'content', 'default' ] ), - }; - - static defaultProps = { - children : undefined, - className : undefined, - cssMap : undefined, - hasError : false, - padding : 'none', - size : 'default', - }; - - static displayName = 'Popup'; - - render() - { - const { - children, - cssMap = createCssMap( this.context.Popup, this.props ), - } = this.props; - - return ( -
    - { children } -
    - ); - } -} + const { children } = props; + + const cssMap = useTheme( componentName, props ); + + return ( +
    + { children } +
    + ); +}; + +Popup.propTypes = { + children : PropTypes.node, + className : PropTypes.string, + cssMap : PropTypes.objectOf( PropTypes.string ), + hasError : PropTypes.bool, + padding : PropTypes.oneOf( [ 'none', 'S', 'M', 'L' ] ), + size : PropTypes.oneOf( [ 'content', 'default' ] ), +}; + +Popup.defaultProps = { + children : undefined, + className : undefined, + cssMap : undefined, + hasError : false, + padding : 'none', + size : 'default', +}; + +Popup.displayName = componentName; + +export default Popup; diff --git a/src/Popup/tests.jsx b/src/Popup/tests.jsx deleted file mode 100644 index 18618f3b..00000000 --- a/src/Popup/tests.jsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import { Popup } from '..'; - - -describe( 'Popup', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - test( 'should have “main” as default className', () => - { - expect( wrapper.prop( 'className' ) ).toEqual( 'main' ); - } ); - - test( 'should render the children of the Popup', () => - { - wrapper.setProps( { children: 'Lightning Strike' } ); - expect( wrapper.children().text() ).toBe( 'Lightning Strike' ); - } ); -} ); diff --git a/src/ProgressBar/index.jsx b/src/ProgressBar/index.jsx index 631b45f6..42d466ba 100644 --- a/src/ProgressBar/index.jsx +++ b/src/ProgressBar/index.jsx @@ -10,40 +10,41 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { useTheme } from '../utils'; -export default class ProgressBar extends React.Component + +const componentName = 'ProgressBar'; + +const ProgressBar = ( props ) => { - static contextType = ThemeContext; - - static propTypes = - { - /** - * Current percentage value - */ - percentage : PropTypes.number, - }; - - static defaultProps = - { - percentage : 0, - }; - - render() - { - const { percentage } = this.props; - - const cssMap = createCssMap( this.context.ProgressBar, this.props ); - - return ( -
    - { percentage > 0 && -
    - } -
    - ); - } -} + const { percentage } = props; + + const cssMap = useTheme( componentName, props ); + + return ( +
    + { percentage > 0 && +
    + } +
    + ); +}; + +ProgressBar.propTypes = +{ + /** + * Current percentage value + */ + percentage : PropTypes.number, +}; + +ProgressBar.defaultProps = +{ + percentage : 0, +}; + +ProgressBar.displayName = componentName; + +export default ProgressBar; diff --git a/src/ScrollBar/driver.js b/src/ScrollBar/driver.js deleted file mode 100644 index 787b4b3e..00000000 --- a/src/ScrollBar/driver.js +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -export default class ScrollBarDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - clickTrack( val ) - { - this.wrapper.prop( 'onClickTrack' )( val ); - return this; - } - - change( val ) - { - this.wrapper.prop( 'onChange' )( val ); - return this; - } - - mouseOver() - { - this.wrapper.simulate( 'mouseOver' ); - return this; - } - - mouseOut() - { - this.wrapper.simulate( 'mouseOut' ); - return this; - } -} diff --git a/src/ScrollBar/index.jsx b/src/ScrollBar/index.jsx index 996e5e92..73299f29 100644 --- a/src/ScrollBar/index.jsx +++ b/src/ScrollBar/index.jsx @@ -14,176 +14,177 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; -import { attachEvents, clamp } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { attachEvents, clamp, useTheme } from '../utils'; -export default class ScrollBar extends React.Component +const componentName = 'ScrollBar'; + +const ScrollBar = props => { - static contextType = ThemeContext; + const { + onChange, + onClickTrack, + orientation, + scrollBoxId, + scrollMax, + scrollMin, + scrollPos, + thumbSize, + } = props; + + const cssMap = useTheme( componentName, props ); + const isVertical = orientation === 'vertical'; + const scrollLength = Math.abs( scrollMax - scrollMin ); + const thumbOffset = + `calc( ${scrollPos / scrollLength} * ( 100% - ${thumbSize} ) )`; - static propTypes = - { - /** - * Extra CSS class name - */ - className : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * orientation of the ScrollBar - */ - orientation : PropTypes.oneOf( [ 'horizontal', 'vertical' ] ), - /** - * scroll position change callback function: ( { scrollPos } ) => ... - */ - onChange : PropTypes.func, - /** - * scroll track click callback function: ( { scrollPos } ) => ... - */ - onClickTrack : PropTypes.func, - /** - * id of the ScrollBox controlled by this ScrollBar - */ - scrollBoxId : PropTypes.string, - /** - * Max scroll value - */ - scrollMax : PropTypes.number, - /** - * Min scroll value - */ - scrollMin : PropTypes.number, - /** - * Current scroll position - */ - scrollPos : PropTypes.number, - /** - * Scroll thumb size (CSS unit) - */ - thumbSize : PropTypes.string, - }; - - static defaultProps = - { - className : undefined, - cssMap : undefined, - onChange : undefined, - onClickTrack : undefined, - orientation : 'horizontal', - scrollBoxId : undefined, - scrollMax : 0, - scrollMin : 0, - scrollPos : 0, - thumbSize : '20px', - }; - - static displayName = 'ScrollBar'; - - render() + const trackRef = React.useRef(); + const thumbRef = React.useRef(); + + const handleClick = useCallback( e => { - const { - cssMap = createCssMap( this.context.ScrollBar, this.props ), - onChange, - onClickTrack, - orientation, - scrollBoxId, - scrollMax, - scrollMin, - scrollPos, - thumbSize, - } = this.props; - - const isVertical = orientation === 'vertical'; - const scrollLength = Math.abs( scrollMax - scrollMin ); - const thumbOffset = - `calc( ${scrollPos / scrollLength} * ( 100% - ${thumbSize} ) )`; + if ( e.target !== e.currentTarget || !onClickTrack ) + { + return; + } - let trackRef = null; - let thumbRef = null; + const trackLength = isVertical ? + trackRef.current.clientHeight : trackRef.current.clientWidth; + const clickOffset = isVertical ? + e.nativeEvent.offsetY : e.nativeEvent.offsetX; - return ( + const scale = scrollLength / trackLength; + const newPos = clickOffset * scale; + + onClickTrack( newPos ); + }, [ isVertical, onClickTrack, scrollLength, trackRef ] ); + + const handleMouseDown = useCallback( md => + { + if ( !onChange ) + { + return; + } + + md.preventDefault(); + const initialMouse = isVertical ? + md.clientY : md.clientX; + const trackLength = isVertical ? + trackRef.current.clientHeight : trackRef.current.clientWidth; + + const thumbLength = isVertical ? + thumbRef.current.clientHeight : thumbRef.current.clientWidth; + + const scale = + scrollLength / ( trackLength - thumbLength ); + + const handleMouseMove = mv => + { + const mouse = isVertical ? mv.clientY : mv.clientX; + const mouseDiff = mouse - initialMouse; + const scrollDiff = mouseDiff * scale; + + const newPos = clamp( + scrollPos + scrollDiff, + scrollMin, scrollMax, + ); + + onChange( newPos ); + }; + + addEventListener( 'mousemove', handleMouseMove ); + addEventListener( 'mouseup', function handleMouseUp() + { + removeEventListener( 'mousemove', handleMouseMove ); + removeEventListener( 'mouseup', handleMouseUp ); + } ); + }, [ isVertical, onChange, scrollLength, thumbRef, trackRef ] ); + + return ( +
    - { - if ( e.target !== e.currentTarget || !onClickTrack ) - { - return; - } - - const trackLength = isVertical ? - trackRef.clientHeight : trackRef.clientWidth; - const clickOffset = isVertical ? - e.nativeEvent.offsetY : e.nativeEvent.offsetX; - - const scale = scrollLength / trackLength; - const newPos = clickOffset * scale; - - onClickTrack( newPos ); - } } - ref = { ref => trackRef = ref } - role = "scrollbar"> -
    - { - if ( !onChange ) - { - return; - } - - md.preventDefault - const initialMouse = isVertical ? - md.clientY : md.clientX; - const trackLength = isVertical ? - trackRef.clientHeight : trackRef.clientWidth; - - const thumbLength = isVertical ? - thumbRef.clientHeight : thumbRef.clientWidth; - - const scale = - scrollLength / ( trackLength - thumbLength ); - - const handleMouseMove = mv => - { - const mouse = isVertical ? mv.clientY : mv.clientX; - const mouseDiff = mouse - initialMouse; - const scrollDiff = mouseDiff * scale; - - const newPos = clamp( - scrollPos + scrollDiff, - scrollMin, scrollMax, - ); - - onChange( newPos ); - }; - - addEventListener( 'mousemove', handleMouseMove ); - addEventListener( 'mouseup', function handleMouseUp() - { - removeEventListener( 'mousemove', handleMouseMove ); - removeEventListener( 'mouseup', handleMouseUp ); - } ); - } } - ref = { ref => thumbRef = ref } - style = { { - [ isVertical ? 'height' : 'width' ] : thumbSize, - [ isVertical ? 'top' : 'left' ] : thumbOffset, - } } /> -
    - ); - } -} + className = { cssMap.thumb } + onMouseDown = { handleMouseDown } + ref = { thumbRef } + style = { { + [ isVertical ? 'height' : 'width' ] : thumbSize, + [ isVertical ? 'top' : 'left' ] : thumbOffset, + } } /> +
    + ); +}; + +ScrollBar.propTypes = +{ + /** + * Extra CSS class name + */ + className : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * orientation of the ScrollBar + */ + orientation : PropTypes.oneOf( [ 'horizontal', 'vertical' ] ), + /** + * scroll position change callback function: ( { scrollPos } ) => ... + */ + onChange : PropTypes.func, + /** + * scroll track click callback function: ( { scrollPos } ) => ... + */ + onClickTrack : PropTypes.func, + /** + * id of the ScrollBox controlled by this ScrollBar + */ + scrollBoxId : PropTypes.string, + /** + * Max scroll value + */ + scrollMax : PropTypes.number, + /** + * Min scroll value + */ + scrollMin : PropTypes.number, + /** + * Current scroll position + */ + scrollPos : PropTypes.number, + /** + * Scroll thumb size (CSS unit) + */ + thumbSize : PropTypes.string, +}; + +ScrollBar.defaultProps = +{ + className : undefined, + cssMap : undefined, + onChange : undefined, + onClickTrack : undefined, + orientation : 'horizontal', + scrollBoxId : undefined, + scrollMax : 0, + scrollMin : 0, + scrollPos : 0, + thumbSize : '20px', +}; + +ScrollBar.displayName = componentName; + +export default ScrollBar; diff --git a/src/ScrollBar/tests.jsx b/src/ScrollBar/tests.jsx deleted file mode 100644 index d951de6f..00000000 --- a/src/ScrollBar/tests.jsx +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { mount, shallow } from 'enzyme'; - -import { ScrollBar } from '..'; - -describe( 'ScrollBar', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - - test( 'should contain exactly two
    ’s', () => - { - expect( wrapper.find( 'div' ) ).toHaveLength( 2 ); - } ); - - describe( 'props', () => - { - describe( 'scrollMax', () => - { - test( 'should be 0 by default', () => - { - expect( ScrollBar.defaultProps.scrollMax ).toEqual( 0 ); - } ); - - test( 'should be passed to the track
    as aria-valuemax', () => - { - wrapper.setProps( { scrollMax: 20 } ); - expect( wrapper.prop( 'aria-valuemax' ) ).toBe( 20 ); - } ); - } ); - - describe( 'scrollMin', () => - { - test( 'should be 0 by default', () => - { - expect( ScrollBar.defaultProps.scrollMin ).toBe( 0 ); - } ); - - test( 'should be passed to the track
    as aria-valuemin', () => - { - wrapper.setProps( { scrollMin: 20 } ); - expect( wrapper.prop( 'aria-valuemin' ) ).toBe( 20 ); - } ); - } ); - - describe( 'scrollPos', () => - { - test( 'should be 0 by default', () => - { - expect( ScrollBar.defaultProps.scrollPos ).toBe( 0 ); - } ); - - test( 'should be passed to the track
    as aria-valuenow', () => - { - wrapper.setProps( { scrollPos: 20 } ); - expect( wrapper.prop( 'aria-valuenow' ) ).toBe( 20 ); - } ); - } ); - } ); -} ); - - -describe( 'ScrollBarDriver', () => -{ - let wrapper; - let driver; - - beforeEach( () => - { - wrapper = mount( ); - driver = wrapper.driver(); - } ); - - describe( 'clickTrack( val )', () => - { - let onClickTrack; - - beforeEach( () => - { - onClickTrack = jest.fn(); - wrapper.setProps( { onClickTrack } ); - driver.clickTrack( 100 ); - } ); - - test( 'should call the onClickTrack prop once', () => - { - expect( onClickTrack ).toHaveBeenCalledTimes( 1 ); - } ); - - test( 'should call the onClickTrack prop with val', () => - { - expect( onClickTrack ).toBeCalledWith( 100 ); - } ); - } ); - - describe( 'change( val )', () => - { - let onChange; - - beforeEach( () => - { - onChange = jest.fn(); - wrapper.setProps( { onChange } ); - driver.change( 100 ); - } ); - - test( 'should trigger onChange callback prop once', () => - { - expect( onChange ).toHaveBeenCalledTimes( 1 ); - } ); - - test( 'should call the onChange prop with val', () => - { - expect( onChange ).toBeCalledWith( 100 ); - } ); - } ); - - describe( 'mouseOver()', () => - { - test( 'should trigger onMouseOver callback prop once', () => - { - const onMouseOver = jest.fn(); - wrapper.setProps( { onMouseOver } ); - - driver.mouseOver(); - expect( onMouseOver ).toHaveBeenCalledTimes( 1 ); - } ); - } ); - - describe( 'mouseOut', () => - { - test( 'should trigger onMouseOut callback prop once', () => - { - const onMouseOut = jest.fn(); - wrapper.setProps( { onMouseOut } ); - - driver.mouseOut(); - expect( onMouseOut ).toHaveBeenCalledTimes( 1 ); - } ); - } ); -} ); diff --git a/src/ScrollBox/driver.js b/src/ScrollBox/driver.js deleted file mode 100644 index fe00e001..00000000 --- a/src/ScrollBox/driver.js +++ /dev/null @@ -1,175 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import { ScrollBar } from 'nessie-ui'; - -import { createCssMap } from '../Theming'; - - -const ERR = { - SCROLL_CANNOT_BE_CLICKED : prop => - `Button cannot be clicked since it doesn't have ${prop} prop`, - CANNOT_SCROLL_IN_DIRECTION : direction => - `Cannot scroll because scroll direction is neither '${direction}' nor \ -'both'`, -}; - - -export default class ScrollBoxDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - get props() - { - return this.wrapper.props(); - } - - get instance() - { - return this.wrapper.instance(); - } - - get cssMap() - { - const { instance } = this; - return instance.props.cssMap || - createCssMap( instance.context.ScrollBox, instance.props ); - } - - get scrollBox() - { - return this.wrapper.find( `.${this.cssMap.inner}` ); - } - - - clickScrollUp() - { - if ( !this.props.scrollUpIsVisible ) - { - throw new Error( ERR - .SCROLL_CANNOT_BE_CLICKED( 'scrollUpIsVisible' ) ); - } - - this.wrapper.find( `.${this.cssMap.iconUp}` ) - .first().simulate( 'click' ); - return this; - } - - clickScrollRight() - { - if ( !this.props.scrollRightIsVisible ) - { - throw new Error( ERR - .SCROLL_CANNOT_BE_CLICKED( 'scrollRightIsVisible' ) ); - } - - this.wrapper.find( `.${this.cssMap.iconRight}` ) - .first().simulate( 'click' ); - return this; - } - - clickScrollDown() - { - if ( !this.props.scrollDownIsVisible ) - { - throw new Error( ERR - .SCROLL_CANNOT_BE_CLICKED( 'scrollDownIsVisible' ) ); - } - - this.wrapper.find( `.${this.cssMap.iconDown}` ) - .first().simulate( 'click' ); - return this; - } - - clickScrollLeft() - { - if ( !this.props.scrollLeftIsVisible ) - { - throw new Error( ERR - .SCROLL_CANNOT_BE_CLICKED( 'scrollLeftIsVisible' ) ); - } - - this.wrapper.find( `.${this.cssMap.iconLeft}` ) - .first().simulate( 'click' ); - return this; - } - - scrollVertical( scrollOffset = 0 ) - { - const node = this.scrollBox.instance(); - - if ( !( this.props.scroll === 'vertical' || - this.props.scroll === 'both' ) ) - { - throw new Error( ERR.CANNOT_SCROLL_IN_DIRECTION( 'vertical' ) ); - } - - node.scrollTop = scrollOffset; - this.scrollBox.simulate( 'scroll' ); - - return this; - } - - scrollHorizontal( scrollOffset = 0 ) - { - const node = this.scrollBox.instance(); - - if ( !( this.props.scroll === 'horizontal' || - this.props.scroll === 'both' ) ) - { - throw new Error( ERR - .CANNOT_SCROLL_IN_DIRECTION( 'horizontal' ) ); - } - - node.scrollLeft = scrollOffset; - this.scrollBox.simulate( 'scroll' ); - - return this; - } - - seekVertical( scrollOffset ) - { - const node = this.scrollBox.instance(); - const scrollBar = this.wrapper.find( ScrollBar ).last(); - - if ( !( this.props.scroll === 'vertical' || - this.props.scroll === 'both' ) ) - { - throw new Error( ERR.CANNOT_SCROLL_IN_DIRECTION( 'vertical' ) ); - } - - node.scrollTop = scrollOffset; - this.scrollBox.simulate( 'scroll' ); - scrollBar.driver().change( scrollOffset ); - - return this; - } - - seekHorizontal( scrollOffset ) - { - const node = this.scrollBox.instance(); - const scrollBar = this.wrapper.find( ScrollBar ).first(); - - if ( !( this.props.scroll === 'vertical' || - this.props.scroll === 'both' ) ) - { - throw new Error( ERR.CANNOT_SCROLL_IN_DIRECTION( 'vertical' ) ); - } - - - node.scrollLeft = scrollOffset; - this.scrollBox.simulate( 'scroll' ); - scrollBar.driver().change( scrollOffset ); - - return this; - } -} diff --git a/src/ScrollBox/index.jsx b/src/ScrollBox/index.jsx index 919690c7..30b7413c 100644 --- a/src/ScrollBox/index.jsx +++ b/src/ScrollBox/index.jsx @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017-2019 dunnhumby Germany GmbH. + * Copyright (c) 2019 dunnhumby Germany GmbH. * All rights reserved. * * This source code is licensed under the MIT license found in the LICENSE file @@ -7,251 +7,82 @@ * */ -import React from 'react'; +import React, { + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import PropTypes from 'prop-types'; import { isEqual } from 'lodash'; -import { IconButton, ScrollBar } from '..'; +import { IconButton, ScrollBar } from '../index'; +import { useTheme } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -import { - attachEvents, - createEventHandler, - generateId, -} from '../utils'; +const componentName = 'ScrollBox'; - -export default class ScrollBox extends React.Component +const ScrollBox = props => { - static contextType = ThemeContext; - - static propTypes = - { - /** - * ScrollBox content - */ - children : PropTypes.node, - /** - * Extra CSS class name - */ - className : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * ScrollBox content width, any CSS length string - */ - contentWidth : PropTypes.string, - /** - * ScrollBox height, any CSS length string - */ - height : PropTypes.string, - /** - * on scroll callback function - */ - onScroll : PropTypes.func, - /** - * scroll down button click callback function - */ - onClickScrollDown : PropTypes.func, - /** - * scroll left button click callback function - */ - onClickScrollLeft : PropTypes.func, - /** - * scroll right button click callback function - */ - onClickScrollRight : PropTypes.func, - /** - * scroll up button click callback function - */ - onClickScrollUp : PropTypes.func, - /** - * Scroll direction - */ - scroll : PropTypes.oneOf( [ - 'horizontal', - 'vertical', - 'both', - ] ), - /** - * Amount of pixels to scroll by - */ - scrollAmount : PropTypes.oneOfType( [ - PropTypes.number, - PropTypes.arrayOf( PropTypes.number ), - ] ), - /** - * ScrollBox padding - */ - padding : PropTypes.oneOfType( [ - PropTypes.oneOf( [ 'none', 'S', 'M', 'L', 'XL', 'XXL' ] ), - PropTypes.arrayOf( PropTypes.oneOf( [ - 'none', - 'S', - 'M', - 'L', - 'XL', - 'XXL', - ] ) ), - ] ), - /** - * Display Scroll bars - */ - scrollBarsAreVisible : PropTypes.bool, - /** - * DOM element "Scrollbox inner" - */ - scrollBoxRef : PropTypes.string, - /** - * Display Scroll down icon - */ - scrollDownIsVisible : PropTypes.bool, - /** - * Display Scroll down icon - */ - scrollIndicatorVariant : PropTypes.oneOf( [ 'circle', 'gradient' ] ), - /** - * Display Scroll left icon - */ - scrollLeftIsVisible : PropTypes.bool, - /** - * Display Scroll right icon - */ - scrollRightIsVisible : PropTypes.bool, - /** - * Display Scroll up icon - */ - scrollUpIsVisible : PropTypes.bool, - }; - - static defaultProps = - { - children : undefined, - className : undefined, - contentWidth : undefined, - cssMap : undefined, - height : undefined, - onClickScrollDown : undefined, - onClickScrollLeft : undefined, - onClickScrollRight : undefined, - onClickScrollUp : undefined, - onScroll : undefined, - padding : 'none', - scroll : 'both', - scrollAmount : undefined, - scrollBarsAreVisible : true, - scrollBoxRef : undefined, - scrollDownIsVisible : false, - scrollIndicatorVariant : 'circle', - scrollLeftIsVisible : false, - scrollRightIsVisible : false, - scrollUpIsVisible : false, - }; - - static displayName = 'ScrollBox'; - - constructor() - { - super(); - - this.state = { - clientHeight : null, - clientWidth : null, - id : generateId( 'ScrollBox' ), - offsetHeight : null, - offsetWidth : null, - scrollHeight : null, - scrollLeft : null, - scrollTop : null, - scrollWidth : null, - }; - - this.handleChangeX = this.handleChangeX.bind( this ); - this.handleChangeY = this.handleChangeY.bind( this ); - this.handleClickTrackX = this.handleClickTrackX.bind( this ); - this.handleClickTrackY = this.handleClickTrackY.bind( this ); - this.handleRef = this.handleRef.bind( this ); - this.handleScroll = this.handleScroll.bind( this ); - } - - componentDidMount() - { - this.setState( this.getNewState() ); - } - - componentDidUpdate() + const [ dimensions, setDimensions ] = useState( { + clientHeight : null, + clientWidth : null, + offsetHeight : null, + offsetWidth : null, + scrollHeight : null, + scrollLeft : null, + scrollTop : null, + scrollWidth : null, + } ); + + const innerRef = useRef( null ); + + useEffect( () => { - const newState = this.getNewState(); + const newDimensions = {}; - if ( !isEqual( newState, this.state ) ) - { - this.setState( newState ); - } - } + Object.keys( dimensions ).forEach( key => + newDimensions[ key ] = innerRef.current[ key ] ); - getInnerStyle() - { - const style = { maxHeight: this.props.height }; - - if ( this.innerRef ) + if ( !isEqual( dimensions, newDimensions ) ) { - const { state } = this; - - // space taken by native scrollbars - const diffX = state.offsetWidth - state.clientWidth; - const diffY = state.offsetHeight - state.clientHeight; - - if ( diffX || diffY ) - { - Object.assign( style, { - width : diffX ? `calc( 100% + ${diffX}px )` : null, - height : diffY ? `calc( 100% + ${diffY}px )` : null, - marginRight : diffX ? `-${diffX}px` : null, - marginBottom : diffY ? `-${diffY}px` : null, - } ); - } - else - { - // compensate for macOS overlaid scrollbars - const compo = 20; - - Object.assign( style, { - padding : `${compo}px`, - margin : `-${compo}px`, - } ); - } + setDimensions( newDimensions ); } - - return style; - } - - getNewState() - { - const newState = {}; - Object.keys( this.state ).forEach( key => - newState[ key ] = this.innerRef[ key ] ); - - return newState; - } - - handleClickScrollButton( dir, e ) + } ); + + const { + children, + contentWidth, + height, + onMouseOut, + onMouseOver, + onClickScrollUp, + onClickScrollDown, + onClickScrollLeft, + onClickScrollRight, + onThumbDragStartX, + onThumbDragEndX, + onThumbDragStartY, + onThumbDragEndY, + scroll, + scrollBoxRef, + scrollAmount, + scrollBarsAreVisible, + scrollIndicatorVariant, + } = props; + + const cssMap = useTheme( componentName, props ); + + const handleClickScrollButton = useCallback( ( dir, e ) => { - const callback = this.props[ `onClickScroll${dir}` ]; + const callback = props[ `onClickScroll${dir}` ]; if ( callback ) { callback( e ); } - const { scrollAmount } = this.props; - if ( dir === 'Up' || dir === 'Down' ) { - const { clientHeight, scrollTop } = this.state; - - let amount = clientHeight; + let amount = dimensions.clientHeight; if ( scrollAmount ) { amount = Array.isArray( scrollAmount ) ? @@ -259,14 +90,12 @@ export default class ScrollBox extends React.Component } const increment = dir === 'Down' ? amount : -amount; - this.innerRef.scrollTop = scrollTop + increment; + innerRef.current.scrollTop = dimensions.scrollTop + increment; } if ( dir === 'Left' || dir === 'Right' ) { - const { clientWidth, scrollLeft } = this.state; - - let amount = clientWidth; + let amount = dimensions.clientWidth; if ( scrollAmount ) { amount = Array.isArray( scrollAmount ) ? @@ -274,236 +103,364 @@ export default class ScrollBox extends React.Component } const increment = dir === 'Right' ? amount : -amount; - this.innerRef.scrollLeft = scrollLeft + increment; + innerRef.current.scrollLeft = dimensions.scrollLeft + increment; } - } + }, [ onClickScrollUp, onClickScrollDown, onClickScrollLeft, + onClickScrollRight, dimensions.clientHeight, dimensions.scrollTop, + dimensions.clientWidth, dimensions.scrollLeft, scrollAmount ] ); - handleClickTrackX( pos ) + const handleClickTrackX = useCallback( ( pos ) => { - const { scrollAmount } = this.props; - const { clientWidth, scrollLeft } = this.state; - - let amount = clientWidth; + let amount = dimensions.clientWidth; if ( scrollAmount ) { amount = Array.isArray( scrollAmount ) ? scrollAmount[ 0 ] : scrollAmount; } - const increment = pos >= scrollLeft ? amount : -amount; - this.innerRef.scrollLeft = scrollLeft + increment; - } + const increment = pos >= dimensions.scrollLeft ? amount : -amount; + innerRef.current.scrollLeft = dimensions.scrollLeft + increment; + }, [ dimensions.clientWidth, dimensions.scrollLeft, scrollAmount ] ); - handleClickTrackY( pos ) + const handleClickTrackY = useCallback( ( pos ) => { - const { scrollAmount } = this.props; - const { clientHeight, scrollTop } = this.state; - - let amount = clientHeight; + let amount = dimensions.clientHeight; if ( scrollAmount ) { amount = Array.isArray( scrollAmount ) ? scrollAmount[ 1 ] : scrollAmount; } - const increment = pos >= scrollTop ? amount : -amount; - this.innerRef.scrollTop = scrollTop + increment; - } + const increment = pos >= dimensions.scrollTop ? amount : -amount; + innerRef.current.scrollTop = dimensions.scrollTop + increment; + }, [ dimensions.clientHeight, dimensions.scrollTop, scrollAmount ] ); - handleChangeX( pos ) + const handleChangeX = useCallback( ( pos ) => { - this.innerRef.scrollLeft = pos; - } + innerRef.current.scrollLeft = pos; + }, [ innerRef.current ] ); - handleChangeY( pos ) + const handleChangeY = useCallback( ( pos ) => { - this.innerRef.scrollTop = pos; - } + innerRef.current.scrollTop = pos; + }, [ innerRef.current ] ); - handleRef( ref ) + const handleRef = useCallback( ( ref ) => { - if ( ref ) + if ( typeof scrollBoxRef === 'function' ) { - if ( this.props.scrollBoxRef ) - { - this.props.scrollBoxRef.current = ref; - } - - this.innerRef = ref; + scrollBoxRef( ref ); } - } - handleScroll() + innerRef.current = ref; + }, [ scrollBoxRef ] ); + + const handleScroll = useCallback( () => { - this.forceUpdate(); - } + setDimensions( dimensions ); + }, [ dimensions ] ); - canScroll( dir ) + const renderScrollButton = useCallback( ( dir ) => { - const { - scrollTop, - scrollLeft, - clientHeight, - clientWidth, - scrollHeight, - scrollWidth, - } = this.state; - - if ( dir === 'Up' && scrollTop === 0 ) + if ( dir === 'Up' && dimensions.scrollTop === 0 ) { return false; } if ( dir === 'Down' && - ( scrollTop + clientHeight ) >= scrollHeight ) + ( dimensions.scrollTop + dimensions.clientHeight ) >= + dimensions.scrollHeight ) { return false; } - if ( dir === 'Left' && scrollLeft === 0 ) + if ( dir === 'Left' && dimensions.scrollLeft === 0 ) { return false; } if ( dir === 'Right' && - ( scrollLeft + clientWidth ) >= scrollWidth ) + ( dimensions.scrollLeft + dimensions.clientWidth ) >= + dimensions.scrollWidth ) { return false; } return true; - } + }, [ dimensions.scrollTop, dimensions.clientHeight, dimensions.scrollHeight, + dimensions.scrollLeft, dimensions.clientWidth, dimensions.scrollWidth, + ] ); + + const style = { maxHeight: height }; - renderScrollBars() + if ( innerRef.current ) { - if ( !this.innerRef ) + // space taken by native scrollbars + const diffX = dimensions.offsetWidth - dimensions.clientWidth; + const diffY = dimensions.offsetHeight - dimensions.clientHeight; + + if ( diffX || diffY ) { - return; + Object.assign( style, { + width : diffX ? `calc( 100% + ${diffX}px )` : null, + height : diffY ? `calc( 100% + ${diffY}px )` : null, + marginRight : diffX ? `-${diffX}px` : null, + marginBottom : diffY ? `-${diffY}px` : null, + } ); } + else + { + // compensate for macOS overlaid scrollbars + const compo = 20; - const { props } = this; - const { - cssMap = createCssMap( this.context.ScrollBox, this.props ), - scroll, - } = props; + Object.assign( style, { + padding : `${compo}px`, + margin : `-${compo}px`, + } ); + } + } - const scrollBars = []; + const scrollBars = []; - if ( scroll !== 'vertical' ) + if ( scroll !== 'vertical' ) + { + if ( dimensions.scrollWidth > dimensions.clientWidth ) { - const { clientWidth, scrollLeft, scrollWidth } = this.state; - - if ( scrollWidth > clientWidth ) - { - scrollBars.push( - , - ); - } + scrollBars.push( ); } + } - if ( scroll !== 'horizontal' ) + if ( scroll !== 'horizontal' ) + { + if ( dimensions.scrollHeight > dimensions.clientHeight ) { - const { clientHeight, scrollHeight, scrollTop } = this.state; - - if ( scrollHeight > clientHeight ) - { - scrollBars.push( - , - ); - } + scrollBars.push( ); } - - return scrollBars; } - renderScrollButtons() + const scrollButtons = []; + + [ 'Up', 'Down', 'Left', 'Right' ].forEach( dir => { - const { props } = this; - const { - cssMap = createCssMap( this.context.ScrollBox, this.props ), - scrollIndicatorVariant, - } = props; - const scrollButtons = []; - - [ 'Up', 'Down', 'Left', 'Right' ].forEach( dir => + if ( props[ `scroll${dir}IsVisible` ] ) { - if ( props[ `scroll${dir}IsVisible` ] && this.canScroll( dir ) ) + if ( renderScrollButton( dir ) ) { - scrollButtons.push( - ( - this.handleClickScrollButton( dir, e ) - ) } />, - ); + scrollButtons.push( + handleClickScrollButton( dir, e ) + } /> ); } - } ); - - return scrollButtons; - } - - render() - { - const { - children, - contentWidth, - cssMap = createCssMap( this.context.ScrollBox, this.props ), - height, - onScroll, - scrollBarsAreVisible, - } = this.props; - - return ( + } + } ); + + return ( +
    + className = { cssMap.inner } + onScroll = { handleScroll } + ref = { handleRef } + style = { style }>
    -
    - { children } -
    + className = { cssMap.content } + style = { contentWidth && { width: contentWidth } }> + { children }
    - { this.renderScrollButtons() } - { scrollBarsAreVisible && this.renderScrollBars() }
    - ); - } -} + { scrollButtons } + { scrollBarsAreVisible && scrollBars } +
    + ); +}; + +ScrollBox.propTypes = +{ + /** + * Extra CSS class name + */ + className : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * ScrollBox content + */ + children : PropTypes.node, + /** + * ScrollBox content width, any CSS length string + */ + contentWidth : PropTypes.string, + /** + * ScrollBox height, any CSS length string + */ + height : PropTypes.string, + /** + * mouseOver callback function + */ + onMouseOver : PropTypes.func, + /** + * mouseOut callback function + */ + onMouseOut : PropTypes.func, + /** + * on Thumb Drag Start horizontal callback function + */ + onThumbDragStartX : PropTypes.func, + /** + * on Thumb Drag End horizontal callback function + */ + onThumbDragEndX : PropTypes.func, + /** + * on Thumb Drag Start vertical callback function + */ + onThumbDragStartY : PropTypes.func, + /** + * on Thumb Drag End vertical callback function + */ + onThumbDragEndY : PropTypes.func, + /** + * scroll down button click callback function + */ + onClickScrollDown : PropTypes.func, + /** + * scroll left button click callback function + */ + onClickScrollLeft : PropTypes.func, + /** + * scroll right button click callback function + */ + onClickScrollRight : PropTypes.func, + /** + * scroll up button click callback function + */ + onClickScrollUp : PropTypes.func, + /** + * Scroll direction + */ + scroll : PropTypes.oneOf( [ + 'horizontal', + 'vertical', + 'both', + ] ), + /** + * Amount of pixels to scroll by + */ + scrollAmount : PropTypes.oneOfType( [ + PropTypes.number, + PropTypes.arrayOf( PropTypes.number ), + ] ), + /** + * ScrollBox padding + */ + padding : PropTypes.oneOfType( [ + PropTypes.oneOf( [ 'none', 'S', 'M', 'L', 'XL', 'XXL' ] ), + PropTypes.arrayOf( PropTypes.oneOf( [ + 'none', + 'S', + 'M', + 'L', + 'XL', + 'XXL', + ] ) ), + ] ), + /** + * Display Scroll bars + */ + scrollBarsAreVisible : PropTypes.bool, + /** + * DOM element "Scrollbox inner" + */ + scrollBoxRef : PropTypes.string, + /** + * Display Scroll down icon + */ + scrollDownIsVisible : PropTypes.bool, + /** + * Display Scroll down icon + */ + scrollIndicatorVariant : PropTypes.oneOf( [ 'circle', 'gradient' ] ), + /** + * Display Scroll left icon + */ + scrollLeftIsVisible : PropTypes.bool, + /** + * Display Scroll right icon + */ + scrollRightIsVisible : PropTypes.bool, + /** + * Display Scroll up icon + */ + scrollUpIsVisible : PropTypes.bool, +}; + +ScrollBox.defaultProps = +{ + children : undefined, + className : undefined, + cssMap : undefined, + contentWidth : undefined, + height : undefined, + onClickScrollDown : undefined, + onClickScrollLeft : undefined, + onClickScrollRight : undefined, + onClickScrollUp : undefined, + onMouseOut : undefined, + onMouseOver : undefined, + onThumbDragStartX : undefined, + onThumbDragEndX : undefined, + onThumbDragStartY : undefined, + onThumbDragEndY : undefined, + padding : 'none', + scroll : 'both', + scrollAmount : undefined, + scrollBarsAreVisible : true, + scrollBoxRef : undefined, + scrollDownIsVisible : false, + scrollIndicatorVariant : 'circle', + scrollLeftIsVisible : false, + scrollRightIsVisible : false, + scrollUpIsVisible : false, +}; + +ScrollBox.displayName = componentName; + +export default ScrollBox; diff --git a/src/ScrollBox/tests.jsx b/src/ScrollBox/tests.jsx deleted file mode 100644 index 001f5200..00000000 --- a/src/ScrollBox/tests.jsx +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { mount } from 'enzyme'; - -import { ScrollBar, ScrollBox } from '..'; - - -describe( 'ScrollBox', () => { - let wrapper; - let instance; - - beforeEach( () => - { - wrapper = mount( ); - instance = wrapper.instance(); - } ); - - test( 'should have exactly one ScrollBar when scroll is "horizontal"', () => - { - wrapper.setProps( { scroll: 'horizontal' } ); - wrapper.setState( { - clientHeight : 100, - scrollHeight : 200, - clientWidth : 100, - scrollWidth : 200, - } ); - - expect( wrapper.find( ScrollBar ) ).toHaveLength( 1 ); - } ); - - test( 'should have exactly one ScrollBar when scroll is "vertical"', () => - { - wrapper.setProps( { scroll: 'vertical' } ); - wrapper.setState( { - clientHeight : 100, - scrollHeight : 200, - clientWidth : 100, - scrollWidth : 200, - } ); - - expect( wrapper.find( ScrollBar ) ).toHaveLength( 1 ); - } ); - - test( 'should have exactly two ScrollBars when scroll is "both"', () => - { - wrapper.setProps( { scroll: 'both' } ); - wrapper.setState( { - clientHeight : 100, - scrollHeight : 200, - clientWidth : 100, - scrollWidth : 200, - } ); - - expect( wrapper.find( ScrollBar ) ).toHaveLength( 2 ); - } ); - - test( 'thumbSize should be set on the scrollBars', () => - { - wrapper.setProps( { scroll: 'both' } ); - wrapper.setState( { - clientHeight : 100, - scrollHeight : 200, - clientWidth : 100, - scrollWidth : 200, - } ); - - expect( wrapper.find( ScrollBar ).first().prop( 'thumbSize' ) ) - .toBe( '50%' ); - - expect( wrapper.find( ScrollBar ).last().prop( 'thumbSize' ) ) - .toBe( '50%' ); - } ); - - describe( 'handleScroll', () => - { - let scrollHandler; - - test( 'forces component to update', () => - { - jest.spyOn( instance, 'forceUpdate' ); - instance.handleScroll(); - expect( instance.forceUpdate ).toBeCalledTimes( 1 ); - } ); - } ); -} ); - - -describe( 'ScrollBoxDriver', () => -{ - let wrapper; - let instance; - - beforeEach( () => - { - wrapper = mount( ); - instance = wrapper.instance(); - instance.innerRef = { - clientHeight : 100, - scrollHeight : 200, - clientWidth : 100, - scrollWidth : 200, - scrollLeft : 50, - scrollTop : 50, - }; - } ); - - describe( 'clickScrollX', () => - { - test( 'invokes onClickScrollUp callback prop', () => - { - const onClickScrollUp = jest.fn(); - wrapper.setProps( { onClickScrollUp, scrollUpIsVisible: true } ); - wrapper.setState(); - - wrapper.driver().clickScrollUp(); - expect( onClickScrollUp ).toBeCalledTimes( 1 ); - } ); - - test( 'invokes onClickScrollRight callback prop', () => - { - const onClickScrollRight = jest.fn(); - wrapper.setProps( { - onClickScrollRight, - scrollRightIsVisible : true, - } ); - wrapper.setState(); - - wrapper.driver().clickScrollRight(); - expect( onClickScrollRight ).toBeCalledTimes( 1 ); - } ); - - - test( 'invokes onClickScrollDown callback prop', () => - { - const onClickScrollDown = jest.fn(); - wrapper.setProps( { - onClickScrollDown, - scrollDownIsVisible : true, - } ); - wrapper.setState(); - - wrapper.driver().clickScrollDown(); - expect( onClickScrollDown ).toBeCalledTimes( 1 ); - } ); - - - test( 'invokes onClickScrollLeft callback prop', () => - { - const onClickScrollLeft = jest.fn(); - wrapper.setProps( { - onClickScrollLeft, - scrollLeftIsVisible : true, - } ); - wrapper.setState(); - - wrapper.driver().clickScrollLeft(); - expect( onClickScrollLeft ).toBeCalledTimes( 1 ); - } ); - - test( 'clicking scrollUp indicator should scroll up', () => - { - wrapper.setProps( { - scrollAmount : 50, - scrollUpIsVisible : true, - } ); - wrapper.setState(); - - wrapper.driver().clickScrollUp(); - expect( instance.innerRef.scrollTop ).toBe( 0 ); - } ); - - test( 'clicking scrollRight indicator should scroll to the right', () => - { - wrapper.setProps( { - scrollAmount : 50, - scrollRightIsVisible : true, - } ); - wrapper.setState(); - - wrapper.driver().clickScrollRight(); - expect( instance.innerRef.scrollLeft ).toBe( 100 ); - } ); - - test( 'clicking scrollDown indicator should scroll to the bottom', () => - { - wrapper.setProps( { - scrollAmount : 50, - scrollDownIsVisible : true, - } ); - wrapper.setState(); - - wrapper.driver().clickScrollDown(); - expect( instance.innerRef.scrollTop ).toBe( 100 ); - } ); - - test( 'clicking scrollLeft indicator should scroll down', () => - { - wrapper.setProps( { - scrollAmount : 50, - scrollLeftIsVisible : true, - } ); - wrapper.setState(); - - wrapper.driver().clickScrollLeft(); - expect( instance.innerRef.scrollLeft ).toBe( 0 ); - } ); - } ); - - describe( 'scrollVertical()', () => - { - test( 'should trigger onScroll()', () => - { - const onScroll = jest.fn(); - wrapper.setProps( { onScroll, scroll: 'vertical' } ); - wrapper.setState(); - - wrapper.driver().scrollVertical( 250 ); - expect( onScroll ).toBeCalledTimes( 1 ); - } ); - - test( 'should throw an error when scroll direction is wrong', () => - { - wrapper.setProps( { scroll: 'horizontal' } ); - wrapper.setState(); - - expect( () => wrapper.driver().scrollVertical( 10 ) ) - .toThrowError( 'Cannot scroll because scroll direction is \ -neither \'vertical\' nor \'both\'' ); - } ); - } ); - - describe( 'scrollHorizontal()', () => - { - test( 'should trigger onScroll()', () => - { - const onScroll = jest.fn(); - wrapper.setProps( { onScroll, scroll: 'horizontal' } ); - wrapper.setState(); - - wrapper.driver().scrollHorizontal( 250 ); - expect( onScroll ).toBeCalledTimes( 1 ); - } ); - - - test( 'should throw an error when scroll direction is wrong', () => - { - wrapper.setProps( { scroll: 'vertical' } ); - wrapper.setState(); - - expect( () => wrapper.driver().scrollHorizontal( 270 ) ) - .toThrowError( 'Cannot scroll because scroll direction is \ -neither \'horizontal\' nor \'both\'' ); - } ); - } ); -} ); diff --git a/src/Spinner/index.jsx b/src/Spinner/index.jsx index dd393705..988b6bff 100644 --- a/src/Spinner/index.jsx +++ b/src/Spinner/index.jsx @@ -7,55 +7,54 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -import { Icon } from '..'; +import { useTheme } from '../utils'; +import { Icon } from '..'; -export default class Spinner extends React.Component + +const componentName = 'Spinner'; + +const Spinner = ( props ) => { - static contextType = ThemeContext; - - static propTypes = - { - /** - * Size of the Spinner - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Size of the Spinner - */ - size : PropTypes.oneOf( [ - 'S', - 'M', - 'L', - 'XL' - ] ), - }; - - static defaultProps = - { - cssMap : undefined, - size : 'M', - }; - - static displayName = 'Spinner'; - - render() - { - const { - cssMap = createCssMap( this.context.Spinner, this.props ), - size - } = this.props; - - return ( - - ); - } -} + const { size } = props; + + const cssMap = useTheme( componentName, props ); + + return ( + + ); +}; + +Spinner.propTypes = +{ + /** + * Size of the Spinner + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Size of the Spinner + */ + size : PropTypes.oneOf( [ + 'S', + 'M', + 'L', + 'XL', + ] ), +}; + +Spinner.defaultProps = +{ + cssMap : undefined, + size : 'M', +}; + +Spinner.displayName = componentName; + +export default Spinner; diff --git a/src/Spinner/tests.jsx b/src/Spinner/tests.jsx deleted file mode 100644 index 3238a8d6..00000000 --- a/src/Spinner/tests.jsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2017-2019 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { shallow } from 'enzyme'; - -import { Spinner } from '..'; - -describe( 'Spinner', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - test( 'should have “main” as default className', () => - { - expect( wrapper.prop( 'className' ) ).toEqual( 'main' ); - } ); -} ); diff --git a/src/SpriteMap/index.jsx b/src/SpriteMap/index.jsx index eabf9b2f..634ff7ae 100644 --- a/src/SpriteMap/index.jsx +++ b/src/SpriteMap/index.jsx @@ -8,10 +8,11 @@ */ import React from 'react'; +import PropTypes from 'prop-types'; import { icons } from 'feather-icons'; -const SpriteMap = ( { id = "nessie" }) => ( +const SpriteMap = ( { id = 'nessie' } ) => ( ( ); +SpriteMap.propTypes = +{ + /** + * Component id + */ + id : PropTypes.string, +}; + +SpriteMap.defaultProps = +{ + id : undefined, +}; + SpriteMap.displayName = 'SpriteMap'; export default SpriteMap; diff --git a/src/Tab/index.jsx b/src/Tab/index.jsx index 166a4b2c..d2a7697f 100644 --- a/src/Tab/index.jsx +++ b/src/Tab/index.jsx @@ -10,58 +10,57 @@ import React from 'react'; import PropTypes from 'prop-types'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { useTheme } from '../utils'; -export default class Tab extends React.Component + +const componentName = 'Tab'; + +const Tab = ( props ) => { - static contextType = ThemeContext; + const cssMap = useTheme( componentName, props ); + const { + children, + label, + } = props; - static propTypes = - { - /** - * Section content - */ - children : PropTypes.node, - /** - * Extra CSS classname - */ - className : PropTypes.string, - /** - * CSS classname map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Label to show in TabButton of this tab - */ - label : PropTypes.string, - }; + return ( +
    + { children } +
    + ); +}; - static defaultProps = - { - children : undefined, - className : undefined, - cssMap : undefined, - label : undefined, - }; +Tab.propTypes = +{ + /** + * Section content + */ + children : PropTypes.node, + /** + * Extra CSS classname + */ + className : PropTypes.string, + /** + * CSS classname map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Label to show in TabButton of this tab + */ + label : PropTypes.string, +}; - static displayName = 'Tab'; +Tab.defaultProps = +{ + children : undefined, + className : undefined, + cssMap : undefined, + label : undefined, +}; - render() - { - const { - children, - cssMap = createCssMap( this.context.Tab, this.props ), - label, - } = this.props; +Tab.displayName = componentName; - return ( -
    - { children } -
    - ); - } -} +export default Tab; diff --git a/src/TabButton/driver.js b/src/TabButton/driver.js deleted file mode 100644 index a9aab462..00000000 --- a/src/TabButton/driver.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -const ERRORS = { - CANNOT_BE_CLICKED : () => - 'TabButton cannot be clicked because it is disabled', -}; - -export default class TabButtonDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - click() - { - if ( this.wrapper.prop( 'isDisabled' ) ) - { - throw new Error( ERRORS.CANNOT_BE_CLICKED() ); - } - - return this.wrapper.simulate( 'click' ); - } -} diff --git a/src/TabButton/index.jsx b/src/TabButton/index.jsx index 2e4f6b14..0218c8e7 100644 --- a/src/TabButton/index.jsx +++ b/src/TabButton/index.jsx @@ -7,104 +7,110 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { + useImperativeHandle, + useRef, + forwardRef, +} from 'react'; +import PropTypes from 'prop-types'; -import { attachEvents } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { attachEvents, useTheme } from '../utils'; -export default class TabButton extends React.Component -{ - static contextType = ThemeContext; - static propTypes = - { - /** - * Callback that receives the native - ); - } -} +
    + + ); +} ); + +TabButton.propTypes = +{ + /** + * Extra CSS class name + */ + className : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Display as active + */ + isActive : PropTypes.bool, + /** + * Display as disabled + */ + isDisabled : PropTypes.bool, + /** + * Label text + */ + label : PropTypes.string, + /** + * Click callback function: ( { tabIndex } ) => ... + */ + onClick : PropTypes.func, + /** + * Subtitle text + */ + subtitle : PropTypes.string, + /** + * Index of this tab + */ + tabIndex : PropTypes.number, +}; + +TabButton.defaultProps = +{ + className : undefined, + cssMap : undefined, + isActive : false, + isDisabled : false, + label : undefined, + onClick : undefined, + subtitle : undefined, + tabIndex : 0, +}; + +TabButton.displayName = componentName; + +export default TabButton; diff --git a/src/TabButton/tests.jsx b/src/TabButton/tests.jsx deleted file mode 100644 index 88200eb8..00000000 --- a/src/TabButton/tests.jsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { mount } from 'enzyme'; - -import { TabButton } from '..'; - -describe( 'TabButton', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = mount( ); - } ); - - describe( 'render()', () => - { - test( 'should render exactly one TabButton', () => - { - expect( wrapper ).toHaveLength( 1 ); - } ); - } ); -} ); - -describe( 'TabButton Driver', () => -{ - let wrapper; - let driver; - - beforeEach( () => - { - wrapper = mount( ); - driver = wrapper.driver(); - } ); - - describe( 'click()', () => - { - test( 'should trigger onClick callback prop once', () => - { - const onClick = jest.fn(); - wrapper.setProps( { - onClick, - } ); - - driver.click(); - - expect( onClick ).toBeCalledTimes( 1 ); - } ); - - test( 'should return error if disabled', () => - { - const onClick = jest.fn(); - wrapper.setProps( { - onClick, - isDisabled : true, - } ); - - expect( () => driver.click() ).toThrowError( 'TabButton cannot be \ -clicked because it is disabled' ); - } ); - } ); -} ); diff --git a/src/Tabs/driver.js b/src/Tabs/driver.js deleted file mode 100644 index bdf0fdd4..00000000 --- a/src/Tabs/driver.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import { TabButton } from 'nessie-ui'; - -export default class TabsDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - change( index = 1 ) - { - return this.clickTab( index ); - } - - clickTab( index = 1 ) - { - this.wrapper.find( TabButton ).at( index ).driver().click(); - return this; - } -} diff --git a/src/Tabs/index.jsx b/src/Tabs/index.jsx index aa744d69..f3a3373e 100644 --- a/src/Tabs/index.jsx +++ b/src/Tabs/index.jsx @@ -7,93 +7,36 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; -import { ScrollBox, TabButton } from '..'; +import { ScrollBox, TabButton } from '..'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -import { attachEvents } from '../utils'; +import { attachEvents, useTheme } from '../utils'; +const componentName = 'Tabs'; -export default class Tabs extends React.Component +const Tabs = ( props ) => { - static contextType = ThemeContext; + const cssMap = useTheme( componentName, props ); - static propTypes = - { - /** - * The active tab index - */ - activeTabIndex : PropTypes.number, - /** - * A set of components - */ - children : PropTypes.node, - /** - * Extra CSS class name - */ - className : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Change callback function: ( { activeTabIndex } ) => ... - */ - onChange : PropTypes.func, - /** - * Click tab callback function: ( { tabIndex } ) => ... - */ - onClickTab : PropTypes.func, - /** - * Tab padding - */ - padding : PropTypes.oneOfType( [ - PropTypes.oneOf( [ 'none', 'S', 'M', 'L', 'XL', 'XXL' ] ), - PropTypes.arrayOf( PropTypes.oneOf( [ - 'none', - 'S', - 'M', - 'L', - 'XL', - 'XXL', - ] ) ), - ] ), - /** - * Secondary controls to add to tabs header - */ - secondaryControls : PropTypes.node, - }; - - static defaultProps = - { - activeTabIndex : undefined, - children : undefined, - className : undefined, - cssMap : undefined, - onChange : undefined, - onClickTab : undefined, - padding : [ 'none', 'M' ], - secondaryControls : undefined, - }; + const { + children, + secondaryControls, + } = props; - static displayName = 'Tabs'; + const [ activeTabIndexState, + setActiveTabIndexState ] = useState( 0 ); + const activeTabIndex = + props.activeTabIndex || activeTabIndexState; - constructor() - { - super(); - - this.state = { activeTabIndex: 0 }; - this.handleClickTab = this.handleClickTab.bind( this ); - } + const tabs = React.Children.toArray( children ); - handleClickTab( { tabIndex }, e ) + const { onClickTab, onChange } = props; + const handleClickTab = useCallback( ( { tabIndex }, e ) => { - const { onClickTab, onChange } = this.props; let nessieDefaultPrevented = false; if ( typeof onClickTab === 'function' ) @@ -112,65 +55,111 @@ export default class Tabs extends React.Component if ( !nessieDefaultPrevented ) { - this.setState( { activeTabIndex: tabIndex } ); + setActiveTabIndexState( tabIndex ); if ( typeof onChange === 'function' ) { onChange( { activeTabIndex: tabIndex } ); } } - } - - render() + }, [ onClickTab, onChange, activeTabIndexState ] ); + const tabButtons = tabs.map( ( tab, tabIndex ) => { - const { - children, - cssMap = createCssMap( this.context.Tabs, this.props ), - secondaryControls, - } = this.props; - - const activeTabIndex = - this.props.activeTabIndex || this.state.activeTabIndex; - - const tabs = React.Children.toArray( children ); - - const tabButtons = tabs.map( ( tab, tabIndex ) => - { - const { isDisabled, label } = tab.props; - const isActive = ( activeTabIndex === tabIndex ); - - return ( - - ); - } ); + const { isDisabled, label } = tab.props; + const isActive = ( activeTabIndex === tabIndex ); return ( -
    -
    - -
    - { tabButtons } -
    -
    - { secondaryControls && -
    - { secondaryControls } -
    - } -
    -
    - { tabs[ activeTabIndex ] } -
    -
    + ); - } -} + } ); + + return ( +
    +
    + +
    + { tabButtons } +
    +
    + { secondaryControls && +
    + { secondaryControls } +
    + } +
    +
    + { tabs[ activeTabIndex ] } +
    +
    + ); +}; + +Tabs.propTypes = +{ + /** + * The active tab index + */ + activeTabIndex : PropTypes.number, + /** + * A set of components + */ + children : PropTypes.node, + /** + * Extra CSS class name + */ + className : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Change callback function: ( { activeTabIndex } ) => ... + */ + onChange : PropTypes.func, + /** + * Click tab callback function: ( { tabIndex } ) => ... + */ + onClickTab : PropTypes.func, + /** + * Tab padding + */ + padding : PropTypes.oneOfType( [ + PropTypes.oneOf( [ 'none', 'S', 'M', 'L', 'XL', 'XXL' ] ), + PropTypes.arrayOf( PropTypes.oneOf( [ + 'none', + 'S', + 'M', + 'L', + 'XL', + 'XXL', + ] ) ), + ] ), + /** + * Secondary controls to add to tabs header + */ + secondaryControls : PropTypes.node, +}; + +Tabs.defaultProps = +{ + activeTabIndex : undefined, + children : undefined, + className : undefined, + cssMap : undefined, + onChange : undefined, + onClickTab : undefined, + padding : [ 'none', 'M' ], + secondaryControls : undefined, +}; + +Tabs.displayName = componentName; + +export default Tabs; diff --git a/src/Tabs/tests.jsx b/src/Tabs/tests.jsx deleted file mode 100644 index 51abd5d1..00000000 --- a/src/Tabs/tests.jsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { shallow, mount } from 'enzyme'; - -import { Tab, TabButton, Tabs } from '..'; - -describe( 'Tabs', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - describe( 'render()', () => - { - test( 'should accept a single Tab as children', () => - { - wrapper.setProps( { children: } ); - expect( wrapper.find( TabButton ) ).toHaveLength( 1 ); - } ); - - test( 'should accept an array of Tabs as children', () => - { - wrapper.setProps( { children: [ , ] } ); - expect( wrapper.find( TabButton ) ).toHaveLength( 2 ); - } ); - } ); -} ); - - -describe( 'TabsDriver', () => -{ - let wrapper; - let driver; - - beforeEach( () => - { - wrapper = mount( ); - driver = wrapper.driver(); - } ); - - describe( 'clickTab( index )', () => - { - test( 'should trigger onClickTab callback prop once on TabButton at \ -index', () => - { - const onClickTab = jest.fn(); - wrapper.setProps( { - onClickTab, - children : [ - , - , - ], - } ); - - driver.clickTab( 1 ); - expect( onClickTab ).toBeCalledTimes( 1 ); - } ); - - test( - 'should throw an expected error if Tab is disabled', - () => - { - wrapper.setProps( { - children : [ - , - , - ], - } ); - - expect( () => driver.clickTab( 0 ) ).toThrowError( 'TabButton \ -cannot be clicked because it is disabled' ); - }, - ); - } ); -} ); diff --git a/src/Tag/driver.js b/src/Tag/driver.js deleted file mode 100644 index 8fede3f8..00000000 --- a/src/Tag/driver.js +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import { IconButton } from 'nessie-ui'; - -export default class TagDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - clickClose() - { - this.wrapper.find( IconButton ).simulate( 'click' ); - return this; - } -} diff --git a/src/Tag/index.jsx b/src/Tag/index.jsx index e22522ee..ffc07332 100644 --- a/src/Tag/index.jsx +++ b/src/Tag/index.jsx @@ -12,101 +12,99 @@ import PropTypes from 'prop-types'; import { IconButton, Text } from '..'; -import { generateId } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { useId, useTheme } from '../utils'; -export default class Tag extends React.Component +const componentName = 'Tag'; + +const Tag = props => { - static contextType = ThemeContext; + const { + children, + isDisabled, + isReadOnly, + label, + onClick, + } = props; - static propTypes = - { - /** - * Tag label (JSX node; overrides label prop) - */ - children : PropTypes.node, - /** - * CSS class name - */ - className : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Component id - */ - id : PropTypes.string, - /** - * Display as disabled - */ - isDisabled : PropTypes.bool, - /** - * Display as read-only - */ - isReadOnly : PropTypes.bool, - /** - * Tag label (string) - */ - label : PropTypes.string, - /** - * onClick callback function for delete icon - */ - onClick : PropTypes.func, - }; + const cssMap = useTheme( componentName, props ); + const id = useId( componentName, props ); + + let labelText = children || label; - static defaultProps = + if ( typeof labelText === 'string' ) { - children : undefined, - className : undefined, - cssMap : undefined, - id : undefined, - isDisabled : false, - isReadOnly : false, - label : undefined, - onClick : undefined, - }; + labelText = ( + + { labelText } + + ); + } - static displayName = 'Tag'; + return ( +
    + { labelText } + + onClick && onClick( { id } ) + } /> +
    + ); +}; - render() - { - const { - children, - cssMap = createCssMap( this.context.Tag, this.props ), - id = generateId( 'Tag' ), - isDisabled, - isReadOnly, - label, - onClick, - } = this.props; +Tag.propTypes = +{ + /** + * Tag label (JSX node; overrides label prop) + */ + children : PropTypes.node, + /** + * CSS class name + */ + className : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Component id + */ + id : PropTypes.string, + /** + * Display as disabled + */ + isDisabled : PropTypes.bool, + /** + * Display as read-only + */ + isReadOnly : PropTypes.bool, + /** + * Tag label (string) + */ + label : PropTypes.string, + /** + * onClick callback function for delete icon + */ + onClick : PropTypes.func, +}; - let labelText = children || label; +Tag.defaultProps = +{ + children : undefined, + className : undefined, + cssMap : undefined, + id : undefined, + isDisabled : false, + isReadOnly : false, + label : undefined, + onClick : undefined, +}; - if ( typeof labelText === 'string' ) - { - labelText = ( - - { labelText } - - ); - } +Tag.displayName = componentName; - return ( -
    - { labelText } - - onClick && onClick( { id } ) - } /> -
    - ); - } -} +export default Tag; diff --git a/src/Tag/tests.jsx b/src/Tag/tests.jsx deleted file mode 100644 index d1e357a4..00000000 --- a/src/Tag/tests.jsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { mount, shallow } from 'enzyme'; - -import { IconButton, Tag, Text } from '../index'; - - -describe( 'Tag', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - test( 'should have “main” as default className', () => - { - expect( wrapper.prop( 'className' ) ).toEqual( 'main' ); - } ); - - test( 'should have an IconButton as a child', () => - { - expect( wrapper.find( IconButton ) ).toHaveLength( 1 ); - } ); - - describe( 'read-only state', () => - { - test( 'should have an IconButton as a child with isReadOnly set', () => - { - wrapper.setProps( { isReadOnly: true } ); - - expect( wrapper.find( IconButton ) ).toHaveLength( 1 ); - expect( wrapper.find( IconButton ).prop( 'isReadOnly' ) ) - .toBeTruthy(); - } ); - } ); - - test( 'should have an IconButton with control theme and close icon as a \ -child', () => - { - expect( wrapper.find( IconButton ).props().iconType ).toBe( 'x' ); - } ); - - test( 'should have a string as a label when prop label is passed', () => - { - const label = 'Tag Label'; - wrapper.setProps( { - label, - } ); - - expect( wrapper.find( Text ).prop( 'children' ) ).toBe( label ); - } ); -} ); - -describe( 'TagDriver', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = mount( ); - } ); - - describe( 'clickClose()', () => - { - test( 'should trigger onClickClose callback prop once', () => - { - const onClick = jest.fn(); - wrapper.setProps( { - onClick, - } ); - - wrapper.driver().clickClose(); - expect( onClick ).toBeCalledTimes( 1 ); - } ); - } ); -} ); diff --git a/src/TagInput/driver.js b/src/TagInput/driver.js deleted file mode 100644 index c066b0a9..00000000 --- a/src/TagInput/driver.js +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Copyright (c) 2018-2019 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import { Tag } from 'nessie-ui'; - -const ERR = { - TAGINPUT_ERR : ( event, state ) => - `TagInput cannot simulate ${event} since it is ${state}`, -}; - -import { createCssMap } from '../Theming'; - - -export default class TagInputDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - this.instance = wrapper.instance() - this.cssMap = createCssMap( - this.wrapper.childAt( 0 ).context.TagInput, - this.instance.props, - ); - } - - clickClose( index = 0 ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR - .TAGINPUT_ERR( 'clickClose', 'disabled' ) ); - } - - if ( this.wrapper.props().isReadOnly ) - { - throw new Error( ERR - .TAGINPUT_ERR( 'clickClose', 'read only' ) ); - } - - this.wrapper.find( Tag ).at( index ).driver().clickClose(); - return this; - } - - blur() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TAGINPUT_ERR( 'blur', 'disabled' ) ); - } - - this.wrapper.find( `.${this.cssMap.input}` ).simulate( 'blur' ); - return this; - } - - change( val ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TAGINPUT_ERR( 'change', 'disabled' ) ); - } - - if ( this.wrapper.props().isReadOnly ) - { - throw new Error( ERR.TAGINPUT_ERR( 'change', 'read only' ) ); - } - - this.wrapper.find( `.${this.cssMap.input}` ).simulate( 'change' ); - return this; - } - - focus() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TAGINPUT_ERR( 'focus', 'disabled' ) ); - } - - this.wrapper.find( `.${this.cssMap.input}` ).simulate( 'focus' ); - return this; - } - - keyPress( keyCode ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TAGINPUT_ERR( 'keyPress', 'disabled' ) ); - } - - this.wrapper.find( `.${this.cssMap.input}` ) - .simulate( 'keyPress', { keyCode, which: keyCode } ); - return this; - } - - keyDown( keyCode ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TAGINPUT_ERR( 'keyDown', 'disabled' ) ); - } - - this.wrapper.find( `.${this.cssMap.input}` ) - .simulate( 'keyDown', { keyCode, which: keyCode } ); - return this; - } - - keyUp( keyCode ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TAGINPUT_ERR( 'keyUp', 'disabled' ) ); - } - - this.wrapper.find( `.${this.cssMap.input}` ) - .simulate( 'keyUp', { keyCode, which: keyCode } ); - return this; - } - - mouseOver() - { - this.wrapper.simulate( 'mouseOver' ); - return this; - } - - mouseOut() - { - this.wrapper.simulate( 'mouseOut' ); - return this; - } -} diff --git a/src/TagInput/index.jsx b/src/TagInput/index.jsx index 27c16558..4d11d9e4 100644 --- a/src/TagInput/index.jsx +++ b/src/TagInput/index.jsx @@ -7,24 +7,31 @@ * */ -import React, { Children } from 'react'; +import React, { + Children, + useState, + useRef, + useMemo, + useImperativeHandle, + forwardRef, +} from 'react'; import PropTypes from 'prop-types'; import { escapeRegExp } from 'lodash'; import { ListBox, ScrollBox, -} from '../index'; +} from '..'; + import Popup from '../Popup'; import PopperWrapper from '../PopperWrapper'; import { attachEvents, callMultiple, - generateId, + useId, + useTheme, } from '../utils'; import { buildTagsFromValues } from './utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; import { addPrefix } from '../ComboBox/utils'; @@ -69,431 +76,371 @@ function normalizeOptions( options ) opt : { id: opt, text: opt } ) ); } -export default class TagInput extends React.Component -{ - static contextType = ThemeContext; - - static propTypes = - { - /** - * Node containing Tag components ( overrides value prop ) - */ - children : PropTypes.node, - /** - * CSS class name - */ - className : PropTypes.string, - /** - * id of the DOM element used as container for popup listbox - */ - container : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Initial value (when component is uncontrolled) - */ - defaultValue : PropTypes.arrayOf( PropTypes.string ), - /** - * Display as error/invalid - */ - hasError : PropTypes.bool, - /** - * Component id - */ - id : PropTypes.string, - /** - * Display as disabled - */ - isDisabled : PropTypes.bool, - /** - * Display as read-only - */ - isReadOnly : PropTypes.bool, - /** - * Change callback function - */ - onChange : PropTypes.func, - /** - * Placeholder text - */ - placeholder : PropTypes.string, - /** - * Tag suggestions - */ - suggestions : PropTypes.arrayOf( PropTypes.string ), - /** - * Array of strings to build Tag components - */ - value : PropTypes.arrayOf( PropTypes.string ), - }; - - static defaultProps = - { - children : undefined, - className : undefined, - container : undefined, - cssMap : undefined, - defaultValue : undefined, - hasError : false, - id : undefined, - isDisabled : false, - isReadOnly : false, - onChange : undefined, - placeholder : undefined, - suggestions : undefined, - value : undefined, - }; - static displayName = 'TagInput'; +const componentName = 'TagInput'; - inputRef = React.createRef(); - outerRef = React.createRef(); +const TagInput = forwardRef( ( props, ref ) => +{ + const inputRef = useRef(); + const outerRef = useRef(); + + const cssMap = useTheme( componentName, props ); + const id = useId( componentName, props ); + + const { + children, + container, + hasError, + isDisabled, + isReadOnly, + placeholder, + } = props; + + + const [ activeOption, setActiveOption ] = useState( undefined ); + const [ filteredOptionsState, + setFilteredOptionsState ] = useState( undefined ); + const [ inputValue, setInputValue ] = useState( '' ); + const [ isOpen, setIsOpen ] = useState( false ); + const [ valueState, + setValueState ] = useState( Array.isArray( props.defaultValue ) ? + props.defaultValue : [] ); + + const options = + useMemo( () => ( + normalizeOptions( props.suggestions ) || [] + ), [ props.suggestions ] ); + + const filteredOptions = + useMemo( () => ( + filteredOptionsState || options + ), [ options, filteredOptionsState ] ); + + const value = useMemo( () => ( + ( Array.isArray( props.value ) && props.value ) || valueState + ), [ props.value, valueState ] ); + + useImperativeHandle( ref, () => ( { + focus : () => + { + inputRef.current.focus(); + }, + } ) ); - constructor( props ) + const enterNewTag = () => { - super( props ); - - this.state = { - activeOption : undefined, - filteredOptions : undefined, - id : undefined, - inputValue : '', - isOpen : false, - value : Array.isArray( props.defaultValue ) ? - props.defaultValue : [], - }; - - this.handleBlur = this.handleBlur.bind( this ); - this.handleChangeInput = this.handleChangeInput.bind( this ); - this.handleClickClose = this.handleClickClose.bind( this ); - this.handleClickOption = this.handleClickOption.bind( this ); - this.handleFocus = this.handleFocus.bind( this ); - this.handleKeyDown = this.handleKeyDown.bind( this ); - this.handleMouseOutOption = this.handleMouseOutOption.bind( this ); - this.handleMouseOverOption = this.handleMouseOverOption.bind( this ); - } - - static getDerivedStateFromProps( props, state ) - { - const options = - normalizeOptions( props.suggestions ) || state.options || []; - - return { - filteredOptions : state.filteredOptions || options, - id : props.id || state.id || generateId( 'TagInput' ), - options, - value : ( Array.isArray( props.value ) && props.value ) || - state.value, - }; - } - - enterNewTag() - { - this.setState( ( { - activeOption, - filteredOptions, - inputValue, - value, - } ) => - { - let newTag; + let newTag; - if ( !value.find( tag => tag === inputValue ) ) + if ( !value.find( tag => tag === inputValue ) ) + { + if ( activeOption ) { - if ( activeOption ) - { - const option = - getOption( activeOption, filteredOptions ); + const option = + getOption( activeOption, filteredOptions ); - newTag = value.indexOf( activeOption ) !== -1 ? - inputValue : option.text; - } - else if ( inputValue ) - { - newTag = inputValue; - } + newTag = value.indexOf( activeOption ) !== -1 ? + inputValue : option.text; } - - let newTags = value; - if ( newTag ) + else if ( inputValue ) { - newTags = [ ...value, newTag ]; - - const { onChange } = this.props; - if ( typeof onChange === 'function' ) - { - onChange( { value: newTags } ); - } + newTag = inputValue; } + } - return { - activeOption : undefined, - filteredOptions : this.filterOptions( newTags ), - inputValue : '', - value : newTags, - }; - } ); - } + let newTags = value; + if ( newTag ) + { + newTags = [ ...value, newTag ]; - focus() - { - this.inputRef.current.focus(); - } + const { onChange } = props; + if ( typeof onChange === 'function' ) + { + onChange( { value: newTags } ); + } + } + setActiveOption( undefined ); + setFilteredOptionsState( filterOptions( newTags ) ); + setInputValue( '' ); + setValueState( newTags ); + }; - filterOptions( tags ) - { - return this.state.options.filter( option => + const filterOptions = ( tags ) => + options.filter( option => !tags.includes( option.text ) ); - } - handleBlur() + const handleBlur = () => { - this.setState( { isOpen: false } ); - this.enterNewTag(); - } + setIsOpen( false ); + enterNewTag(); + }; - handleChangeInput( e ) + const handleChangeInput = ( e ) => { e.stopPropagation(); - const { value } = e.target; - - this.setState( ( { options } ) => - { - const filteredOptions = options.filter( ( { text } ) => - text.match( new RegExp( escapeRegExp( value ), 'i' ) ) ); + const { value: scopedValue } = e.target; + const filteredOptionsScoped = options.filter( ( { text } ) => + text.match( new RegExp( escapeRegExp( scopedValue ), 'i' ) ) ); - const activeOption = ( value && filteredOptions.length ) ? - filteredOptions[ 0 ].id : undefined; + const activeOptionScoped = ( scopedValue && + filteredOptionsScoped.length ) ? + filteredOptionsScoped[ 0 ].id : undefined; - return { - activeOption, - filteredOptions, - inputValue : value, - }; - } ); - } + setActiveOption( activeOptionScoped ); + setFilteredOptionsState( filteredOptionsScoped ); + setInputValue( scopedValue ); + }; - handleClickClose( { id } ) + const handleClickClose = ( { id: scopedId } ) => { - this.setState( ( { value } ) => - { - const newTags = value.filter( tag => tag !== id ); + const newTags = value.filter( tag => tag !== scopedId ); - const { onChange } = this.props; - if ( typeof onChange === 'function' ) - { - onChange( { value: newTags } ); - } + const { onChange } = props; + if ( typeof onChange === 'function' ) + { + onChange( { value: newTags } ); + } - return { - value : newTags, - filteredOptions : this.filterOptions( newTags ), - }; - } ); - } + setValueState( newTags ); + setFilteredOptionsState( filterOptions( newTags ) ); + }; - handleClickOption( { id } ) + const handleClickOption = ( { id: scopedId } ) => { - this.setState( ( { filteredOptions, value } ) => + const option = getOption( scopedId, filteredOptions ); + const newTags = [ ...value, option.text ]; + const { onChange } = props; + if ( typeof onChange === 'function' ) { - const option = getOption( id, filteredOptions ); - const newTags = [ ...value, option.text ]; - - const { onChange } = this.props; - if ( typeof onChange === 'function' ) - { - onChange( { value: newTags } ); - } - - return { - activeOption : undefined, - filteredOptions : this.filterOptions( newTags ), - inputValue : '', - value : newTags, - }; - } ); - } + onChange( { value: newTags } ); + } + setActiveOption( undefined ); + setFilteredOptionsState( filterOptions( newTags ) ); + setInputValue( '' ); + setValueState( newTags ); + }; - handleFocus() + const handleFocus = () => { - this.setState( { isOpen: true } ); - } + setIsOpen( true ); + }; - handleKeyDown( e ) + const handleKeyDown = ( e ) => { const { key } = e; if ( key === 'Backspace' ) { - this.setState( ( { inputValue, value } ) => + let newTags = value; + if ( !inputValue ) { - let newTags = value; - if ( !inputValue ) - { - newTags = value.slice( 0, -1 ); + newTags = value.slice( 0, -1 ); - const { onChange } = this.props; - if ( typeof onChange === 'function' ) - { - onChange( { value: newTags } ); - } + const { onChange } = props; + if ( typeof onChange === 'function' ) + { + onChange( { value: newTags } ); } - - return { - value : newTags, - filteredOptions : this.filterOptions( newTags ), - }; - } ); + } + setValueState( newTags ); + setFilteredOptionsState( filterOptions( newTags ) ); } - else if ( key === 'Enter' ) + if ( key === 'Enter' ) { - this.enterNewTag(); + enterNewTag(); } else if ( key === 'ArrowUp' || key === 'ArrowDown' ) { e.preventDefault(); - this.setState( ( { activeOption, isOpen } ) => + + if ( isOpen && filteredOptions.length ) { - const { filteredOptions } = this.state; + const minIndex = 0; + const maxIndex = filteredOptions.length - 1; - if ( isOpen && filteredOptions.length ) - { - const minIndex = 0; - const maxIndex = filteredOptions.length - 1; + let activeIndex = getIndex( activeOption, filteredOptions ); - let activeIndex = getIndex( activeOption, filteredOptions ); + activeIndex = key === 'ArrowUp' ? + Math.max( activeIndex - 1, minIndex ) : + Math.min( activeIndex + 1, maxIndex ); - activeIndex = key === 'ArrowUp' ? - Math.max( activeIndex - 1, minIndex ) : - Math.min( activeIndex + 1, maxIndex ); - return { - activeOption : filteredOptions[ activeIndex ].id, - }; - } + setActiveOption( filteredOptions[ activeIndex ].id ); + } - return { isOpen: true }; - } ); + setIsOpen( true ); } - } + }; - handleMouseOutOption() + const handleMouseOutOption = () => { - this.setState( { activeOption: undefined } ); - } + setActiveOption( undefined ); + }; - handleMouseOverOption( { id } ) + const handleMouseOverOption = ( { id: scopedId } ) => { - this.setState( { activeOption: id } ); - } + setActiveOption( scopedId ); + }; - render() + const listBoxOptions = filteredOptions.reduce( ( result, opt ) => { - const { - children, - container, - cssMap = createCssMap( this.context.TagInput, this.props ), - hasError, - isDisabled, - isReadOnly, - placeholder, - } = this.props; - - const { - activeOption, - filteredOptions, - id, - inputValue, - isOpen, - value, - } = this.state; - - const listBoxOptions = filteredOptions.reduce( ( result, opt ) => + if ( !value.find( tag => tag === opt.id ) ) { - if ( !value.find( tag => tag === opt.id ) ) - { - result.push( opt ); - } - return result; - }, [] ); - - const dropdownContent = listBoxOptions.length > 0 && ( - - - - ); - - let items = children ? - Children.toArray( children ) : buildTagsFromValues( value ); - - items = items.map( tag => ( - React.cloneElement( tag, { - ...tag.props, - isDisabled : isDisabled || tag.props.isDisabled, - isReadOnly : isReadOnly || tag.props.isReadOnly, - onClick : this.handleClickClose, - } ) - ) ); - - const popperChildren = ( - - ); - - const popperPopup = ( - - { dropdownContent } - - ); - - return ( - 0 && isOpen } - matchRefWidth - popper = { popperPopup } - popperOffset = "S" - popperPosition = "bottom"> - { popperChildren } - - ); - } -} + result.push( opt ); + } + return result; + }, [] ); + + const dropdownContent = listBoxOptions.length > 0 && ( + + + + ); + + let items = children ? + Children.toArray( children ) : buildTagsFromValues( value ); + + items = items.map( tag => ( + React.cloneElement( tag, { + ...tag.props, + isDisabled : isDisabled || tag.props.isDisabled, + isReadOnly : isReadOnly || tag.props.isReadOnly, + onClick : handleClickClose, + } ) + ) ); + + const popperChildren = ( + + ); + + const popperPopup = ( + + { dropdownContent } + + ); + + return ( + 0 && isOpen } + matchRefWidth + popper = { popperPopup } + popperOffset = "S" + popperPosition = "bottom"> + { popperChildren } + + ); +} ); + +TagInput.propTypes = +{ + /** + * Node containing Tag components ( overrides value prop ) + */ + children : PropTypes.node, + /** + * CSS class name + */ + className : PropTypes.string, + /** + * id of the DOM element used as container for popup listbox + */ + container : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Initial value (when component is uncontrolled) + */ + defaultValue : PropTypes.arrayOf( PropTypes.string ), + /** + * Display as error/invalid + */ + hasError : PropTypes.bool, + /** + * Component id + */ + id : PropTypes.string, + /** + * Display as disabled + */ + isDisabled : PropTypes.bool, + /** + * Display as read-only + */ + isReadOnly : PropTypes.bool, + /** + * Change callback function + */ + onChange : PropTypes.func, + /** + * Placeholder text + */ + placeholder : PropTypes.string, + /** + * Tag suggestions + */ + suggestions : PropTypes.arrayOf( PropTypes.string ), + /** + * Array of strings to build Tag components + */ + value : PropTypes.arrayOf( PropTypes.string ), +}; + +TagInput.defaultProps = +{ + children : undefined, + className : undefined, + container : undefined, + cssMap : undefined, + defaultValue : undefined, + hasError : false, + id : undefined, + isDisabled : false, + isReadOnly : false, + onChange : undefined, + placeholder : undefined, + suggestions : undefined, + value : undefined, +}; + +TagInput.displayName = componentName; + +export default TagInput; diff --git a/src/TagInput/tests.jsx b/src/TagInput/tests.jsx deleted file mode 100644 index d9044060..00000000 --- a/src/TagInput/tests.jsx +++ /dev/null @@ -1,522 +0,0 @@ -/* - * Copyright (c) 2017-2019 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { mount, shallow } from 'enzyme'; - -import styles from './tagInput.css'; -import { Tag, TagInput } from '../index'; - -describe( 'TagInput', () => -{ - let wrapper; - let instance; - - beforeEach( () => - { - wrapper = mount( ); - instance = wrapper.instance(); - } ); - - describe( 'constructor( props )', () => - { - test( 'should have name TagInput', () => - { - expect( instance.constructor.name ).toBe( 'TagInput' ); - } ); - } ); - - describe( 'render()', () => - { - test( 'should contain exactly one input', () => - { - expect( wrapper.find( 'input' ) ).toHaveLength( 1 ); - } ); - } ); - - test( 'should have Tag components when passed as children', () => - { - wrapper.setProps( { - children : [ - , - , - ], - } ); - - expect( wrapper.find( Tag ) ).toHaveLength( 2 ); - } ); - - test( 'should have Tag components when passed as tags prop', () => - { - wrapper.setProps( { - value : [ 'TagLabelString 1', 'TagLabelString 2' ], - } ); - - expect( wrapper.find( Tag ) ).toHaveLength( 2 ); - } ); - - describe( 'readOnly state', () => - { - test( 'input should receive readonly', () => - { - wrapper.setProps( { isReadOnly: true } ); - - expect( wrapper.find( 'input' ).prop( 'readOnly' ) ).toBe( true ); - } ); - } ); - - describe( 'disabled state', () => - { - test( 'input should receive isDisabled as "disabled"', () => - { - wrapper.setProps( { isDisabled: true } ); - - expect( wrapper.find( 'input' ).prop( 'disabled' ) ).toBe( true ); - } ); - } ); -} ); - - -// describe( 'TagInputDriver', () => -// { -// let wrapper; -// let driver; -// -// beforeEach( () => -// { -// wrapper = mount( ); -// driver = wrapper.driver(); -// } ); -// -// describe( 'blur()', () => -// { -// test( 'should call blur exactly once', () => -// { -// const onBlur = jest.fn(); -// wrapper.setProps( { -// onBlur, -// children : [ -// , -// , -// ], -// } ); -// -// driver.blur(); -// expect( onBlur ).toBeCalledTimes( 1 ); -// } ); -// -// -// describe( 'isDisabled', () => -// { -// test( 'throws the expected error when isDisabled', () => -// { -// const expectedError = -// 'TagInput cannot simulate blur since it is disabled'; -// wrapper.setProps( { isDisabled: true } ); -// -// expect( () => driver.blur() ).toThrow( expectedError ); -// } ); -// -// test( 'should not trigger onBlur when isDisabled', () => -// { -// const onBlur = jest.fn(); -// wrapper.setProps( { onBlur, isDisabled: true } ); -// -// try -// { -// driver.blur(); -// } -// catch ( error ) -// { -// expect( onBlur ).not.toBeCalled(); -// } -// } ); -// } ); -// } ); -// -// -// describe( 'focus()', () => -// { -// test( 'should call focus exactly once', () => -// { -// const onFocus = jest.fn(); -// wrapper.setProps( { -// onFocus, -// children : [ -// , -// , -// ], -// } ); -// -// driver.focus(); -// expect( onFocus ).toBeCalledTimes( 1 ); -// } ); -// -// -// describe( 'isDisabled', () => -// { -// test( 'throws the expected error when isDisabled', () => -// { -// const expectedError = -// 'TagInput cannot simulate focus since it is disabled'; -// wrapper.setProps( { isDisabled: true } ); -// -// expect( () => driver.focus() ).toThrow( expectedError ); -// } ); -// -// test( 'should not trigger onFocus when isDisabled', () => -// { -// const onFocus = jest.fn(); -// wrapper.setProps( { onFocus, isDisabled: true } ); -// -// try -// { -// driver.focus(); -// } -// catch ( error ) -// { -// expect( onFocus ).not.toBeCalled(); -// } -// } ); -// } ); -// } ); -// -// -// describe( 'change()', () => -// { -// test( 'should call change exactly once', () => -// { -// const onChangeInput = jest.fn(); -// wrapper.setProps( { -// onChangeInput, -// children : [ -// , -// , -// ], -// } ); -// -// driver.change(); -// expect( onChangeInput ).toBeCalled; -// } ); -// -// -// describe( 'isDisabled', () => -// { -// test( 'throws the expected error when isDisabled', () => -// { -// const expectedError = -// 'TagInput cannot simulate change since it is disabled'; -// wrapper.setProps( { isDisabled: true } ); -// -// expect( () => driver.change() ).toThrow( expectedError ); -// } ); -// -// test( 'should not trigger onChange when isDisabled', () => -// { -// const onChange = jest.fn(); -// wrapper.setProps( { onChange, isDisabled: true } ); -// -// try -// { -// driver.change(); -// } -// catch ( error ) -// { -// expect( onChange ).not.toBeCalled(); -// } -// } ); -// } ); -// -// -// describe( 'isReadOnly', () => -// { -// test( 'throws the expected error when isReadOnly', () => -// { -// const expectedError = -// 'TagInput cannot simulate change since it is read only'; -// wrapper.setProps( { isReadOnly: true } ); -// -// expect( () => driver.change() ).toThrow( expectedError ); -// } ); -// -// test( 'should not trigger onChange when isReadOnly', () => -// { -// const onChange = jest.fn(); -// wrapper.setProps( { onChange, isReadOnly: true } ); -// -// try -// { -// driver.change(); -// } -// catch ( error ) -// { -// expect( onChange ).not.toBeCalled(); -// } -// } ); -// } ); -// } ); -// -// -// describe( 'clickClose( index )', () => -// { -// test( 'should call onClickClose exactly once', () => -// { -// const onClickClose = jest.fn(); -// wrapper.setProps( { -// onClickClose, -// children : [ -// , -// , -// ], -// } ); -// -// driver.clickClose( 1 ); -// expect( onClickClose ).toBeCalledTimes( 1 ); -// } ); -// -// -// describe( 'isDisabled', () => -// { -// test( 'throws the expected error when isDisabled', () => -// { -// const expectedError = -// 'TagInput cannot simulate clickClose since it is disabled'; -// wrapper.setProps( { isDisabled: true } ); -// -// expect( () => driver.clickClose() ) -// .toThrow( expectedError ); -// } ); -// -// test( 'should not trigger onClickClose when isDisabled', () => -// { -// const onClickClose = jest.fn(); -// wrapper.setProps( { onClickClose, isDisabled: true } ); -// -// try -// { -// driver.clickClose(); -// } -// catch ( error ) -// { -// expect( onClickClose ).not.toBeCalled(); -// } -// } ); -// } ); -// -// -// describe( 'isReadOnly', () => -// { -// test( 'throws the expected error when isReadOnly', () => -// { -// const expectedError = -// 'TagInput cannot simulate clickClose since it is read only'; -// wrapper.setProps( { isReadOnly: true } ); -// -// expect( () => driver.clickClose() ) -// .toThrow( expectedError ); -// } ); -// -// test( 'should not trigger onClickClose when isReadOnly', () => -// { -// const onClickClose = jest.fn(); -// wrapper.setProps( { onClickClose, isReadOnly: true } ); -// -// try -// { -// driver.clickClose(); -// } -// catch ( error ) -// { -// expect( onClickClose ).not.toBeCalled(); -// } -// } ); -// } ); -// } ); -// -// -// describe( 'keyPress()', () => -// { -// test( 'should trigger onKeyPress callback prop once', () => -// { -// const onKeyPress = jest.fn(); -// wrapper.setProps( { -// onKeyPress, -// children : [ -// , -// , -// ], -// } ); -// -// driver.keyPress(); -// expect( onKeyPress ).toBeCalledTimes( 1 ); -// } ); -// -// -// describe( 'isDisabled', () => -// { -// test( 'throws the expected error when isDisabled', () => -// { -// const expectedError = -// 'TagInput cannot simulate keyPress since it is disabled'; -// wrapper.setProps( { isDisabled: true } ); -// -// expect( () => driver.keyPress() ).toThrow( expectedError ); -// } ); -// -// test( 'should not trigger onKeyPress when isDisabled', () => -// { -// const onKeyPress = jest.fn(); -// wrapper.setProps( { onKeyPress, isDisabled: true } ); -// -// try -// { -// driver.keyPress(); -// } -// catch ( error ) -// { -// expect( onKeyPress ).not.toBeCalled(); -// } -// } ); -// } ); -// } ); -// -// -// describe( 'keyUp()', () => -// { -// test( 'should trigger onKeyUp callback prop once', () => -// { -// const onKeyUp = jest.fn(); -// wrapper.setProps( { -// onKeyUp, -// children : [ -// , -// , -// ], -// } ); -// -// driver.keyUp(); -// expect( onKeyUp ).toBeCalledTimes( 1 ); -// } ); -// -// -// describe( 'isDisabled', () => -// { -// test( 'throws the expected error when isDisabled', () => -// { -// const expectedError = -// 'TagInput cannot simulate keyUp since it is disabled'; -// wrapper.setProps( { isDisabled: true } ); -// -// expect( () => driver.keyUp() ).toThrow( expectedError ); -// } ); -// -// test( 'should not trigger onKeyUp when isDisabled', () => -// { -// const onKeyUp = jest.fn(); -// wrapper.setProps( { onKeyUp, isDisabled: true } ); -// -// try -// { -// driver.keyUp(); -// } -// catch ( error ) -// { -// expect( onKeyUp ).not.toBeCalled(); -// } -// } ); -// } ); -// } ); -// -// -// describe( 'keyDown()', () => -// { -// test( 'should trigger onKeyDown callback prop once', () => -// { -// const onKeyDown = jest.fn(); -// wrapper.setProps( { -// onKeyDown, -// children : [ -// , -// , -// ], -// } ); -// -// driver.keyDown(); -// expect( onKeyDown ).toBeCalledTimes( 1 ); -// } ); -// -// -// describe( 'isDisabled', () => -// { -// test( 'throws the expected error when isDisabled', () => -// { -// const expectedError = -// 'TagInput cannot simulate keyDown since it is disabled'; -// wrapper.setProps( { isDisabled: true } ); -// -// expect( () => driver.keyDown() ).toThrow( expectedError ); -// } ); -// -// test( 'should not trigger onKeyDown when isDisabled', () => -// { -// const onKeyDown = jest.fn(); -// wrapper.setProps( { onKeyDown, isDisabled: true } ); -// -// try -// { -// driver.keyDown(); -// } -// catch ( error ) -// { -// expect( onKeyDown ).not.toBeCalled(); -// } -// } ); -// } ); -// } ); -// -// -// describe( 'mouseOut()', () => -// { -// test( 'should call onMouseOut exactly once', () => -// { -// const onMouseOut = jest.fn(); -// wrapper.setProps( { -// onMouseOut, -// children : [ -// , -// , -// ], -// } ); -// -// driver.mouseOut(); -// expect( onMouseOut ).toBeCalledTimes( 1 ); -// } ); -// } ); -// -// -// describe( 'mouseOver()', () => -// { -// test( 'should call onMouseOver exactly once', () => -// { -// const onMouseOver = jest.fn(); -// wrapper.setProps( { -// onMouseOver, -// children : [ -// , -// , -// ], -// } ); -// -// driver.mouseOver(); -// expect( onMouseOver ).toBeCalledTimes( 1 ); -// } ); -// } ); -// } ); diff --git a/src/Testing/ComponentDriver/README.md b/src/Testing/ComponentDriver/README.md deleted file mode 100644 index 253afa4e..00000000 --- a/src/Testing/ComponentDriver/README.md +++ /dev/null @@ -1,118 +0,0 @@ -Component Driver -================ - -This module provides a minimal [Enzyme](https://github.com/airbnb/enzyme) -extension allowing custom component drivers to be configured for each component -constructor. - - -Usage Example -------------- - -### Step 1: create component driver classes. - -The constructor accepts one `wrapper` argument, which is the Enzyme `ReactWrapper` -instance for the component your driver is for (we'll see later how to actually -link the driver class to the component). - -Note that the class doesn't have to derive from anything. Also, there is no -magic in the class definition: the methods you expose are exactly what the -driver will have. - -```es6 -export default class ModuleDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - humanClickToggle() - { - this.wrapper.find( '.selector_for_toggle_button' ) - .simulate( 'click' ); - return this; - } - - isExpanded() - { - return this.wrapper.find( SomeScreen ) - .prop( 'isCollapsed' ) === false; - } -} -``` - - -### Step 2: define a driver suite - -The driver suite links the components to their drivers. It looks like this: - - -```es6 -import { ComponentDriver } from 'nessie-ui/dist/componentDriver'; -import Module from 'path/to/module'; -import ModuleDriver from 'path/to/module/driver'; - -export default ComponentDriver.createDriverSuite( -[ - { - Component : Module, - Driver : ModuleDriver - }, - /* ... more Component-Driver linkages ... */ -] ); -``` - - -### Step 3: Activate component drivers in tests. - -Somewhere in your test suite entry point, you activate the drivers and provide -the enzyme extension to use drivers. - - -```es6 -import * as enzyme from 'enzyme'; -import { ComponentDriver } from 'nessie-ui/dist/componentDriver'; -import driverSuite from 'path/to/driver/suite'; - -// Extend enzyme's API to make drivers available. -ComponentDriver.extendEnzyme( enzyme ); - -// Register the driver suite for use. -driverSuite.provideDrivers(); -``` - - -### Step 4: use the drivers in tests! - -Now the method `driver()` to available to all instances of Enzyme's `ReactWrapper` -(however, **not** Enzyme's `ShallowWrapper`!). Provided that drivers have been -correctly linked up to the components, we can now write tests semantically -using the drivers. - -```es6 -import { mount } from 'enzyme'; -import Module from 'path/to/module'; - -test( 'initial state is expanded', () => -{ - const wrapper = mount( ); - expect( wrapper.driver().isExpanded() ).true; -} ); - -test( 'clicking toggle changes the toggle state', () => -{ - const wrapper = mount( ); - - wrapper.driver().humanClickToggle(); - expect( driver.isExpanded() ).false; - wrapper.driver().humanClickToggle(); - expect( driver.isExpanded() ).true; -} ); -``` - -Driver API Recommendations --------------------------- - -It's probably a good idea to adopt consistent convention to distinguish -getters from human action simulations from programmatic action simulations. diff --git a/src/Testing/ComponentDriver/index.js b/src/Testing/ComponentDriver/index.js deleted file mode 100644 index 9e62643e..00000000 --- a/src/Testing/ComponentDriver/index.js +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -const Drivers = new WeakMap(); - -const Err = -{ - BAD_DRIVER_NODE_COUNT : ( { count } ) => `ReactWrapper::driver() requires the wrapper to contain exactly one node, but the wrapper contains ${count} nodes`, - SHALLOW_NOT_SUPPORTED : 'ShallowWrapper::driver() is not supported.', - NO_DRIVER_FOUND : ( { name } ) => `Could not find driver for Component ${name}`, - BAD_SUITE_COMPONENT : 'Invalid driver suite specification; expect "Component" to be a function.', - BAD_SUITE_DRIVER : 'Invalid driver suite specification; expect "Driver" to be a function.', -}; - - -export function extendEnzyme( enzyme ) -{ - enzyme.ReactWrapper.prototype.driver = function() - { - if ( this.length === 0 ) - { - throw new Error( Err.BAD_DRIVER_NODE_COUNT( { count: this.length } ) ); - } - - const componentConstructor = this.type(); - const Driver = Drivers.get( componentConstructor ); - - if ( !Driver ) - { - throw new Error( Err.NO_DRIVER_FOUND( { name: componentConstructor.name } ) ); - } - - return new Driver( this ); - }; - - enzyme.ShallowWrapper.prototype.driver = function() - { - throw new Error( Err.SHALLOW_NOT_SUPPORTED ); - }; -} - -export function createDriverSuite( suiteSpec, ...extensions ) -{ - return { - provideDrivers : () => - { - extensions.forEach( suite => suite.provideDrivers() ); - provideDrivers( suiteSpec ); - }, - }; -} - - -function provideDrivers( suiteSpec ) -{ - suiteSpec.forEach( ( { Component, Driver } ) => - { - if ( !Component ) - { - throw new Error( Err.BAD_SUITE_COMPONENT ); - } - - if ( !( typeof Driver === 'function' ) ) - { - throw new Error( Err.BAD_SUITE_DRIVER ); - } - - Drivers.set( Component, Driver ); - } ); -} diff --git a/src/Testing/index.js b/src/Testing/index.js deleted file mode 100644 index b7676393..00000000 --- a/src/Testing/index.js +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import * as ComponentDriver from './ComponentDriver'; - -import * as lib from './index'; - -export default lib; -export { ComponentDriver }; diff --git a/src/Testing/mocks/createCssMapMock.js b/src/Testing/mocks/createCssMapMock.js deleted file mode 100644 index cff73cf1..00000000 --- a/src/Testing/mocks/createCssMapMock.js +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -import cssMock from 'identity-obj-proxy'; - -const createCssMap = () => cssMock; - -export default createCssMap; diff --git a/src/Testing/mocks/fileMock.js b/src/Testing/mocks/fileMock.js deleted file mode 100644 index 14f0eb83..00000000 --- a/src/Testing/mocks/fileMock.js +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -module.exports = 'test-file-stub'; diff --git a/src/Testing/setupTestEnvironment.js b/src/Testing/setupTestEnvironment.js deleted file mode 100644 index 3e46743b..00000000 --- a/src/Testing/setupTestEnvironment.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -const Enzyme = require( 'enzyme' ); -const Adapter = require( 'enzyme-adapter-react-16' ); - -Enzyme.configure( { adapter: new Adapter() } ); - -const DriverSuite = require( '../drivers' ).default; - -const Testing = require( './index.js' ); - -Testing.ComponentDriver.extendEnzyme( Enzyme ); -DriverSuite.provideDrivers(); diff --git a/src/Text/driver.js b/src/Text/driver.js deleted file mode 100644 index 11a6e615..00000000 --- a/src/Text/driver.js +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (c) 2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -export default class TextDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - - click() - { - this.wrapper.simulate( 'click' ); - return this; - } -} diff --git a/src/Text/index.jsx b/src/Text/index.jsx index dff6e0e8..02277df4 100644 --- a/src/Text/index.jsx +++ b/src/Text/index.jsx @@ -7,149 +7,147 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; +import PropTypes from 'prop-types'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; -import { attachEvents } from '../utils'; +import { attachEvents, useTheme } from '../utils'; -export default class Text extends React.Component +const componentName = 'Text'; + +const Text = props => { - static contextType = ThemeContext; + const { + children, + color, + letterSpacing, + lineHeight, + text, + textRef, + } = props; + + const cssMap = useTheme( componentName, props ); - static propTypes = - { - /** - * Capitalize text - */ - allCaps : PropTypes.bool, - /** - * Text content (JSX node; overrides text prop) - */ - children : PropTypes.node, - /** - * Extra CSS class name - */ - className : PropTypes.string, - /** - * Text Color - */ - color : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Letter Spacing for the text - */ - letterSpacing : PropTypes.string, - /** - * Line Height for the text - */ - lineHeight : PropTypes.string, - /** - * Don’t wrap text to the next line - */ - noWrap : PropTypes.bool, - /** - * Clip overflow - */ - overflowIsHidden : PropTypes.bool, - /** - * Role (style) to apply to text - */ - role : PropTypes.oneOf( [ - 'default', - 'subtle', - 'promoted', - 'critical', - 'link', - ] ), - /** - * Size to apply to text - */ - size : PropTypes.oneOf( [ - 'XXXL', - 'XXL', - 'XL', - 'L', - 'M', - 'S', - 'XS', - 'XXS', - ] ), - /** - * Text string - */ - text : PropTypes.string, - /** - * Text alignment - */ - textAlign : PropTypes.oneOf( [ 'left', 'center', 'right' ] ), - /** - * Callback that receives ref to the text div: ref => ... - */ - textRef : PropTypes.func, - /** - * Style to apply to text - */ - variant : PropTypes.oneOf( [ - 'Light', - 'LightIt', - 'Regular', - 'RegularIt', - 'SemiBold', - 'SemiBoldIt', - 'Bold', - 'BoldIt', - 'ExtraBold', - 'ExtraBoldIt', - ] ), - }; + return ( +
    + { children || text } +
    + ); +}; - static defaultProps = - { - allCaps : false, - children : undefined, - className : undefined, - color : undefined, - cssMap : undefined, - letterSpacing : undefined, - lineHeight : undefined, - noWrap : false, - overflowIsHidden : false, - role : 'default', - size : 'M', - text : undefined, - textAlign : undefined, - textRef : undefined, - variant : 'Regular', - }; +Text.propTypes = +{ + /** + * Capitalize text + */ + allCaps : PropTypes.bool, + /** + * Text content (JSX node; overrides text prop) + */ + children : PropTypes.node, + /** + * Extra CSS class name + */ + className : PropTypes.string, + /** + * Text Color + */ + color : PropTypes.string, + /** + * CSS class map + */ + cssMap : PropTypes.objectOf( PropTypes.string ), + /** + * Letter Spacing for the text + */ + letterSpacing : PropTypes.string, + /** + * Line Height for the text + */ + lineHeight : PropTypes.string, + /** + * Don’t wrap text to the next line + */ + noWrap : PropTypes.bool, + /** + * Clip overflow + */ + overflowIsHidden : PropTypes.bool, + /** + * Role (style) to apply to text + */ + role : PropTypes.oneOf( [ + 'default', + 'subtle', + 'promoted', + 'critical', + 'link', + ] ), + /** + * Size to apply to text + */ + size : PropTypes.oneOf( [ + 'XXXL', + 'XXL', + 'XL', + 'L', + 'M', + 'S', + 'XS', + 'XXS', + ] ), + /** + * Text string + */ + text : PropTypes.string, + /** + * Text alignment + */ + textAlign : PropTypes.oneOf( [ 'left', 'center', 'right' ] ), + /** + * Callback that receives ref to the text div: ref => ... + */ + textRef : PropTypes.func, + /** + * Style to apply to text + */ + variant : PropTypes.oneOf( [ + 'Light', + 'LightIt', + 'Regular', + 'RegularIt', + 'SemiBold', + 'SemiBoldIt', + 'Bold', + 'BoldIt', + 'ExtraBold', + 'ExtraBoldIt', + ] ), +}; - static displayName = 'Text'; +Text.defaultProps = +{ + allCaps : false, + children : undefined, + className : undefined, + color : undefined, + cssMap : undefined, + letterSpacing : undefined, + lineHeight : undefined, + noWrap : false, + overflowIsHidden : false, + role : 'default', + size : 'M', + text : undefined, + textAlign : undefined, + textRef : undefined, + variant : 'Regular', +}; - render() - { - const { - children, - color, - cssMap = createCssMap( this.context.Text, this.props ), - letterSpacing, - lineHeight, - text, - textRef, - } = this.props; +Text.displayName = componentName; - return ( -
    - { children || text } -
    - ); - } -} +export default Text; diff --git a/src/Text/tests.jsx b/src/Text/tests.jsx deleted file mode 100644 index d443003b..00000000 --- a/src/Text/tests.jsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (c) 2017-2018 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -/* eslint-disable no-magic-numbers */ - -import React from 'react'; -import { mount, shallow } from 'enzyme'; - -import { Text } from '..'; - - -describe( 'Text', () => -{ - let wrapper; - - beforeEach( () => - { - wrapper = shallow( ); - } ); - - test( 'should have “main” as default className', () => - { - expect( wrapper.prop( 'className' ) ).toEqual( 'main' ); - } ); -} ); - - -describe( 'TextDriver', () => -{ - let wrapper; - let driver; - - beforeEach( () => - { - wrapper = mount( ); - driver = wrapper.driver(); - } ); - - describe( 'click()', () => - { - test( 'should call onClick exactly once', () => - { - const onClick = jest.fn(); - wrapper.setProps( { onClick } ); - - driver.click(); - expect( onClick ).toBeCalledTimes( 1 ); - } ); - } ); -} ); diff --git a/src/TextArea/driver.js b/src/TextArea/driver.js deleted file mode 100644 index d137e45d..00000000 --- a/src/TextArea/driver.js +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright (c) 2018-2019 dunnhumby Germany GmbH. - * All rights reserved. - * - * This source code is licensed under the MIT license found in the LICENSE file - * in the root directory of this source tree. - * - */ - -const ERR = { - TEXTAREA_ERROR : ( event, state ) => - `TextArea cannot simulate ${event} since it is ${state}`, -}; - -export default class TextAreaDriver -{ - constructor( wrapper ) - { - this.wrapper = wrapper; - } - - blur() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TEXTAREA_ERROR( 'blur', 'disabled' ) ); - } - - this.wrapper.simulate( 'blur' ); - return this; - } - - focus() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TEXTAREA_ERROR( 'focus', 'disabled' ) ); - } - - this.wrapper.simulate( 'focus' ); - return this; - } - - change( val, textarea = 'textarea' ) - { - const node = this.wrapper.find( textarea ).instance(); - - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TEXTAREA_ERROR( 'change', 'disabled' ) ); - } - - if ( this.wrapper.props().isReadOnly ) - { - throw new Error( ERR.TEXTAREA_ERROR( 'change', 'read only' ) ); - } - - node.value = val; - this.wrapper.simulate( 'change' ); - - return this; - } - - click() - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TEXTAREA_ERROR( 'click', 'disabled' ) ); - } - - this.wrapper.simulate( 'click' ); - return this; - } - - keyPress( keyCode ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TEXTAREA_ERROR( 'keyPress', 'disabled' ) ); - } - - this.wrapper.simulate( 'keyPress', { keyCode, which: keyCode } ); - return this; - } - - keyDown( keyCode ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TEXTAREA_ERROR( 'keyDown', 'disabled' ) ); - } - - this.wrapper.simulate( 'keyDown', { keyCode, which: keyCode } ); - return this; - } - - keyUp( keyCode ) - { - if ( this.wrapper.props().isDisabled ) - { - throw new Error( ERR.TEXTAREA_ERROR( 'keyUp', 'disabled' ) ); - } - - this.wrapper.simulate( 'keyUp', { keyCode, which: keyCode } ); - return this; - } - - mouseOver() - { - this.wrapper.simulate( 'mouseover' ); - return this; - } - - mouseOut() - { - this.wrapper.simulate( 'mouseout' ); - return this; - } -} diff --git a/src/TextArea/index.jsx b/src/TextArea/index.jsx index b2ba5091..a4fbe9be 100644 --- a/src/TextArea/index.jsx +++ b/src/TextArea/index.jsx @@ -7,218 +7,221 @@ * */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { + useRef, useImperativeHandle, forwardRef, +} from 'react'; +import PropTypes from 'prop-types'; -import { attachEvents, mapAria, generateId } from '../utils'; -import ThemeContext from '../Theming/ThemeContext'; -import { createCssMap } from '../Theming'; +import { + attachEvents, + mapAria, + useTheme, +} from '../utils'; -export default class TextArea extends React.Component +const componentName = 'TextArea'; + +const TextArea = forwardRef( ( props, ref ) => { - static contextType = ThemeContext; + const cssMap = useTheme( componentName, props ); - static propTypes = - { - /** - * ARIA properties - */ - aria : PropTypes.objectOf( PropTypes.oneOfType( [ - PropTypes.bool, - PropTypes.number, - PropTypes.string, - ] ) ), - /** - * HTML attribute controlling input auto capitalize - */ - autoCapitalize : PropTypes.oneOf( [ - 'on', - 'off', - 'none', - 'sentences', - 'words', - 'characters', - ] ), - /** - * HTML attribute controlling input auto complete - */ - autoComplete : PropTypes.oneOf( [ 'on', 'off' ] ), - /** - * HTML attribute controlling input auto correct (Safari-specific) - */ - autoCorrect : PropTypes.oneOf( [ 'on', 'off' ] ), - /** - * Extra CSS class name - */ - className : PropTypes.string, - /** - * CSS class map - */ - cssMap : PropTypes.objectOf( PropTypes.string ), - /** - * Default input string value - */ - defaultValue : PropTypes.string, - /** - * Display as error/invalid - */ - hasError : PropTypes.bool, - /** - * HTML id attribute - */ - id : PropTypes.string, - /** - * Display as disabled - */ - isDisabled : PropTypes.bool, - /** - * Display as read-only - */ - isReadOnly : PropTypes.bool, - /** - * Blur callback function - */ - onBlur : PropTypes.func, - /** - * Input change callback function - */ - onChange : PropTypes.func, - /** - * Input click callback function - */ - onClick : PropTypes.func, - /** - * Focus callback function - */ - onFocus : PropTypes.func, - /** - * Key down callback function - */ - onKeyDown : PropTypes.func, - /** - * Key press callback function - */ - onKeyPress : PropTypes.func, - /** - * Key up callback function - */ - onKeyUp : PropTypes.func, - /** - * Mouse out callback function - */ - onMouseOut : PropTypes.func, - /** - * Mouse over callback function - */ - onMouseOver : PropTypes.func, - /** - * Placeholder text - */ - placeholder : PropTypes.string, - /** - * TextArea resize handle - */ - resize : PropTypes.oneOf( - [ - 'horizontal', - 'vertical', - 'both', - 'none', - ], - ), - /** - * The visible number of lines in a text area - */ - rows : PropTypes.number, - /** - * HTML attribute controlling input spell check - */ - spellCheck : PropTypes.bool, - /** - * Input text alignment - */ - textAlign : PropTypes.oneOf( [ 'left', 'right' ] ), - /** - * Input string value - */ - value : PropTypes.string, - }; + const textAreaRef = useRef(); + + useImperativeHandle( ref, () => ( { + focus : () => textAreaRef.current.focus(), + } ) ); - static defaultProps = - { - aria : undefined, - autoCapitalize : undefined, - autoComplete : undefined, - autoCorrect : undefined, - className : undefined, - cssMap : undefined, - defaultValue : undefined, - hasError : false, - id : undefined, - isDisabled : false, - isReadOnly : false, - onBlur : undefined, - onChange : undefined, - onClick : undefined, - onFocus : undefined, - onKeyDown : undefined, - onKeyPress : undefined, - onKeyUp : undefined, - onMouseOut : undefined, - onMouseOver : undefined, - placeholder : undefined, - resize : undefined, - rows : 2, - spellCheck : undefined, - textAlign : 'left', - value : undefined, - }; + const { + aria, + autoCapitalize, + autoComplete, + autoCorrect, + defaultValue, + id, + isDisabled, + isReadOnly, + placeholder, + rows, + spellCheck, + value, + } = props; - static displayName = 'TextArea'; + return ( +