├── .babelrc ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .storybook ├── config.js └── preview-head.html ├── .travis.yml ├── LICENSE ├── README.md ├── demo.gif ├── package-lock.json ├── package.json ├── src ├── Clock.js ├── Clock.spec.js ├── TimeInput.js ├── TimeInput.spec.js ├── TimePicker.js ├── TimePicker.md ├── TimePicker.spec.js ├── __snapshots__ │ ├── Clock.spec.js.snap │ ├── TimeInput.spec.js.snap │ └── TimePicker.spec.js.snap ├── index.js ├── index.spec.js ├── util.js └── util.spec.js ├── stories └── index.js ├── styleguide.config.js └── test ├── jestsetup.js ├── shim.js └── utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react", "@babel/preset-env"], 3 | "plugins": [["@babel/plugin-proposal-class-properties", { "loose": true }]] 4 | } 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [leMaik,saschb2b] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | coverage 4 | styleguide -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/* 2 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react' 2 | 3 | function loadStories () { 4 | require('../stories') 5 | } 6 | 7 | configure(loadStories, module) 8 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | script: npm test && npm run test:coverage 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017-2019 Team Wertarbyte and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # material-ui-time-picker 2 | [![npm Package](https://img.shields.io/npm/v/material-ui-time-picker.svg)](https://www.npmjs.com/package/material-ui-time-picker) 3 | [![Build Status](https://travis-ci.org/TeamWertarbyte/material-ui-time-picker.svg?branch=master)](https://travis-ci.org/TeamWertarbyte/material-ui-time-picker) 4 | [![Coverage Status](https://coveralls.io/repos/github/TeamWertarbyte/material-ui-time-picker/badge.svg?branch=master)](https://coveralls.io/github/TeamWertarbyte/material-ui-time-picker?branch=master) 5 | 6 | This project provides a [time picker][time-picker-spec] for [Material-UI][material-ui]. 7 | 8 | ![Demo](demo.gif) 9 | 10 | ## Installation 11 | ``` 12 | npm i --save material-ui-time-picker 13 | ``` 14 | 15 | ## Usage 16 | There are multiple ways to use this component to allow greater flexibility. This is the most basic usage that behaves similar to the [Material-UI 0.x time picker][legacy-time-picker]: 17 | 18 | ```jsx 19 | import TimeInput from 'material-ui-time-picker' 20 | 21 | // uncontrolled input 22 | this.handleChange(time)} 25 | /> 26 | 27 | // controlled input 28 | this.handleChange(time)} 32 | /> 33 | ``` 34 | 35 | For detailed documentation, take a look into the [styleguide][]. The source code, especially the tests, might also be helpful. 36 | 37 | ## TimeInput Properties 38 | |Name|Type|Default|Description| 39 | |---|---|---|---| 40 | |autoOk|`bool`|`false`|If true, automatically accept and close the picker on set minutes.| 41 | |cancelLabel|`string`|`'Cancel'`|Override the label of the cancel button.| 42 | |ClockProps|`object`||Properties to pass down to the Clock component.| 43 | |defaultValue|`Date`||This default value overrides initialTime and placeholder.| 44 | |initialTime|`Date`||The default value for the time picker.| 45 | |inputComponent|`elementType`|`Input`|The component used for the input. Either a string to use a DOM element or a component.| 46 | |placeholder|`string`||The placeholder value for the time picker before a time has been selected.| 47 | |mode|`enum: '12h' '24h'`|`'12h'`|Sets the clock mode, 12-hour or 24-hour clocks are supported.| 48 | |okLabel|`string`|`'Ok'`|Override the label of the ok button.| 49 | |onChange|`func`||Callback that is called with the new date (as Date instance) when the value is changed.| 50 | |openOnMount|`bool`||If true, automatically opens the dialog when the component is mounted.| 51 | |TimePickerProps|`object`||Properties to pass down to the TimePicker component.| 52 | |value|`Date`||The value of the time picker, for use in controlled mode.| 53 | 54 | Note: `TimeInput` behaves like Material-UI's `Input` component and can be used inside `FormControl`s. 55 | 56 | ## License 57 | The files included in this repository are licensed under the MIT license. 58 | 59 | [time-picker-spec]: https://material.io/guidelines/components/pickers.html#pickers-time-pickers 60 | [material-ui]: https://material-ui.com/ 61 | [legacy-time-picker]: http://v0.material-ui.com/#/components/time-picker 62 | [styleguide]: https://teamwertarbyte.github.io/material-ui-time-picker 63 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TeamWertarbyte/material-ui-time-picker/51d3edba6dc5057b1805acdaccc8525121e59a7a/demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "material-ui-time-picker", 3 | "version": "1.3.0", 4 | "description": "A time picker for material-ui 1.0-beta.", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib/**/*.js", 8 | "README.md", 9 | "LICENSE", 10 | "demo.gif" 11 | ], 12 | "scripts": { 13 | "test": "standard && jest", 14 | "test:coverage": "jest --coverage && cat ./coverage/lcov.info | coveralls", 15 | "test:unit": "jest", 16 | "build": "babel src -d lib --ignore '**/*.spec.js'", 17 | "prepublish": "babel src -d lib --ignore '**/*.spec.js'", 18 | "storybook": "start-storybook -p 6006", 19 | "styleguide": "styleguidist server", 20 | "styleguide:build": "styleguidist build", 21 | "styleguide:deploy": "npm run styleguide:build && gh-pages -d styleguide" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/TeamWertarbyte/material-ui-time-picker.git" 26 | }, 27 | "keywords": [ 28 | "material-ui", 29 | "material", 30 | "design", 31 | "timepicker", 32 | "time", 33 | "form", 34 | "react", 35 | "ui" 36 | ], 37 | "author": "Wertarbyte", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/TeamWertarbyte/material-ui-time-picker/issues" 41 | }, 42 | "homepage": "https://github.com/TeamWertarbyte/material-ui-time-picker#readme", 43 | "devDependencies": { 44 | "@babel/cli": "^7.4.3", 45 | "@babel/core": "^7.4.3", 46 | "@babel/plugin-proposal-class-properties": "^7.4.0", 47 | "@babel/preset-env": "^7.4.3", 48 | "@babel/preset-react": "^7.0.0", 49 | "@material-ui/core": "^3.9.3", 50 | "@storybook/addon-actions": "^3.4.5", 51 | "@storybook/react": "^3.4.5", 52 | "babel-eslint": "^8.2.3", 53 | "coveralls": "^3.0.0", 54 | "enzyme": "^3.9.0", 55 | "enzyme-adapter-react-16": "^1.12.1", 56 | "enzyme-to-json": "^3.3.5", 57 | "gh-pages": "^1.0.0", 58 | "jest": "^24.7.1", 59 | "mockdate": "^2.0.2", 60 | "react": "^16.3.2", 61 | "react-dom": "^16.3.2", 62 | "react-styleguidist": "^7.0.14", 63 | "react-test-renderer": "^16.8.6", 64 | "standard": "^11.0.1", 65 | "webpack": "^3.7.1", 66 | "webpack-blocks": "^1.0.0-rc" 67 | }, 68 | "jest": { 69 | "setupFiles": [ 70 | "./test/shim.js", 71 | "./test/jestsetup.js" 72 | ], 73 | "snapshotSerializers": [ 74 | "enzyme-to-json/serializer" 75 | ], 76 | "coveragePathIgnorePatterns": [ 77 | "/test" 78 | ], 79 | "roots": [ 80 | "src", 81 | "test" 82 | ], 83 | "testURL": "http://localhost/" 84 | }, 85 | "standard": { 86 | "parser": "babel-eslint" 87 | }, 88 | "dependencies": { 89 | "classnames": "^2.2.5", 90 | "prop-types": "^15.7.2" 91 | }, 92 | "peerDependencies": { 93 | "@material-ui/core": "^1.0.0 || ^3.0.0", 94 | "react": "^16.3.0" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Clock.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { getContrastRatio } from '@material-ui/core/styles/colorManipulator' 5 | import { duration, easing } from '@material-ui/core/styles/transitions' 6 | import classNames from 'classnames' 7 | import { getShortestAngle } from './util' 8 | 9 | const styles = (theme) => ({ 10 | root: { 11 | width: 256, 12 | height: 256, 13 | fontFamily: theme.typography.fontFamily, 14 | cursor: 'default' 15 | }, 16 | circle: { 17 | width: 256, 18 | height: 256, 19 | borderRadius: '50%', 20 | background: theme.palette.type === 'light' ? theme.palette.grey[200] : theme.palette.grey[900], 21 | color: theme.palette.text.primary, 22 | position: 'relative' 23 | }, 24 | number: { 25 | width: 32, 26 | height: 32, 27 | left: 'calc(50% - 16px)', 28 | top: 'calc(50% - 16px)', 29 | position: 'absolute', 30 | textAlign: 'center', 31 | lineHeight: '32px', 32 | cursor: 'pointer', 33 | fontSize: '14px', 34 | pointerEvents: 'none', 35 | userSelect: 'none', 36 | '&.selected': { 37 | color: getContrastRatio(theme.palette.primary.main, theme.palette.common.black) < 7 ? theme.palette.common.white : theme.palette.common.black 38 | } 39 | }, 40 | smallNumber: { 41 | fontSize: '12px', 42 | color: theme.palette.text.secondary 43 | }, 44 | pointer: { 45 | width: 'calc(50% - 20px)', 46 | height: 2, 47 | backgroundColor: theme.palette.primary.main, 48 | position: 'absolute', 49 | left: '50%', 50 | top: 'calc(50% - 1px)', 51 | transformOrigin: 'left center', 52 | pointerEvents: 'none' 53 | }, 54 | animatedPointer: { 55 | transition: `all ${duration.short}ms ${easing.easeInOut}` 56 | }, 57 | smallPointer: { 58 | width: 'calc(50% - 52px)' 59 | }, 60 | innerDot: { 61 | backgroundColor: theme.palette.primary.main, 62 | position: 'absolute', 63 | top: -4 + 1, 64 | left: -4, 65 | width: 8, 66 | height: 8, 67 | borderRadius: '50%' 68 | }, 69 | outerDot: { 70 | border: `16px solid ${theme.palette.primary.main}`, 71 | borderWidth: 16, 72 | position: 'absolute', 73 | top: -16 + 1, 74 | right: -16, 75 | width: 0, 76 | height: 0, 77 | borderRadius: '50%', 78 | boxSizing: 'content-box' 79 | }, 80 | outerDotOdd: { 81 | background: getContrastRatio(theme.palette.primary.main, theme.palette.common.black) < 7 ? theme.palette.common.white : theme.palette.common.black, 82 | width: 4, 83 | height: 4, 84 | borderWidth: 14 85 | } 86 | }) 87 | 88 | const size = 256 89 | 90 | class Clock extends React.PureComponent { 91 | constructor (props) { 92 | super(props) 93 | this.state = { touching: false, angle: getPointerAngle(props.value, props.mode) } 94 | } 95 | 96 | componentWillReceiveProps ({ value, mode }) { 97 | if (mode !== this.props.mode || value !== this.props.value) { 98 | this.setState({ angle: getShortestAngle(this.state.angle, getPointerAngle(value, mode)) }) 99 | } 100 | } 101 | 102 | disableAnimatedPointer = () => this.setState({ touching: true }) 103 | enableAnimatedPointer = () => this.setState({ touching: false }) 104 | 105 | handleTouchMove = (e) => { 106 | e.preventDefault() // prevent scrolling behind the clock on iOS 107 | const rect = e.target.getBoundingClientRect() 108 | this.movePointer(e.changedTouches[0].clientX - rect.left, e.changedTouches[0].clientY - rect.top) 109 | } 110 | 111 | handleTouchEnd = (e) => { 112 | this.handleTouchMove(e) 113 | this.enableAnimatedPointer() 114 | } 115 | 116 | handleMouseMove = (e) => { 117 | // MouseEvent.which is deprecated, but MouseEvent.buttons is not supported in Safari 118 | if (e.buttons === 1 || e.which === 1) { 119 | const rect = e.target.getBoundingClientRect() 120 | this.movePointer(e.clientX - rect.left, e.clientY - rect.top) 121 | } 122 | } 123 | 124 | handleClick = (e) => { 125 | const rect = e.target.getBoundingClientRect() 126 | this.movePointer(e.clientX - rect.left, e.clientY - rect.top) 127 | } 128 | 129 | movePointer (x, y) { 130 | const value = getPointerValue(x, y, this.props.mode) 131 | if (value !== this.props.value && this.props.onChange != null) { 132 | this.props.onChange(value) 133 | } 134 | } 135 | 136 | render () { 137 | const { classes, mode, value, ...other } = this.props 138 | const { touching } = this.state 139 | 140 | return ( 141 |
142 |
152 |
12), [classes.animatedPointer]: !touching })} style={{ 153 | transform: `rotate(${this.state.angle}deg)` 154 | }}> 155 |
156 |
157 |
158 | {mode === '12h' && getNumbers(12, { size }).map((digit, i) => ( 159 | 166 | {digit.display} 167 | 168 | ))} 169 | {mode === '24h' && getNumbers(12, { size }).map((digit, i) => ( 170 | 177 | {digit.display} 178 | 179 | ))} 180 | {mode === '24h' && getNumbers(12, { size: size - 64, start: 13 }).map((digit, i) => ( 181 | 188 | {digit.display === 24 ? '00' : digit.display} 189 | 190 | ))} 191 | {mode === 'minutes' && getNumbers(12, { size, start: 5, step: 5 }).map((digit, i) => ( 192 | 199 | {digit.display === 60 ? '00' : digit.display} 200 | 201 | ))} 202 |
203 |
204 | ) 205 | } 206 | } 207 | 208 | Clock.propTypes = { 209 | /** Sets the mode of this clock. It can either select hours (supports 12- and 24-hour-clock) or minutes. */ 210 | mode: PropTypes.oneOf(['12h', '24h', 'minutes']).isRequired, 211 | /** Callback that is called with the new hours/minutes (as a number) when the value is changed. */ 212 | onChange: PropTypes.func, 213 | /** The value of the clock. */ 214 | value: PropTypes.number.isRequired 215 | } 216 | 217 | export default withStyles(styles)(Clock) 218 | 219 | function getNumbers (count, { size, start = 1, step = 1 }) { 220 | return Array.apply(null, Array(count)).map((_, i) => ({ 221 | display: i * step + start, 222 | translateX: (size / 2 - 20) * Math.cos(2 * Math.PI * (i - 2) / count), 223 | translateY: (size / 2 - 20) * Math.sin(2 * Math.PI * (i - 2) / count) 224 | })) 225 | } 226 | 227 | function getPointerAngle (value, mode) { 228 | switch (mode) { 229 | case '12h': 230 | return 360 / 12 * (value - 3) 231 | case '24h': 232 | return 360 / 12 * (value % 12 - 3) 233 | case 'minutes': 234 | return 360 / 60 * (value - 15) 235 | } 236 | } 237 | 238 | function getPointerValue (x, y, mode) { 239 | let angle = Math.atan2(size / 2 - x, size / 2 - y) / Math.PI * 180 240 | if (angle < 0) { 241 | angle = 360 + angle 242 | } 243 | 244 | switch (mode) { 245 | case '12h': { 246 | const value = 12 - Math.round(angle * 12 / 360) 247 | return value === 0 ? 12 : value 248 | } 249 | case '24h': { 250 | const radius = Math.sqrt(Math.pow(size / 2 - x, 2) + Math.pow(size / 2 - y, 2)) 251 | let value = 12 - Math.round(angle * 12 / 360) 252 | if (value === 0) { 253 | value = 12 254 | } 255 | if (radius < size / 2 - 32) { 256 | value = value === 12 ? 0 : value + 12 257 | } 258 | return value 259 | } 260 | case 'minutes': { 261 | const value = Math.round(60 - 60 * angle / 360) 262 | return value === 60 ? 0 : value 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/Clock.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import { unwrap } from '@material-ui/core/test-utils' 5 | import * as testUtils from '../test/utils' 6 | import Clock from './Clock' 7 | 8 | describe('', () => { 9 | it('propagates unknown props through to the child component', () => { 10 | const callback = jest.fn() 11 | const tree = mount( 12 | 13 | ) 14 | tree.simulate('click') 15 | expect(callback).toBeCalled() 16 | }) 17 | 18 | it('handles clicks', () => { 19 | const onChangeCallback = jest.fn() 20 | const tree = mount() 21 | getCircle(tree).simulate('click', testUtils.stubClickEvent(175, 200)) // click on 17 22 | expect(onChangeCallback).toHaveBeenCalledWith(17) 23 | }) 24 | 25 | it('handles dragging with the mouse', () => { 26 | const onChangeCallback = jest.fn() 27 | const tree = mount() 28 | getCircle(tree).simulate('mousemove', { buttons: 1, clientX: 175, clientY: 200 }) // drag the hand to 17 29 | expect(onChangeCallback).toHaveBeenCalledWith(17) 30 | }) 31 | 32 | it('handles dragging with the mouse in Safari (where MouseEvent.buttons is not supported)', () => { 33 | const onChangeCallback = jest.fn() 34 | const tree = mount() 35 | getCircle(tree).simulate('mousemove', { which: 1, clientX: 175, clientY: 200 }) // drag the hand to 17 36 | expect(onChangeCallback).toHaveBeenCalledWith(17) 37 | }) 38 | 39 | it('handles taps', () => { 40 | const onChangeCallback = jest.fn() 41 | const tree = mount() 42 | getCircle(tree).simulate('touchend', testUtils.stubTouchEndEvent(175, 200)) // tap on 17 43 | expect(onChangeCallback).toHaveBeenCalledWith(17) 44 | 45 | onChangeCallback.mockClear() 46 | getCircle(tree).simulate('touchmove', testUtils.stubTouchMoveEvent(175, 200)) // swipe over 17 47 | expect(onChangeCallback).toHaveBeenCalledWith(17) 48 | }) 49 | 50 | it('takes the shortest route when moving the hand', () => { 51 | const tree = mount() 52 | tree.setProps({ value: 5 }) 53 | expect(getPointer(tree).getDOMNode().style.transform).toBe('rotate(-60deg)') 54 | tree.setProps({ value: 55 }) 55 | expect(getPointer(tree).getDOMNode().style.transform).toBe('rotate(-120deg)') 56 | }) 57 | 58 | it('disables the hand animation when moving the hand manually via touch', () => { 59 | const UnstyledClock = unwrap(Clock) 60 | const onChangeCallback = jest.fn() 61 | const tree = mount() 62 | getCircle(tree).simulate('touchstart') 63 | expect(tree.state().touching).toBe(true) 64 | getCircle(tree).simulate('touchend', testUtils.stubTouchEndEvent(175, 200)) 65 | expect(tree.state().touching).toBe(false) 66 | }) 67 | 68 | it('disables the hand animation when moving the hand manually via mouse', () => { 69 | const UnstyledClock = unwrap(Clock) 70 | const onChangeCallback = jest.fn() 71 | const tree = mount() 72 | getCircle(tree).simulate('mousedown') 73 | expect(tree.state().touching).toBe(true) 74 | getCircle(tree).simulate('mouseup') 75 | expect(tree.state().touching).toBe(false) 76 | }) 77 | 78 | it('calls preventDefault on touchmove events to prevent scrolling through the clock on Safari', () => { 79 | const tree = mount() 80 | const preventDefault = jest.fn() 81 | getCircle(tree).simulate('touchmove', { 82 | preventDefault, 83 | changedTouches: [{ clientX: 0, clientY: 0 }] 84 | }) 85 | expect(preventDefault).toBeCalled() 86 | }) 87 | 88 | describe('24h', () => { 89 | it('matches the snapshot', () => { 90 | const tree = mount( 91 | 92 | ) 93 | expect(tree).toMatchSnapshot() 94 | }) 95 | 96 | it('displays 24 hours', () => { 97 | const tree = mount( 98 | 99 | ) 100 | 101 | const spans = tree.find('span') 102 | for (let i = 1; i <= 23; i++) { 103 | expect(spans.findWhere((e) => e.type() === 'span' && parseInt(e.text()) === i).length).toBe(1) 104 | } 105 | }) 106 | 107 | it('rotates the pointer to point to the selected hour', () => { 108 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(0deg)') 109 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(90deg)') 110 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(180deg)') 111 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(-90deg)') 112 | 113 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(0deg)') 114 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(90deg)') 115 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(180deg)') 116 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(-90deg)') 117 | }) 118 | 119 | it('changes the pointer length when pointing to inner hours', () => { 120 | const isSmall = testUtils.hasClass(/-smallPointer/) 121 | 122 | expect(isSmall(getPointer(mount()))).toBeTruthy() 123 | expect(isSmall(getPointer(mount()))).toBeFalsy() 124 | 125 | // 12 is in the outer circle, 00 is in the inner circle 126 | expect(isSmall(getPointer(mount()))).toBeFalsy() 127 | expect(isSmall(getPointer(mount()))).toBeTruthy() 128 | }) 129 | 130 | it('calls onChange when a different value is selected', () => { 131 | const onChangeCallback = jest.fn() 132 | const tree = mount() 133 | getCircle(tree).simulate('click', testUtils.stubClickEvent(175, 200)) // click on 17 134 | expect(onChangeCallback).toHaveBeenCalledWith(17) 135 | 136 | onChangeCallback.mockClear() 137 | getCircle(tree).simulate('click', testUtils.stubClickEvent(35, 70)) // click on 10 138 | expect(onChangeCallback).toHaveBeenCalledWith(10) 139 | 140 | onChangeCallback.mockClear() 141 | getCircle(tree).simulate('click', testUtils.stubClickEvent(128 + 5, 30)) // click on 12, but a little more right 142 | expect(onChangeCallback).toHaveBeenCalledWith(12) 143 | 144 | onChangeCallback.mockClear() 145 | getCircle(tree).simulate('click', testUtils.stubClickEvent(128 + 5, 64)) // click on 0, but a little more right 146 | expect(onChangeCallback).toHaveBeenCalledWith(0) 147 | }) 148 | }) 149 | 150 | describe('12h', () => { 151 | it('matches the snapshot', () => { 152 | const tree = mount( 153 | 154 | ) 155 | expect(tree).toMatchSnapshot() 156 | }) 157 | 158 | it('displays 12 hours', () => { 159 | const tree = mount( 160 | 161 | ) 162 | 163 | const spans = tree.find('span') 164 | for (let i = 1; i <= 12; i++) { 165 | expect(spans.findWhere((e) => e.type() === 'span' && parseInt(e.text()) === i).length).toBe(1) 166 | } 167 | }) 168 | 169 | it('rotates the pointer to point to the selected hour', () => { 170 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(0deg)') 171 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(90deg)') 172 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(180deg)') 173 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(270deg)') 174 | }) 175 | 176 | it('calls onChange when a different value is selected', () => { 177 | const onChangeCallback = jest.fn() 178 | const tree = mount() 179 | 180 | getCircle(tree).simulate('click', testUtils.stubClickEvent(250, 128)) // click on 3 181 | expect(onChangeCallback).toHaveBeenCalledWith(3) 182 | }) 183 | 184 | it('only calls onChange if the value actually changed', () => { 185 | const onChangeCallback = jest.fn() 186 | const tree = mount() 187 | 188 | getCircle(tree).simulate('click', testUtils.stubClickEvent(128, 30)) // click on 12 189 | expect(onChangeCallback).not.toHaveBeenCalled() 190 | }) 191 | }) 192 | 193 | describe('minutes', () => { 194 | it('matches the snapshot', () => { 195 | const tree = mount( 196 | 197 | ) 198 | expect(tree).toMatchSnapshot() 199 | }) 200 | 201 | it('displays minutes in 5 min steps, starting with 00', () => { 202 | const tree = mount( 203 | 204 | ) 205 | 206 | const spans = tree.find('span') 207 | expect(spans.findWhere((e) => e.type() === 'span' && e.text() === '00').length).toBe(1) 208 | for (let i = 1; i <= 11; i++) { 209 | expect(spans.findWhere((e) => e.type() === 'span' && parseInt(e.text()) === i * 5).length).toBe(1) 210 | } 211 | }) 212 | 213 | it('displays a different pointer for odd minutes', () => { 214 | const evenTree = mount( 215 | 216 | ) 217 | const evenPointer = getPointer(evenTree) 218 | 219 | const oddTree = mount( 220 | 221 | ) 222 | const oddPointer = getPointer(oddTree) 223 | 224 | expect(evenPointer.html()).not.toBe(oddPointer.html()) 225 | }) 226 | 227 | it('rotates the pointer to point to the selected minutes', () => { 228 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(-90deg)') 229 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(0deg)') 230 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(90deg)') 231 | expect(getPointer(mount()).getDOMNode().style.transform).toBe('rotate(180deg)') 232 | }) 233 | 234 | it('calls onChange when a different value is selected', () => { 235 | const onChangeCallback = jest.fn() 236 | const tree = mount() 237 | getCircle(tree).simulate('click', testUtils.stubClickEvent(190, 230)) // click on 25 238 | expect(onChangeCallback).toHaveBeenCalledWith(25) 239 | 240 | // ensure that 0 is treated as 0 and not 60 241 | onChangeCallback.mockClear() 242 | getCircle(tree).simulate('click', testUtils.stubClickEvent(128, 30)) // click on 0 243 | expect(onChangeCallback).toHaveBeenCalledWith(0) 244 | }) 245 | 246 | it('highlights the selected minute', () => { 247 | const tree55 = mount( 248 | 249 | ) 250 | const number55 = tree55.findWhere((e) => e.type() === 'span' && e.text() === '55') 251 | expect(testUtils.hasClass('selected')(number55)).toBeTruthy() 252 | const number50 = tree55.findWhere((e) => e.type() === 'span' && e.text() === '50') 253 | expect(testUtils.hasClass('selected')(number50)).toBeFalsy() 254 | 255 | const tree0 = mount( 256 | 257 | ) 258 | const number0 = tree0.findWhere((e) => e.type() === 'span' && e.text() === '00') 259 | expect(testUtils.hasClass('selected')(number0)).toBeTruthy() 260 | }) 261 | }) 262 | }) 263 | 264 | function getPointer (clock) { 265 | return clock.findWhere((e) => e.type() === 'div' && e.getDOMNode().className.indexOf('Clock-pointer') === 0) 266 | } 267 | 268 | function getCircle (clock) { 269 | return clock.childAt(0).children('div').childAt(0) 270 | } 271 | -------------------------------------------------------------------------------- /src/TimeInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Dialog from '@material-ui/core/Dialog' 4 | import DialogActions from '@material-ui/core/DialogActions' 5 | import Button from '@material-ui/core/Button' 6 | import Input from '@material-ui/core/Input' 7 | import { withStyles } from '@material-ui/core/styles' 8 | import TimePicker from './TimePicker' 9 | import { formatHours, twoDigits } from './util' 10 | 11 | const styles = { 12 | header: { 13 | borderTopLeftRadius: 2, 14 | borderTopRightRadius: 2 15 | }, 16 | body: { 17 | paddingBottom: 20 18 | } 19 | } 20 | 21 | class TimeInput extends React.Component { 22 | constructor (props) { 23 | super(props) 24 | const defaultValue = new Date() 25 | defaultValue.setSeconds(0) 26 | defaultValue.setMilliseconds(0) 27 | 28 | const open = !!props.openOnMount 29 | const value = props.value || props.defaultValue || props.initialTime || defaultValue 30 | 31 | this.state = { 32 | open, 33 | value, 34 | hasChanged: false, 35 | newValue: open ? value : null 36 | } 37 | } 38 | 39 | componentWillReceiveProps (nextProps) { 40 | if (nextProps.value !== this.props.value) { 41 | this.setState({ value: nextProps.value }) 42 | } 43 | } 44 | 45 | showDialog = () => this.setState({ open: true, newValue: this.state.value }) 46 | 47 | handleChange = (newValue) => { 48 | this.setState({ newValue, hasChanged: true }) 49 | } 50 | 51 | handleOk = () => { 52 | if (this.props.onChange != null) { 53 | this.props.onChange(this.state.newValue) 54 | } 55 | this.setState({ open: false, value: this.state.newValue, newValue: null }) 56 | } 57 | 58 | handleCancel = () => this.setState({ open: false, newValue: null }) 59 | 60 | getFormattedValue = () => { 61 | const { mode, placeholder } = this.props 62 | const { hasChanged } = this.state 63 | 64 | const is12h = mode === '12h' 65 | 66 | if (placeholder && !hasChanged) return placeholder 67 | 68 | let value = this.state.value 69 | if (this.props.hasOwnProperty('value')) { 70 | if (this.props.value == null) { 71 | // Allow a null/undefined value for controlled inputs 72 | return '' 73 | } 74 | value = this.props.value 75 | } 76 | 77 | const { hours, isPm } = formatHours(value.getHours(), mode) 78 | const minutes = twoDigits(value.getMinutes()) 79 | const displayHours = is12h ? hours : twoDigits(value.getHours()) 80 | const ending = is12h ? (isPm ? ' pm' : ' am') : '' 81 | return `${displayHours}:${minutes}${ending}` 82 | } 83 | 84 | render () { 85 | const { 86 | autoOk, 87 | cancelLabel, 88 | classes, 89 | ClockProps, 90 | defaultValue, 91 | disabled: disabledProp, 92 | initialTime, 93 | inputComponent: InputComponent, 94 | placeholder, 95 | mode, 96 | okLabel, 97 | onChange, 98 | openOnMount, 99 | TimePickerProps, 100 | value: valueProp, 101 | ...other 102 | } = this.props 103 | 104 | const { newValue, open } = this.state 105 | 106 | const { muiFormControl } = this.context 107 | const disabled = disabledProp || (muiFormControl != null && muiFormControl.disabled) 108 | 109 | return ( 110 | 111 | 118 | 123 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | ) 139 | } 140 | } 141 | 142 | TimeInput.propTypes = { 143 | /** If true, automatically accept and close the picker on set minutes. */ 144 | autoOk: PropTypes.bool, 145 | /** Override the label of the cancel button. */ 146 | cancelLabel: PropTypes.string, 147 | /** Properties to pass down to the Clock component. */ 148 | ClockProps: PropTypes.object, 149 | /** This default value overrides initialTime and placeholder. */ 150 | defaultValue: PropTypes.instanceOf(Date), 151 | /** The default value for the time picker. */ 152 | initialTime: PropTypes.instanceOf(Date), 153 | /** The component used for the input. Either a string to use a DOM element or a component. */ 154 | inputComponent: PropTypes.elementType, 155 | /** The placeholder value for the time picker before a time has been selected. */ 156 | placeholder: PropTypes.string, 157 | /** Sets the clock mode, 12-hour or 24-hour clocks are supported. */ 158 | mode: PropTypes.oneOf(['12h', '24h']), 159 | /** Override the label of the ok button. */ 160 | okLabel: PropTypes.string, 161 | /** Callback that is called with the new date (as Date instance) when the value is changed. */ 162 | onChange: PropTypes.func, 163 | /** If true, automatically opens the dialog when the component is mounted. */ 164 | openOnMount: PropTypes.bool, 165 | /** Properties to pass down to the TimePicker component. */ 166 | TimePickerProps: PropTypes.object, 167 | /** The value of the time picker, for use in controlled mode. */ 168 | value: PropTypes.instanceOf(Date) 169 | } 170 | 171 | TimeInput.defaultProps = { 172 | autoOk: false, 173 | cancelLabel: 'Cancel', 174 | inputComponent: Input, 175 | mode: '12h', 176 | okLabel: 'Ok' 177 | } 178 | 179 | TimeInput.contextTypes = { 180 | muiFormControl: PropTypes.object 181 | } 182 | 183 | export default withStyles(styles)(TimeInput) 184 | -------------------------------------------------------------------------------- /src/TimeInput.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import MockDate from 'mockdate' 5 | import { unwrap } from '@material-ui/core/test-utils' 6 | import Button from '@material-ui/core/Button' 7 | import Input from '@material-ui/core/Input' 8 | import TimeInput from './TimeInput' 9 | import TimePicker from './TimePicker' 10 | import Clock from './Clock' 11 | import * as testUtils from '../test/utils' 12 | 13 | describe('', () => { 14 | describe('24h', () => { 15 | it('matches the snapshot', () => { 16 | const originalGetHours = Date.prototype.getHours 17 | Date.prototype.getHours = Date.prototype.getUTCHours // eslint-disable-line 18 | const tree = mount() 19 | expect(tree).toMatchSnapshot() 20 | Date.prototype.getHours = originalGetHours // eslint-disable-line 21 | }) 22 | 23 | it('displays the formatted time', () => { 24 | const tree = mount() 25 | expect(getValue(tree)).toBe('13:37') 26 | 27 | const tree2 = mount() 28 | expect(getValue(tree2)).toBe('01:23') 29 | }) 30 | 31 | it('disables the input if the disabled prop is set to true', () => { 32 | const tree = mount() 33 | expect(tree.find(Input).prop('disabled')).toBe(true) 34 | }) 35 | }) 36 | 37 | describe('12h', () => { 38 | it('matches the snapshot', () => { 39 | const originalGetHours = Date.prototype.getHours 40 | Date.prototype.getHours = Date.prototype.getUTCHours // eslint-disable-line 41 | const tree = mount() 42 | expect(tree).toMatchSnapshot() 43 | Date.prototype.getHours = originalGetHours // eslint-disable-line 44 | }) 45 | 46 | it('displays the formatted time', () => { 47 | const tree = mount() 48 | expect(getValue(tree)).toBe('1:37 pm') 49 | 50 | const tree2 = mount() 51 | expect(getValue(tree2)).toBe('1:23 am') 52 | 53 | const tree3 = mount() 54 | expect(getValue(tree3)).toBe('12:00 am') 55 | 56 | const tree4 = mount() 57 | expect(getValue(tree4)).toBe('12:00 am') 58 | }) 59 | }) 60 | 61 | describe('controlled mode', () => { 62 | it('supports controlled mode', () => { 63 | const tree = mount() 64 | expect(getValue(tree)).toBe('13:37') 65 | 66 | tree.setProps({ value: new Date(2017, 10, 15, 14, 42, 0, 0) }) 67 | expect(getValue(tree)).toBe('14:42') 68 | }) 69 | 70 | it('displays no value if the value is null', () => { 71 | const tree = mount() 72 | expect(getValue(tree)).toBe('') 73 | }) 74 | 75 | it('always ignores the internal state if a value is specified', () => { 76 | const UnstyledTimeInput = unwrap(TimeInput) 77 | const tree = mount() 78 | 79 | // simulate time selection 80 | tree.instance().handleChange(new Date(2017, 10, 15, 13, 37, 0, 0)) 81 | tree.instance().handleOk() 82 | 83 | // controlled component, so value should stay as specified by the prop (issue #11) 84 | expect(getValue(tree)).toBe('14:42') 85 | }) 86 | }) 87 | 88 | describe('uncontrolled mode', () => { 89 | afterEach(() => { 90 | MockDate.reset() 91 | }) 92 | 93 | it('supports uncontrolled mode with a default value', () => { 94 | const tree = mount() 95 | expect(getValue(tree)).toBe('--:--') 96 | }) 97 | 98 | it('supports uncontrolled mode with an initial time', () => { 99 | const tree = mount() 100 | expect(getValue(tree)).toBe('13:37') 101 | }) 102 | 103 | it('supports a null value', () => { 104 | const tree = mount() 105 | 106 | expect(getValue(tree)).toBe('') 107 | }) 108 | 109 | it('overrides a null value with a placeholder', () => { 110 | const tree = mount() 111 | 112 | expect(getValue(tree)).toBe('--:--') 113 | }) 114 | 115 | it('uses the current time if no value or default value is set', () => { 116 | MockDate.set(new Date(2017, 10, 15, 13, 37, 0, 0)) 117 | const tree = mount() 118 | expect(getValue(tree)).toBe('13:37') 119 | }) 120 | }) 121 | 122 | describe('TimePicker dialog', () => { 123 | it('opens when clicking the input', () => { 124 | const UnstyledTimeInput = unwrap(TimeInput) 125 | const tree = mount() 126 | tree.find(Input).simulate('click') 127 | expect(tree.state('open')).toBe(true) 128 | }) 129 | 130 | it('opens when clicking the input if it is disabled', () => { 131 | const UnstyledTimeInput = unwrap(TimeInput) 132 | const tree = mount() 133 | tree.simulate('click') 134 | expect(tree.state('open')).toBe(false) 135 | }) 136 | 137 | it('closes when clicking ok', () => { 138 | const UnstyledTimeInput = unwrap(TimeInput) 139 | const tree = mount() 140 | tree.find(Input).simulate('click') 141 | tree.find(Button).at(1).simulate('click') 142 | expect(tree.state('open')).toBe(false) 143 | }) 144 | 145 | it('updates uses the new time when clicking ok', () => { 146 | const changeHandler = jest.fn() 147 | MockDate.set(new Date(2017, 10, 15, 13, 37, 0, 0)) 148 | const tree = mount() 149 | 150 | tree.simulate('click') 151 | getCircle(tree.find(Clock)) 152 | .simulate('click', testUtils.stubClickEvent(128, 30)) // click on 12 153 | .simulate('mouseup', testUtils.stubClickEvent(128, 30)) // click on 12 154 | tree.find(Button).at(1).simulate('click') 155 | 156 | expect(getValue(tree)).toBe('12:37') 157 | expect(changeHandler).toHaveBeenCalled() 158 | const time = changeHandler.mock.calls[0][0] 159 | expect(time.getHours()).toBe(12) 160 | expect(time.getMinutes()).toBe(37) 161 | }) 162 | 163 | it('closes when clicking cancel', () => { 164 | const tree = mount() 165 | tree.simulate('click') 166 | tree.find(Button).at(0).simulate('click') 167 | }) 168 | 169 | it('discards the new time when clicking cancel', () => { 170 | const changeHandler = jest.fn() 171 | MockDate.set(new Date(2017, 10, 15, 13, 37, 0, 0)) 172 | const tree = mount() 173 | 174 | tree.simulate('click') 175 | getCircle(tree.find(Clock)) 176 | .simulate('click', testUtils.stubClickEvent(128, 30)) // click on 12 177 | .simulate('mouseup', testUtils.stubClickEvent(128, 30)) // click on 12 178 | tree.find(Button).at(0).simulate('click') 179 | 180 | expect(getValue(tree)).toBe('13:37') // unchanged 181 | expect(changeHandler).not.toHaveBeenCalled() 182 | }) 183 | 184 | it('supports an initialTime prop', () => { 185 | const tree = mount() 186 | tree.simulate('click') 187 | expect(getValue(tree)).toBe('13:37') 188 | }) 189 | }) 190 | 191 | it('supports changing the input component', () => { 192 | const CustomInput = (props) => 193 | const tree = mount() 194 | expect(tree.find(CustomInput).length).toBe(1) 195 | }) 196 | 197 | it('automatically opens when the openOnMount prop is true', () => { 198 | const tree = mount() 199 | expect(tree.find(TimePicker).length).toBe(0) 200 | 201 | const tree2 = mount() 202 | expect(tree2.find(TimePicker).length).toBe(1) 203 | }) 204 | 205 | it('supports sending properties down to the time picker via TimePickerProps', () => { 206 | const TimePickerProps = { 207 | some: 'timePickerProp' 208 | } 209 | 210 | const tree = mount() 211 | expect(tree.find(TimePicker).prop('some')).toEqual('timePickerProp') 212 | }) 213 | 214 | it('supports sending properties down to the clock via ClockProps', () => { 215 | const ClockProps = { 216 | some: 'clockProp' 217 | } 218 | 219 | const tree = mount() 220 | expect(tree.find(Clock).prop('some')).toEqual('clockProp') 221 | }) 222 | }) 223 | 224 | function getValue (timeInput) { 225 | return timeInput.find('input[type="text"]').getDOMNode().value 226 | } 227 | 228 | function getCircle (clock) { 229 | return clock.childAt(0).children('div').childAt(0) 230 | } 231 | -------------------------------------------------------------------------------- /src/TimePicker.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { withStyles } from '@material-ui/core/styles' 4 | import { duration, easing } from '@material-ui/core/styles/transitions' 5 | import { getContrastRatio, fade } from '@material-ui/core/styles/colorManipulator' 6 | import classNames from 'classnames' 7 | import Clock from './Clock' 8 | import { formatHours, twoDigits } from './util' 9 | 10 | const styles = (theme) => ({ 11 | root: { 12 | width: 288, 13 | fontFamily: theme.typography.fontFamily 14 | }, 15 | header: { 16 | background: theme.palette.primary.main, 17 | color: fade(getContrastRatio(theme.palette.primary.main, theme.palette.common.black) < 7 ? theme.palette.common.white : theme.palette.common.black, 0.54), 18 | padding: '20px 0', 19 | lineHeight: '58px', 20 | fontSize: '58px', 21 | display: 'flex', 22 | justifyContent: 'center', 23 | alignItems: 'baseline', 24 | userSelect: 'none' 25 | }, 26 | time: { 27 | transition: `all ${duration.short}ms ${easing.easeInOut}`, 28 | cursor: 'pointer' 29 | }, 30 | placeholder: { 31 | flex: 1 32 | }, 33 | ampm: { 34 | display: 'flex', 35 | flexDirection: 'column-reverse', 36 | flex: 1, 37 | fontSize: '14px', 38 | lineHeight: '20px', 39 | marginLeft: 16, 40 | fontWeight: 700 41 | }, 42 | select: { 43 | color: getContrastRatio(theme.palette.primary.main, theme.palette.common.black) < 7 ? theme.palette.common.white : theme.palette.common.black 44 | }, 45 | body: { 46 | padding: '24px 16px', 47 | background: theme.palette.background.paper 48 | } 49 | }) 50 | 51 | class TimePicker extends React.Component { 52 | constructor (props) { 53 | super(props) 54 | 55 | const defaultValue = new Date() 56 | defaultValue.setSeconds(0) 57 | defaultValue.setMilliseconds(0) 58 | const time = props.value || props.defaultValue || defaultValue 59 | this.state = { 60 | select: 'h', 61 | hours: time.getHours(), 62 | minutes: time.getMinutes() 63 | } 64 | } 65 | 66 | componentWillReceiveProps (nextProps) { 67 | if (nextProps.value != null && (this.props.value == null || nextProps.value.getTime() !== this.props.value.getTime())) { 68 | this.setState({ 69 | hours: nextProps.value.getHours(), 70 | minutes: nextProps.value.getMinutes() 71 | }) 72 | } 73 | } 74 | 75 | handleClockChange = (value) => { 76 | if (this.state.select === 'h') { 77 | if (this.props.mode === '12h') { 78 | if (this.state.hours >= 12) { 79 | this.setState({ hours: value === 12 ? value : value + 12 }, this.propagateChange) 80 | } else { 81 | this.setState({ hours: value === 12 ? 0 : value }, this.propagateChange) 82 | } 83 | } else { 84 | this.setState({ hours: value }, this.propagateChange) 85 | } 86 | } else { 87 | this.setState({ minutes: value }, () => { 88 | this.propagateChange() 89 | }) 90 | } 91 | } 92 | 93 | handleClockChangeDone = (e) => { 94 | e.preventDefault() // prevent mouseUp after touchEnd 95 | 96 | if (this.state.select === 'm') { 97 | if (this.props.onMinutesSelected) { 98 | setTimeout(() => { 99 | this.props.onMinutesSelected() 100 | }, 300) 101 | } 102 | } else { 103 | setTimeout(() => { 104 | this.setState({ select: 'm' }) 105 | }, 300) 106 | } 107 | } 108 | 109 | editHours = () => this.setState({ select: 'h' }) 110 | 111 | editMinutes = () => this.setState({ select: 'm' }) 112 | 113 | setAm = () => { 114 | if (this.state.hours >= 12) { 115 | this.setState({ hours: this.state.hours - 12 }, this.propagateChange) 116 | } 117 | } 118 | 119 | setPm = () => { 120 | if (this.state.hours < 12) { 121 | this.setState({ hours: this.state.hours + 12 }, this.propagateChange) 122 | } 123 | } 124 | 125 | propagateChange = () => { 126 | if (this.props.onChange != null) { 127 | const date = new Date() 128 | date.setHours(this.state.hours) 129 | date.setMinutes(this.state.minutes) 130 | date.setSeconds(0) 131 | date.setMilliseconds(0) 132 | this.props.onChange(date) 133 | } 134 | } 135 | 136 | render () { 137 | const { 138 | classes, 139 | mode, 140 | ClockProps 141 | } = this.props 142 | 143 | const clockMode = this.state.select === 'm' ? 'minutes' : mode 144 | const { minutes } = this.state 145 | const { hours, isPm } = formatHours(this.state.hours, mode) 146 | 147 | return ( 148 |
149 |
150 |
151 |
152 | 156 | {twoDigits(hours)} 157 | 158 | : 159 | 163 | {twoDigits(minutes)} 164 | 165 |
166 | {mode === '12h' ? ( 167 |
168 | 172 | PM 173 | 174 | 178 | AM 179 | 180 |
181 | ) : (
)} 182 |
183 |
184 | 192 |
193 |
194 | ) 195 | } 196 | } 197 | 198 | TimePicker.propTypes = { 199 | /** The initial value of the time picker. */ 200 | defaultValue: PropTypes.instanceOf(Date), 201 | /** Sets the clock mode, 12-hour or 24-hour clocks are supported. */ 202 | mode: PropTypes.oneOf(['12h', '24h']), 203 | /** Callback that is called with the new date (as Date instance) when the value is changed. */ 204 | onChange: PropTypes.func, 205 | /** Callback that is called when the minutes are changed. Can be used to automatically hide the picker after selecting a time. */ 206 | onMinutesSelected: PropTypes.func, 207 | /** The value of the time picker, for use in controlled mode. */ 208 | value: PropTypes.instanceOf(Date), 209 | /** Properties to pass down to the Clock component */ 210 | ClockProps: PropTypes.object 211 | } 212 | 213 | TimePicker.defaultProps = { 214 | mode: '12h' 215 | } 216 | 217 | export default withStyles(styles)(TimePicker) 218 | -------------------------------------------------------------------------------- /src/TimePicker.md: -------------------------------------------------------------------------------- 1 | ```jsx 2 | const { Button, Dialog, DialogActions } = require('@material-ui/core'); 3 | initialState = { open: false }; 4 | 5 |
6 | 11 | 15 | 16 | 17 | 20 | 23 | 24 | 25 |
26 | ``` 27 | -------------------------------------------------------------------------------- /src/TimePicker.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from 'react' 3 | import { mount } from 'enzyme' 4 | import MockDate from 'mockdate' 5 | import * as testUtils from '../test/utils' 6 | import TimePicker from './TimePicker' 7 | 8 | describe('', () => { 9 | it('supports controlled mode', () => { 10 | const tree = mount() 11 | expect(tree.findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^TimePicker-header-/)(e)).text()).toBe('13:37') 12 | 13 | tree.setProps({ value: new Date(2017, 10, 15, 14, 42, 0, 0) }) 14 | expect(tree.findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^TimePicker-header-/)(e)).text()).toBe('14:42') 15 | }) 16 | 17 | describe('uncontrolled mode', () => { 18 | afterEach(() => { 19 | MockDate.reset() 20 | }) 21 | 22 | it('supports uncontrolled mode with a default value', () => { 23 | const tree = mount() 24 | expect(tree.findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^TimePicker-header-/)(e)).text()).toBe('13:37') 25 | }) 26 | 27 | it('uses the current time if no value or default value is set', () => { 28 | MockDate.set(new Date(2017, 10, 15, 13, 37, 0, 0)) 29 | const tree = mount() 30 | expect(tree.findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^TimePicker-header-/)(e)).text()).toBe('13:37') 31 | }) 32 | }) 33 | 34 | describe('12h', () => { 35 | it('matches the snapshot', () => { 36 | const originalGetHours = Date.prototype.getHours 37 | Date.prototype.getHours = Date.prototype.getUTCHours // eslint-disable-line 38 | const tree = mount( 39 | 40 | ) 41 | expect(tree).toMatchSnapshot() 42 | Date.prototype.getHours = originalGetHours // eslint-disable-line 43 | }) 44 | 45 | it('starts with the hour selection', () => { 46 | const date = new Date(2017, 10, 15, 13, 37, 0, 0) 47 | const tree = mount( 48 | 49 | ) 50 | const clock = getClock(tree) 51 | expect(clock.parent().props().mode).toBe('12h') 52 | }) 53 | 54 | it('changes the value and the clock mode correctly', () => { 55 | jest.useFakeTimers() 56 | const changeHandler = jest.fn() 57 | const date = new Date(2017, 10, 15, 13, 37, 0, 0) 58 | const tree = mount( 59 | 60 | ) 61 | 62 | // hour selection 63 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 64 | .simulate('click', testUtils.stubClickEvent(128, 30)) // click on 12 65 | .simulate('mouseup', testUtils.stubClickEvent(128, 30)) // click on 12 66 | 67 | expect(changeHandler).toBeCalled() 68 | expect(changeHandler.mock.calls[0][0].getHours()).toBe(12) 69 | expect(changeHandler.mock.calls[0][0].getMinutes()).toBe(37) 70 | 71 | jest.runAllTimers() 72 | tree.update() 73 | expect(getClock(tree).props().mode).toBe('minutes') 74 | 75 | // minute selection 76 | changeHandler.mockClear() 77 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 78 | .simulate('click', testUtils.stubClickEvent(190, 230)) // click on 25 79 | .simulate('mouseup', testUtils.stubClickEvent(190, 230)) // click on 25 80 | 81 | expect(changeHandler).toBeCalled() 82 | expect(changeHandler.mock.calls[0][0].getHours()).toBe(12) 83 | expect(changeHandler.mock.calls[0][0].getMinutes()).toBe(25) 84 | }) 85 | 86 | it('calls onMinutesSelected after selecting the minutes', () => { 87 | jest.useFakeTimers() 88 | const onMinutesSelected = jest.fn() 89 | const tree = mount( 90 | 91 | ) 92 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 93 | .simulate('click', testUtils.stubClickEvent(128, 30)) // click on 12 94 | .simulate('mouseup', testUtils.stubClickEvent(128, 30)) // click on 12 95 | jest.runAllTimers() 96 | tree.update() 97 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 98 | .simulate('click', testUtils.stubClickEvent(190, 230)) // click on 25 99 | .simulate('mouseup', testUtils.stubClickEvent(190, 230)) // click on 25 100 | jest.runAllTimers() 101 | 102 | expect(onMinutesSelected).toHaveBeenCalledTimes(1) 103 | }) 104 | 105 | it('can toggle between editing the hours and the minutes', () => { 106 | const tree = mount( 107 | 108 | ) 109 | 110 | tree.findWhere((e) => e != null && e.getDOMNode() != null && e.text() === '37') 111 | .simulate('click') // click on minutes 112 | expect(getClock(tree).props().mode).toBe('minutes') 113 | 114 | tree.findWhere((e) => e != null && e.getDOMNode() != null && e.text() === '01') 115 | .simulate('click') // click on hours 116 | expect(getClock(tree).props().mode).toBe('12h') 117 | }) 118 | 119 | it('supports toggling between am and pm', () => { 120 | const changeHandler = jest.fn() 121 | let tree = mount( 122 | 123 | ) 124 | 125 | // 13:37 = 1:37 pm --> 01:37 126 | tree.findWhere((e) => e != null && e.getDOMNode() != null && e.text() === 'AM') 127 | .simulate('click') 128 | expect(changeHandler).toBeCalled() 129 | expect(changeHandler.mock.calls[0][0].getHours()).toBe(1) 130 | expect(changeHandler.mock.calls[0][0].getMinutes()).toBe(37) 131 | 132 | // 01:37 = 1:37 am --> 13:37 133 | changeHandler.mockClear() 134 | tree.findWhere((e) => e != null && e.getDOMNode() != null && e.text() === 'PM') 135 | .simulate('click') 136 | expect(changeHandler).toBeCalled() 137 | expect(changeHandler.mock.calls[0][0].getHours()).toBe(13) 138 | expect(changeHandler.mock.calls[0][0].getMinutes()).toBe(37) 139 | 140 | tree = mount( 141 | 142 | ) 143 | // 01:37 = 1:37 am --> 01:37 144 | changeHandler.mockClear() 145 | tree.findWhere((e) => e != null && e.getDOMNode() != null && e.text() === 'AM') 146 | .simulate('click') 147 | expect(changeHandler).not.toBeCalled() 148 | 149 | tree = mount( 150 | 151 | ) 152 | // 13:37 = 1:37 pm --> 13:37 153 | changeHandler.mockClear() 154 | tree.findWhere((e) => e != null && e.getDOMNode() != null && e.text() === 'PM') 155 | .simulate('click') 156 | expect(changeHandler).not.toBeCalled() 157 | }) 158 | 159 | it('does not switch from am to pm when selecting 12 hours', () => { 160 | const changeHandler = jest.fn() 161 | let tree = mount( 162 | 163 | ) 164 | 165 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 166 | .simulate('click', testUtils.stubClickEvent(128, 30)) // click on 12 167 | 168 | expect(changeHandler).toHaveBeenCalled() 169 | const time = changeHandler.mock.calls[0][0] 170 | expect(time.getHours()).toBe(0) 171 | }) 172 | 173 | it('does not switch from am to pm when changing the hours', () => { 174 | const changeHandler = jest.fn() 175 | let tree = mount( 176 | 177 | ) 178 | 179 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 180 | .simulate('click', testUtils.stubClickEvent(250, 128 + 30)) // click on 3 181 | 182 | expect(changeHandler).toHaveBeenCalled() 183 | const time = changeHandler.mock.calls[0][0] 184 | expect(time.getHours()).toBe(3) 185 | }) 186 | 187 | it('does not switch from pm to am when selecting 12 hours', () => { 188 | const changeHandler = jest.fn() 189 | let tree = mount( 190 | 191 | ) 192 | 193 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 194 | .simulate('click', testUtils.stubClickEvent(128, 30)) // click on 12 195 | 196 | expect(changeHandler).toHaveBeenCalled() 197 | const time = changeHandler.mock.calls[0][0] 198 | expect(time.getHours()).toBe(12) 199 | }) 200 | 201 | it('does not switch from pm to am when changing the hours', () => { 202 | const changeHandler = jest.fn() 203 | let tree = mount( 204 | 205 | ) 206 | 207 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 208 | .simulate('click', testUtils.stubClickEvent(250, 128 + 30)) // click on 3 209 | 210 | expect(changeHandler).toHaveBeenCalled() 211 | const time = changeHandler.mock.calls[0][0] 212 | expect(time.getHours()).toBe(15) 213 | }) 214 | }) 215 | 216 | describe('24h', () => { 217 | it('matches the snapshot', () => { 218 | const originalGetHours = Date.prototype.getHours 219 | Date.prototype.getHours = Date.prototype.getUTCHours // eslint-disable-line 220 | const tree = mount( 221 | 222 | ) 223 | expect(tree).toMatchSnapshot() 224 | Date.prototype.getHours = originalGetHours // eslint-disable-line 225 | }) 226 | 227 | it('starts with the hour selection', () => { 228 | const date = new Date(2017, 10, 15, 13, 37, 0, 0) 229 | const tree = mount( 230 | 231 | ) 232 | const clock = getClock(tree) 233 | expect(clock.parent().props().mode).toBe('24h') 234 | }) 235 | 236 | it('changes the value and the clock mode correctly', () => { 237 | jest.useFakeTimers() 238 | const changeHandler = jest.fn() 239 | const date = new Date(2017, 10, 15, 13, 37, 0, 0) 240 | const tree = mount( 241 | 242 | ) 243 | 244 | // hour selection 245 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 246 | .simulate('click', testUtils.stubClickEvent(128, 30)) // click on 12 247 | .simulate('mouseup', testUtils.stubClickEvent(128, 30)) // click on 12 248 | 249 | expect(changeHandler).toBeCalled() 250 | expect(changeHandler.mock.calls[0][0].getHours()).toBe(12) 251 | expect(changeHandler.mock.calls[0][0].getMinutes()).toBe(37) 252 | 253 | jest.runAllTimers() 254 | tree.update() 255 | expect(getClock(tree).props().mode).toBe('minutes') 256 | 257 | // minute selection 258 | changeHandler.mockClear() 259 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 260 | .simulate('click', testUtils.stubClickEvent(190, 230)) // click on 25 261 | .simulate('mouseup', testUtils.stubClickEvent(190, 230)) // click on 25 262 | 263 | expect(changeHandler).toBeCalled() 264 | expect(changeHandler.mock.calls[0][0].getHours()).toBe(12) 265 | expect(changeHandler.mock.calls[0][0].getMinutes()).toBe(25) 266 | }) 267 | 268 | it('calls onMinutesSelected after selecting the minutes', () => { 269 | jest.useFakeTimers() 270 | const onMinutesSelected = jest.fn() 271 | const tree = mount( 272 | 273 | ) 274 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 275 | .simulate('click', testUtils.stubClickEvent(128, 30)) // click on 12 276 | .simulate('mouseup', testUtils.stubClickEvent(128, 30)) // click on 12 277 | jest.runAllTimers() 278 | tree.update() 279 | getClock(tree).findWhere((e) => e.type() === 'div' && testUtils.hasClass(/^Clock-circle/)(e)) 280 | .simulate('click', testUtils.stubClickEvent(190, 230)) // click on 25 281 | .simulate('mouseup', testUtils.stubClickEvent(190, 230)) // click on 25 282 | jest.runAllTimers() 283 | 284 | expect(onMinutesSelected).toHaveBeenCalledTimes(1) 285 | }) 286 | 287 | it('can toggle between editing the hours and the minutes', () => { 288 | const tree = mount( 289 | 290 | ) 291 | 292 | tree.findWhere((e) => e != null && e.getDOMNode() != null && e.text() === '37') 293 | .simulate('click') // click on minutes 294 | expect(getClock(tree).props().mode).toBe('minutes') 295 | 296 | tree.findWhere((e) => e != null && e.getDOMNode() != null && e.text() === '13') 297 | .simulate('click') // click on hours 298 | expect(getClock(tree).props().mode).toBe('24h') 299 | }) 300 | }) 301 | }) 302 | 303 | function getClock (picker) { 304 | return picker.findWhere((e) => e.name() === 'Clock') 305 | } 306 | -------------------------------------------------------------------------------- /src/__snapshots__/Clock.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` 12h matches the snapshot 1`] = ` 4 | 8 | 26 |
29 |
39 |
47 |
50 |
53 |
54 | 63 | 1 64 | 65 | 74 | 2 75 | 76 | 85 | 3 86 | 87 | 96 | 4 97 | 98 | 107 | 5 108 | 109 | 118 | 6 119 | 120 | 129 | 7 130 | 131 | 140 | 8 141 | 142 | 151 | 9 152 | 153 | 162 | 10 163 | 164 | 173 | 11 174 | 175 | 184 | 12 185 | 186 |
187 |
188 | 189 | 190 | `; 191 | 192 | exports[` 24h matches the snapshot 1`] = ` 193 | 197 | 215 |
218 |
228 |
236 |
239 |
242 |
243 | 252 | 1 253 | 254 | 263 | 2 264 | 265 | 274 | 3 275 | 276 | 285 | 4 286 | 287 | 296 | 5 297 | 298 | 307 | 6 308 | 309 | 318 | 7 319 | 320 | 329 | 8 330 | 331 | 340 | 9 341 | 342 | 351 | 10 352 | 353 | 362 | 11 363 | 364 | 373 | 12 374 | 375 | 384 | 13 385 | 386 | 395 | 14 396 | 397 | 406 | 15 407 | 408 | 417 | 16 418 | 419 | 428 | 17 429 | 430 | 439 | 18 440 | 441 | 450 | 19 451 | 452 | 461 | 20 462 | 463 | 472 | 21 473 | 474 | 483 | 22 484 | 485 | 494 | 23 495 | 496 | 505 | 00 506 | 507 |
508 |
509 | 510 | 511 | `; 512 | 513 | exports[` minutes matches the snapshot 1`] = ` 514 | 518 | 536 |
539 |
549 |
557 |
560 |
563 |
564 | 573 | 5 574 | 575 | 584 | 10 585 | 586 | 595 | 15 596 | 597 | 606 | 20 607 | 608 | 617 | 25 618 | 619 | 628 | 30 629 | 630 | 639 | 35 640 | 641 | 650 | 40 651 | 652 | 661 | 45 662 | 663 | 672 | 50 673 | 674 | 683 | 55 684 | 685 | 694 | 00 695 | 696 |
697 |
698 | 699 | 700 | `; 701 | -------------------------------------------------------------------------------- /src/__snapshots__/TimeInput.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` 12h matches the snapshot 1`] = ` 4 | 8 | 22 | 28 | 51 | 78 | 109 | 140 |
144 | 154 |
155 |
156 |
157 |
158 | 159 |
160 | 165 | 201 | 218 | 256 | 257 | 258 | 259 |
260 |
261 | `; 262 | 263 | exports[` 24h matches the snapshot 1`] = ` 264 | 268 | 282 | 288 | 311 | 338 | 369 | 400 |
404 | 414 |
415 |
416 |
417 |
418 | 419 |
420 | 425 | 461 | 478 | 516 | 517 | 518 | 519 |
520 |
521 | `; 522 | -------------------------------------------------------------------------------- /src/__snapshots__/TimePicker.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` 12h matches the snapshot 1`] = ` 4 | 8 | 23 |
26 |
29 |
32 |
33 | 37 | 01 38 | 39 | : 40 | 44 | 37 45 | 46 |
47 |
50 | 54 | PM 55 | 56 | 60 | AM 61 | 62 |
63 |
64 |
67 | 74 | 95 |
101 |
111 |
119 |
122 |
125 |
126 | 135 | 1 136 | 137 | 146 | 2 147 | 148 | 157 | 3 158 | 159 | 168 | 4 169 | 170 | 179 | 5 180 | 181 | 190 | 6 191 | 192 | 201 | 7 202 | 203 | 212 | 8 213 | 214 | 223 | 9 224 | 225 | 234 | 10 235 | 236 | 245 | 11 246 | 247 | 256 | 12 257 | 258 |
259 |
260 | 261 | 262 |
263 |
264 | 265 | 266 | `; 267 | 268 | exports[` 24h matches the snapshot 1`] = ` 269 | 273 | 288 |
291 |
294 |
297 |
298 | 302 | 13 303 | 304 | : 305 | 309 | 37 310 | 311 |
312 |
315 |
316 |
319 | 326 | 347 |
353 |
363 |
371 |
374 |
377 |
378 | 387 | 1 388 | 389 | 398 | 2 399 | 400 | 409 | 3 410 | 411 | 420 | 4 421 | 422 | 431 | 5 432 | 433 | 442 | 6 443 | 444 | 453 | 7 454 | 455 | 464 | 8 465 | 466 | 475 | 9 476 | 477 | 486 | 10 487 | 488 | 497 | 11 498 | 499 | 508 | 12 509 | 510 | 519 | 13 520 | 521 | 530 | 14 531 | 532 | 541 | 15 542 | 543 | 552 | 16 553 | 554 | 563 | 17 564 | 565 | 574 | 18 575 | 576 | 585 | 19 586 | 587 | 596 | 20 597 | 598 | 607 | 21 608 | 609 | 618 | 22 619 | 620 | 629 | 23 630 | 631 | 640 | 00 641 | 642 |
643 |
644 | 645 | 646 |
647 |
648 | 649 | 650 | `; 651 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default, default as TimeInput } from './TimeInput' 2 | export { default as Clock } from './Clock' 3 | export { default as TimePicker } from './TimePicker' 4 | -------------------------------------------------------------------------------- /src/index.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | describe('index.js', () => { 4 | it('should export the TimeInput as default export', () => { 5 | expect(require('./').default).toBe(require('./TimeInput').default) 6 | }) 7 | 8 | it('should export the TimeInput', () => { 9 | expect(require('./').TimeInput).toBe(require('./TimeInput').default) 10 | }) 11 | 12 | it('should export the TimePicker', () => { 13 | expect(require('./').TimePicker).toBe(require('./TimePicker').default) 14 | }) 15 | 16 | it('should export the Clock', () => { 17 | expect(require('./').Clock).toBe(require('./Clock').default) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export function twoDigits (n) { 2 | return n < 10 ? `0${n}` : `${n}` 3 | } 4 | 5 | export function formatHours (hours, mode) { 6 | const isPm = hours >= 12 7 | if (mode === '24h') { 8 | return { hours, isPm } 9 | } else if (hours === 0 || hours === 12) { 10 | return { hours: 12, isPm } 11 | } else if (hours < 12) { 12 | return { hours, isPm } 13 | } else { 14 | return { hours: hours - 12, isPm } 15 | } 16 | } 17 | 18 | function mod (a, b) { 19 | return a - Math.floor(a / b) * b 20 | } 21 | 22 | export function getShortestAngle (from, to) { 23 | const difference = to - from 24 | return from + mod(difference + 180, 360) - 180 25 | } 26 | -------------------------------------------------------------------------------- /src/util.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import { twoDigits, formatHours, getShortestAngle } from './util' 3 | 4 | describe('twoDigits', () => { 5 | it('adds a leading zero to one-digit numbers', () => { 6 | expect(twoDigits(3)).toBe('03') 7 | }) 8 | 9 | it('does not change two-digit numbers', () => { 10 | expect(twoDigits(42)).toBe('42') 11 | }) 12 | }) 13 | 14 | describe('formatHours', () => { 15 | it('tells whether the hours are am or pm', () => { 16 | expect(formatHours(0, '24h').isPm).toBe(false) 17 | expect(formatHours(1, '24h').isPm).toBe(false) 18 | expect(formatHours(12, '24h').isPm).toBe(true) 19 | expect(formatHours(13, '24h').isPm).toBe(true) 20 | }) 21 | 22 | it('returns 12 hour hours in 12h mode', () => { 23 | expect(formatHours(1, '12h').hours).toBe(1) 24 | expect(formatHours(13, '12h').hours).toBe(1) 25 | expect(formatHours(12, '12h').hours).toBe(12) 26 | expect(formatHours(0, '12h').hours).toBe(12) 27 | }) 28 | 29 | it('returns 24 hour hours in 24h mode', () => { 30 | expect(formatHours(1, '24h').hours).toBe(1) 31 | expect(formatHours(13, '24h').hours).toBe(13) 32 | expect(formatHours(12, '24h').hours).toBe(12) 33 | expect(formatHours(0, '24h').hours).toBe(0) 34 | }) 35 | }) 36 | 37 | describe('getShortestAngle', () => { 38 | it('calculates the angle with the shortest distance from a specific angle to another angle (modulo 360)', () => { 39 | expect(getShortestAngle(0, 90)).toBe(90) 40 | expect(getShortestAngle(20, 90)).toBe(90) 41 | expect(getShortestAngle(210, 240)).toBe(240) 42 | expect(getShortestAngle(-60, 240)).toBe(-120) 43 | expect(getShortestAngle(-60, 150)).toBe(-210) 44 | expect(getShortestAngle(42, 42)).toBe(42) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /stories/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import { action } from '@storybook/addon-actions' 4 | import InputLabel from '@material-ui/core/InputLabel' 5 | import FormControl from '@material-ui/core/FormControl' 6 | import FormHelperText from '@material-ui/core/FormHelperText' 7 | import Clock from '../src/Clock' 8 | import TimePicker from '../src/TimePicker' 9 | import TimeInput from '../src/TimeInput' 10 | 11 | storiesOf('Clock', module) 12 | .add('12 hours', () => ( 13 | 14 | )) 15 | .add('24 hours', () => ( 16 |
17 | 18 | 19 |
20 | )) 21 | .add('minutes', () => ( 22 |
23 | 24 | 25 |
26 | )) 27 | 28 | storiesOf('TimePicker', module) 29 | .add('12 hours', () => ( 30 | 31 | )) 32 | .add('24 hours', () => ( 33 | 34 | )) 35 | 36 | storiesOf('TimeInput', module) 37 | .add('12 hours', () => ( 38 | 39 | )) 40 | .add('24 hours', () => ( 41 | 42 | )) 43 | .add('complex example', () => ( 44 | 45 | Start time 46 | 47 | Some important helper text 48 | 49 | )) 50 | .add('german', () => ( 51 | 52 | )) 53 | .add('default input value and initial time', () => ( 54 | 55 | )) 56 | -------------------------------------------------------------------------------- /styleguide.config.js: -------------------------------------------------------------------------------- 1 | const { createConfig, babel } = require('webpack-blocks') 2 | const path = require('path') 3 | 4 | module.exports = { 5 | components: 'src/**/[A-Z]*.js', 6 | webpackConfig: createConfig([ 7 | babel() 8 | ]), 9 | getComponentPathLine: (componentPath) => { 10 | const name = path.basename(componentPath, '.js') 11 | return `import { ${name} } from 'material-ui-time-picker';` 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/jestsetup.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme' 2 | import Adapter from 'enzyme-adapter-react-16' 3 | // React 16 Enzyme adapter 4 | Enzyme.configure({ adapter: new Adapter() }) 5 | // Fail tests on any warning 6 | console.error = message => { 7 | throw new Error(message) 8 | } 9 | -------------------------------------------------------------------------------- /test/shim.js: -------------------------------------------------------------------------------- 1 | // temporary fix, see https://github.com/facebookincubator/create-react-app/issues/3199 and https://github.com/facebook/jest/issues/4545 2 | global.requestAnimationFrame = (cb) => { 3 | setTimeout(cb, 0) 4 | } 5 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | export function stubClickEvent (x, y) { 2 | return { 3 | clientX: x, 4 | clientY: y, 5 | target: { 6 | getBoundingClientRect: () => ({ 7 | left: 0, 8 | top: 0, 9 | right: 0, 10 | bottom: 0, 11 | width: 0, 12 | height: 0 13 | }) 14 | } 15 | } 16 | } 17 | 18 | export function stubTouchMoveEvent (x, y) { 19 | return { 20 | changedTouches: [{ 21 | clientX: x, 22 | clientY: y 23 | }], 24 | touches: [{ 25 | clientX: x, 26 | clientY: y 27 | }], 28 | target: { 29 | getBoundingClientRect: () => ({ 30 | left: 0, 31 | top: 0, 32 | right: 0, 33 | bottom: 0, 34 | width: 0, 35 | height: 0 36 | }) 37 | } 38 | } 39 | } 40 | 41 | export function stubTouchEndEvent (x, y) { 42 | return { 43 | changedTouches: [{ 44 | clientX: x, 45 | clientY: y 46 | }], 47 | touches: [], 48 | target: { 49 | getBoundingClientRect: () => ({ 50 | left: 0, 51 | top: 0, 52 | right: 0, 53 | bottom: 0, 54 | width: 0, 55 | height: 0 56 | }) 57 | } 58 | } 59 | } 60 | 61 | export function stubMouseMoveEvent (x, y, buttons) { 62 | return { 63 | clientX: x, 64 | clientY: y, 65 | buttons, 66 | target: { 67 | getBoundingClientRect: () => ({ 68 | left: 0, 69 | top: 0, 70 | right: 0, 71 | bottom: 0, 72 | width: 0, 73 | height: 0 74 | }) 75 | } 76 | } 77 | } 78 | 79 | export function hasClass (className) { 80 | if (typeof className === 'string') { 81 | return (element) => element.getDOMNode() != null && element.getDOMNode().className.split(' ').some((name) => name === className) 82 | } else { // regex 83 | return (element) => element.getDOMNode() != null && element.getDOMNode().className.split(' ').some((name) => className.test(name)) 84 | } 85 | } 86 | --------------------------------------------------------------------------------