├── .npmignore ├── .babelrc ├── src ├── index.js ├── components │ ├── PickerEffects.js │ ├── TimePicker.js │ ├── TimePickerSelection.js │ ├── HourFormat.js │ ├── MinuteWheel.js │ └── HourWheel.js ├── helpers │ └── index.js └── styles │ └── react-ios-time-picker.css ├── example ├── README.md ├── src │ ├── index.css │ ├── index.js │ └── App.js ├── public │ └── index.html └── package.json ├── .prettierrc.json ├── babel.config.json ├── .gitignore ├── LICENCE ├── webpack.config.js ├── .eslintrc.json ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | src -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react", "@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import TimePicker from './components/TimePicker'; 2 | 3 | export { TimePicker }; 4 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # React ios time picker - demo 2 | 3 | ![React-ios-time-picker demo](https://res.cloudinary.com/emdpro/image/upload/v1661245249/demo_bcmzme.gif) 4 | -------------------------------------------------------------------------------- /example/src/index.css: -------------------------------------------------------------------------------- 1 | .App { 2 | width: 100vw; 3 | height: 100vh; 4 | display: flex; 5 | justify-content: center; 6 | background-color: rgb(46, 45, 45); 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "printWidth": 100, 4 | "tabWidth": 3, 5 | "semi": true, 6 | "bracketSpacing": true, 7 | "singleQuote": true 8 | } 9 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render(); 8 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React App 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "targets": { 7 | "edge": "17", 8 | "firefox": "60", 9 | "chrome": "67", 10 | "safari": "11.1" 11 | }, 12 | "useBuiltIns": "usage", 13 | "corejs": "3.6.5" 14 | } 15 | ], 16 | "@babel/preset-react" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { TimePicker } from 'react-ios-time-picker'; 3 | 4 | function App() { 5 | const [value, setValue] = useState('10:00'); 6 | 7 | const onChange = (time) => { 8 | setValue(time); 9 | }; 10 | 11 | return ( 12 |
13 | 14 |
15 | ); 16 | } 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /src/components/PickerEffects.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function PickerEffects({ height }) { 4 | return ( 5 | <> 6 |
7 |
11 | 12 | ); 13 | } 14 | 15 | export default PickerEffects; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | package-lock.json 26 | dist 27 | 28 | # Output of 'npm pack' 29 | *.tgz 30 | 31 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.3.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "react": "^18.2.0", 10 | "react-dom": "^18.2.0", 11 | "react-ios-time-picker": "^0.1.1", 12 | "react-scripts": "5.0.1", 13 | "web-vitals": "^2.1.4" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "test": "react-scripts test", 19 | "eject": "react-scripts eject" 20 | }, 21 | "eslintConfig": { 22 | "extends": [ 23 | "react-app", 24 | "react-app/jest" 25 | ] 26 | }, 27 | "browserslist": { 28 | "production": [ 29 | ">0.2%", 30 | "not dead", 31 | "not op_mini all" 32 | ], 33 | "development": [ 34 | "last 1 chrome version", 35 | "last 1 firefox version", 36 | "last 1 safari version" 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-PRESENT MEddarhri 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 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: './src/index.js', 6 | output: { 7 | path: path.resolve('dist'), 8 | filename: 'TimePicker.js', 9 | libraryTarget: 'commonjs2', 10 | }, 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.js?$/, 15 | exclude: /(node_modules)/, 16 | use: 'babel-loader', 17 | }, 18 | { 19 | test: /\.css$/, 20 | use: ['style-loader', 'css-loader'], 21 | }, 22 | ], 23 | }, 24 | resolve: { 25 | alias: { 26 | react: path.resolve(__dirname, './node_modules/react'), 27 | 'react-dom': path.resolve(__dirname, './node_modules/react-dom'), 28 | }, 29 | }, 30 | externals: { 31 | // Don't bundle react or react-dom 32 | react: { 33 | commonjs: 'react', 34 | commonjs2: 'react', 35 | amd: 'React', 36 | root: 'React', 37 | }, 38 | 'react-dom': { 39 | commonjs: 'react-dom', 40 | commonjs2: 'react-dom', 41 | amd: 'ReactDOM', 42 | root: 'ReactDOM', 43 | }, 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "browser": true 5 | }, 6 | "extends": ["plugin:react/recommended", "airbnb", "prettier"], 7 | "overrides": [], 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module" 11 | }, 12 | "plugins": ["react", "jsx-a11y"], 13 | "rules": { 14 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 15 | "react/react-in-jsx-scope": "off", 16 | "no-console": 0, 17 | "camelcase": 0, 18 | "function-paren-newline": 0, 19 | "react/prefer-stateless-function": 0, 20 | "react/forbid-prop-types": 0, 21 | "class-methods-use-this": 0, 22 | "react/no-unused-prop-types": 0, 23 | "react/no-array-index-key": 0, 24 | "react/no-danger": 0, 25 | "no-nested-ternary": 0, 26 | "no-restricted-globals": 0, 27 | "jsx-a11y/media-has-caption": 0, 28 | "jsx-a11y/label-has-for": 0, 29 | "jsx-a11y/anchor-has-content": 0, 30 | "jsx-a11y/anchor-is-valid": 0, 31 | "jsx-a11y/click-events-have-key-events": 0, 32 | "import/no-extraneous-dependencies": 0, 33 | "jsx-a11y/no-static-element-interactions": 0, 34 | "no-unused-vars": 0, 35 | "react/button-has-type": 0, 36 | "no-plusplus": 0, 37 | "import/prefer-default-export": 0, 38 | "react/prop-types": 0, 39 | "no-continue": 0, 40 | "radix": 0, 41 | "eqeqeq": 0, 42 | "array-callback-return": 0, 43 | "consistent-return": 0, 44 | "react/jsx-props-no-spreading": 0 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ios-time-picker", 3 | "version": "0.2.2", 4 | "description": "A modern time picker for your React app.", 5 | "main": "dist/TimePicker.js", 6 | "module": "dist/TimePicker.js", 7 | "typings": "dist/TimePicker.d.ts", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/MEddarhri/react-ios-time-picker" 12 | }, 13 | "files": [ 14 | "dist", 15 | "README.md" 16 | ], 17 | "dependencies": { 18 | "react-portal": "^4.2.2" 19 | }, 20 | "scripts": { 21 | "build": "webpack --mode production", 22 | "eslint": "eslint src --ext .js,.jsx", 23 | "eslint:fix": "npm run eslint -- --fix" 24 | }, 25 | "peerDependencies": { 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "@babel/cli": "^7.18.10", 49 | "@babel/core": "^7.18.10", 50 | "@babel/preset-env": "^7.18.10", 51 | "@babel/preset-react": "^7.18.6", 52 | "babel-eslint": "^10.1.0", 53 | "babel-loader": "^8.2.5", 54 | "css-loader": "^6.7.1", 55 | "eslint": "^8.22.0", 56 | "eslint-config-airbnb": "^19.0.4", 57 | "eslint-config-prettier": "^8.5.0", 58 | "eslint-plugin-import": "^2.26.0", 59 | "eslint-plugin-jsx-a11y": "^6.6.1", 60 | "eslint-plugin-react": "^7.30.1", 61 | "eslint-plugin-react-hooks": "^4.6.0", 62 | "prettier": "^2.7.1", 63 | "react": "^18.2.0", 64 | "react-dom": "^18.2.0", 65 | "style-loader": "^3.3.1", 66 | "webpack": "^5.74.0", 67 | "webpack-cli": "^4.10.0" 68 | }, 69 | "keywords": [ 70 | "react time picker", 71 | "time picker", 72 | "ios time picker", 73 | "time picker react ios", 74 | "wheel time picker", 75 | "react ios time picker", 76 | "wheel picker", 77 | "select time" 78 | ], 79 | "author": { 80 | "name": "MEddarhri", 81 | "email": "mokhtareddarhri@gmail.com" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/components/TimePicker.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Portal } from 'react-portal'; 3 | import TimePickerSelection from './TimePickerSelection'; 4 | import '../styles/react-ios-time-picker.css'; 5 | 6 | function TimePicker({ 7 | value: initialValue = null, 8 | cellHeight = 28, 9 | placeHolder = 'Select Time', 10 | pickerDefaultValue = '10:00', 11 | onChange = () => {}, 12 | onFocus = () => {}, 13 | onSave = () => {}, 14 | onCancel = () => {}, 15 | disabled = false, 16 | isOpen: initialIsOpenValue = false, 17 | required = false, 18 | cancelButtonText = 'Cancel', 19 | saveButtonText = 'Save', 20 | controllers = true, 21 | seperator = true, 22 | id = null, 23 | use12Hours = false, 24 | onAmPmChange = () => {}, 25 | name = null, 26 | onOpen = () => {}, 27 | popupClassName = null, 28 | inputClassName = null, 29 | }) { 30 | const [isOpen, setIsOpen] = useState(initialIsOpenValue); 31 | const [height, setHeight] = useState(cellHeight); 32 | const [inputValue, setInputValue] = useState(initialValue); 33 | 34 | const handleClick = () => { 35 | setIsOpen(!isOpen); 36 | }; 37 | 38 | const handleFocus = () => { 39 | onFocus(); 40 | onOpen(); 41 | }; 42 | 43 | let finalValue = inputValue; 44 | 45 | if (initialValue === null && use12Hours) { 46 | finalValue = `${pickerDefaultValue} AM`; 47 | } else if (initialValue === null && !use12Hours) { 48 | finalValue = pickerDefaultValue; 49 | } 50 | 51 | const params = { 52 | onChange, 53 | height, 54 | onSave, 55 | onCancel, 56 | cancelButtonText, 57 | saveButtonText, 58 | controllers, 59 | setInputValue, 60 | setIsOpen, 61 | seperator, 62 | use12Hours, 63 | onAmPmChange, 64 | initialValue: finalValue, 65 | pickerDefaultValue, 66 | }; 67 | 68 | return ( 69 | <> 70 |
71 | 83 |
84 | {isOpen && !disabled && ( 85 | 86 |
87 |
setIsOpen(!isOpen)} 90 | /> 91 | 92 |
93 | 94 | )} 95 | 96 | ); 97 | } 98 | 99 | export default TimePicker; 100 | -------------------------------------------------------------------------------- /src/components/TimePickerSelection.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import HourFormat from './HourFormat'; 3 | import HourWheel from './HourWheel'; 4 | import MinuteWheel from './MinuteWheel'; 5 | 6 | function TimePickerSelection({ 7 | pickerDefaultValue, 8 | initialValue, 9 | onChange, 10 | height, 11 | onSave, 12 | onCancel, 13 | cancelButtonText, 14 | saveButtonText, 15 | controllers, 16 | setInputValue, 17 | setIsOpen, 18 | seperator, 19 | use12Hours, 20 | onAmPmChange, 21 | }) { 22 | const initialTimeValue = use12Hours ? initialValue.slice(0, 5) : initialValue; 23 | const [value, setValue] = useState( 24 | initialValue === null ? pickerDefaultValue : initialTimeValue, 25 | ); 26 | const [hourFormat, setHourFormat] = useState({ 27 | mount: false, 28 | hourFormat: initialValue.slice(6, 8), 29 | }); 30 | 31 | useEffect(() => { 32 | if (controllers === false) { 33 | const finalSelectedValue = use12Hours ? `${value} ${hourFormat.hourFormat}` : value; 34 | setInputValue(finalSelectedValue); 35 | onChange(finalSelectedValue); 36 | } 37 | }, [value]); 38 | 39 | useEffect(() => { 40 | if (hourFormat.mount) { 41 | onAmPmChange(hourFormat.hourFormat); 42 | } 43 | }, [hourFormat]); 44 | 45 | const params = { 46 | height, 47 | value, 48 | setValue, 49 | controllers, 50 | use12Hours, 51 | onAmPmChange, 52 | setHourFormat, 53 | hourFormat, 54 | }; 55 | 56 | const handleSave = () => { 57 | const finalSelectedValue = use12Hours ? `${value} ${hourFormat.hourFormat}` : value; 58 | setInputValue(finalSelectedValue); 59 | onChange(finalSelectedValue); 60 | onSave(finalSelectedValue); 61 | setIsOpen(false); 62 | }; 63 | const handleCancel = () => { 64 | onCancel(); 65 | setIsOpen(false); 66 | }; 67 | 68 | return ( 69 |
70 | {controllers && ( 71 |
72 | 78 | 81 |
82 | )} 83 |
87 |
94 | 95 | {seperator &&
:
} 96 | 97 | {use12Hours && } 98 |
99 |
100 | ); 101 | } 102 | 103 | export default TimePickerSelection; 104 | -------------------------------------------------------------------------------- /src/helpers/index.js: -------------------------------------------------------------------------------- 1 | export const initialNumbersValue = (heightValue = 54, numbersLength = 24, value = null) => { 2 | const initialValue24hourFormat = [ 3 | { 4 | number: '00', 5 | translatedValue: (heightValue * 2).toString(), 6 | selected: false, 7 | }, 8 | { 9 | number: '01', 10 | translatedValue: heightValue.toString(), 11 | selected: false, 12 | }, 13 | ]; 14 | 15 | const initialValue12hourFormat = [ 16 | { 17 | number: '00', 18 | translatedValue: heightValue.toString(), 19 | selected: false, 20 | hidden: true, 21 | }, 22 | { 23 | number: '01', 24 | translatedValue: heightValue.toString(), 25 | selected: false, 26 | }, 27 | ]; 28 | const arrayOfSelectedValue = 29 | numbersLength === 13 ? initialValue12hourFormat : initialValue24hourFormat; 30 | let count = 0; 31 | for (let index = 0; index < 3; index++) { 32 | for (let j = 0; j < numbersLength; j++) { 33 | if ((index === 0 && j < 2) || (numbersLength === 13 && j === 0)) { 34 | continue; 35 | } 36 | if (index === 1 && j === value) { 37 | if (j.toString().length === 1) { 38 | arrayOfSelectedValue.push({ 39 | number: `0${j.toString()}`, 40 | translatedValue: `-${count}`, 41 | selected: true, 42 | }); 43 | } else { 44 | arrayOfSelectedValue.push({ 45 | number: j.toString(), 46 | translatedValue: `-${count}`, 47 | selected: true, 48 | }); 49 | } 50 | count += heightValue; 51 | continue; 52 | } 53 | if (j.toString().length === 1) { 54 | arrayOfSelectedValue.push({ 55 | number: `0${j.toString()}`, 56 | translatedValue: `-${count}`, 57 | selected: false, 58 | }); 59 | } else { 60 | arrayOfSelectedValue.push({ 61 | number: j.toString(), 62 | translatedValue: `-${count}`, 63 | selected: false, 64 | }); 65 | } 66 | 67 | count += heightValue; 68 | } 69 | } 70 | 71 | return arrayOfSelectedValue; 72 | }; 73 | 74 | export const returnSelectedValue = (heightValue = 54, numbersLength = 24) => { 75 | const arrayOfSelectedValue = [ 76 | { 77 | number: '00', 78 | translatedValue: (heightValue * 2).toString(), 79 | arrayNumber: 0, 80 | }, 81 | { 82 | number: '01', 83 | translatedValue: heightValue.toString(), 84 | arrayNumber: 1, 85 | }, 86 | ]; 87 | let count = 0; 88 | for (let index = 0; index < 3; index++) { 89 | for (let j = 0; j < numbersLength; j++) { 90 | if ((index === 0 && j < 2) || (numbersLength === 13 && j === 0)) { 91 | continue; 92 | } 93 | if (j.toString().length === 1) { 94 | arrayOfSelectedValue.push({ 95 | number: `0${j.toString()}`, 96 | translatedValue: `-${count}`, 97 | selected: false, 98 | }); 99 | } else { 100 | arrayOfSelectedValue.push({ 101 | number: j.toString(), 102 | translatedValue: `-${count}`, 103 | selected: false, 104 | }); 105 | } 106 | 107 | count += heightValue; 108 | } 109 | } 110 | return arrayOfSelectedValue; 111 | }; 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/react-ios-time-picker)](https://www.npmjs.com/package/react-ios-time-picker) ![downloads](https://img.shields.io/npm/dt/react-ios-time-picker?color=blue&logo=npm&logoColor=blue) 2 | 3 | # React ios time picker 4 | 5 | ![React-ios-time-picker demo](https://res.cloudinary.com/emdpro/image/upload/v1661245249/demo_bcmzme.gif) 6 | 7 | A modern time picker for your next React app. 8 | 9 | - No moment.js needed 10 | - Zero dependencies and lightweight 11 | 12 | ## install 13 | 14 | ``` 15 | npm install react-ios-time-picker 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### 24 hours format 21 | 22 | ![24 hours format](https://res.cloudinary.com/emdpro/image/upload/v1661245289/24Hours_xbooc1.png) 23 | 24 | ```js 25 | import React, { useState } from 'react'; 26 | import { TimePicker } from 'react-ios-time-picker'; 27 | 28 | export default const MyApp = () => { 29 | const [value, setValue] = useState('10:00'); 30 | 31 | const onChange = (timeValue) => { 32 | setValue(timeValue); 33 | } 34 | 35 | return ( 36 |
37 | 38 |
39 | ); 40 | } 41 | ``` 42 | 43 | ### 12 hours format 44 | 45 | ![12 hours format](https://res.cloudinary.com/emdpro/image/upload/v1661245282/12Hours_tqf8gc.png) 46 | 47 | ```js 48 | import React, { useState } from 'react'; 49 | import { TimePicker } from 'react-ios-time-picker'; 50 | 51 | export default const MyApp = () => { 52 | const [value, setValue] = useState('10:00 AM'); 53 | 54 | const onChange = (timeValue) => { 55 | setValue(timeValue); 56 | } 57 | 58 | return ( 59 |
60 | 61 |
62 | ); 63 | } 64 | ``` 65 | 66 | ## API 67 | 68 | | Name | Type | Default | Description | 69 | | ------------------ | --------------------------------------------- | -------------- | --------------------------------------------------------------- | 70 | | value | String | n/a | Current value. | 71 | | cellHeight | Number | 35 | The height of the cell number. | 72 | | placeHolder | String | `"Selet_time"` | Time input's placeholder. | 73 | | pickerDefaultValue | String | `"00:00"` | The initial value that the picker begin with in the first time. | 74 | | disabled | Boolean | `false` | Whether picker is disabled. | 75 | | isOpen | Boolean | `false` | Whether the time picker should be opened. | 76 | | required | Boolean | `false` | Whether time input should be required. | 77 | | cancelButtonText | String | `"Cancel"` | Cancel button text content | 78 | | saveButtonText | String | `"Save"` | Save button text content | 79 | | controllers | Boolean | `true` | Whether the buttons should be displayed | 80 | | seperator | Boolean | `true` | whether show the colon seperator | 81 | | id | String | n/a | Input time picker id | 82 | | name | String | n/a | Input time picker name | 83 | | use12Hours | Boolean | false | 12 hours display mode | 84 | | inputClassName | String | n/a | Input time picker className | 85 | | popupClassName | String | n/a | time picker popup className | 86 | | onChange | `(value) => alert ('New time is: ', value)` | n/a | Called when select a different value | 87 | | onSave | `(value) => alert ('Time saved is: ', value)` | n/a | When the user clicks on save button | 88 | | onClose | `() => alert('Clock closed')` | n/a | When the user clicks on cancel button | 89 | | onAmPmChange | `(value) => alert('Am/Pm changed : value')` | n/a | called when select an am/pm value | 90 | | onOpen | `() => alert('time picker opened')` | n/a | called when time picker is opened | 91 | 92 | ## Contributions Welcome! 93 | 94 | ```shell 95 | git clone https://github.com/MEddarhri/react-ios-time-picker 96 | cd react-ios-time-picker 97 | ``` 98 | 99 | ## License 100 | 101 | The MIT License. 102 | -------------------------------------------------------------------------------- /src/styles/react-ios-time-picker.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | font-family: 'Roboto', sans-serif; 6 | } 7 | 8 | button { 9 | border: none; 10 | background: transparent; 11 | cursor: pointer; 12 | } 13 | 14 | input { 15 | border: none; 16 | background: transparent; 17 | cursor: pointer; 18 | } 19 | input:focus { 20 | outline: none; 21 | } 22 | 23 | .react-ios-time-picker { 24 | margin-bottom: 50px; 25 | border-radius: 12px; 26 | overflow: hidden; 27 | box-shadow: 0 11px 15px #0005; 28 | } 29 | 30 | .react-ios-time-picker-transition { 31 | animation: fade-in 150ms ease-out; 32 | } 33 | 34 | @keyframes fade-in { 35 | 0% { 36 | transform: translateY(150px); 37 | opacity: 0; 38 | } 39 | 100% { 40 | transform: translateY(0); 41 | opacity: 1; 42 | } 43 | } 44 | 45 | .react-ios-time-picker-container { 46 | display: flex; 47 | justify-content: center; 48 | position: relative; 49 | background-color: #1d1d1d; 50 | width: 220px; 51 | overflow: hidden; 52 | /* border-radius: 0 0 15px 17px; */ 53 | padding: 20px 0; 54 | /* box-shadow: inset 0px 0px 5px 0px rgba(255, 159, 10, 0.5); */ 55 | /* box-shadow: 0 11px 15px -7px rgb(0 0 0 / 20%), 56 | 0 24px 38px 3px rgb(0 0 0 / 14%), 0 9px 46px 8px rgb(0 0 0 / 12%); */ 57 | } 58 | 59 | .react-ios-time-picker-hour { 60 | position: relative; 61 | width: 50px; 62 | overflow: hidden; 63 | z-index: 100; 64 | margin-right: 5px; 65 | } 66 | 67 | .react-ios-time-picker-minute { 68 | position: relative; 69 | width: 50px; 70 | overflow: hidden; 71 | z-index: 100; 72 | margin-left: 5px; 73 | } 74 | 75 | .react-ios-time-picker-hour-format { 76 | position: relative; 77 | width: 40px; 78 | overflow: hidden; 79 | z-index: 100; 80 | } 81 | 82 | .react-ios-time-picker-fast { 83 | transition: transform 700ms cubic-bezier(0.13, 0.67, 0.01, 0.94); 84 | } 85 | 86 | .react-ios-time-picker-slow { 87 | transition: transform 600ms cubic-bezier(0.13, 0.67, 0.01, 0.94); 88 | } 89 | 90 | .react-ios-time-picker-selected-overlay { 91 | position: absolute; 92 | border-radius: 6px; 93 | background-color: #2c2c2f; 94 | pointer-events: none; 95 | margin: 0 10px; 96 | left: 0; 97 | right: 0; 98 | z-index: 1; 99 | /* box-shadow: inset 0px 0px 2px 0px rgba(255, 159, 10, 0.3); */ 100 | } 101 | 102 | .react-ios-time-picker-top-shadow { 103 | position: absolute; 104 | top: 0; 105 | width: 100%; 106 | background: #0009; 107 | background: linear-gradient(180deg, #0009 0%, #1c1c1c 100%); 108 | } 109 | 110 | .react-ios-time-picker-bottom-shadow { 111 | position: absolute; 112 | bottom: 0; 113 | width: 100%; 114 | background: #0009; 115 | background: linear-gradient(0deg, #0009 0%, hsla(0, 0%, 11%, 1) 100%); 116 | } 117 | 118 | .react-ios-time-picker-cell-hour { 119 | width: 100%; 120 | text-align: center; 121 | display: flex; 122 | justify-content: end; 123 | align-items: center; 124 | user-select: none; 125 | transition: all 100ms linear; 126 | } 127 | .react-ios-time-picker-cell-minute { 128 | width: 100%; 129 | text-align: center; 130 | display: flex; 131 | justify-content: start; 132 | align-items: center; 133 | user-select: none; 134 | transition: all 100ms linear; 135 | } 136 | .react-ios-time-picker-cell-hour-format { 137 | width: 100%; 138 | text-align: center; 139 | display: flex; 140 | justify-content: end; 141 | align-items: center; 142 | user-select: none; 143 | transition: all 100ms linear; 144 | } 145 | 146 | .react-ios-time-picker-cell-inner-hour { 147 | width: fit-content; 148 | height: 100%; 149 | transition: all 100ms linear; 150 | cursor: pointer; 151 | border-radius: 7px; 152 | line-height: 35px; 153 | text-align: center; 154 | display: flex; 155 | justify-content: end; 156 | align-items: center; 157 | font-size: 14px; 158 | color: #666; 159 | padding: 0 10px; 160 | } 161 | 162 | .react-ios-time-picker-cell-inner-hour-format { 163 | width: fit-content; 164 | height: 100%; 165 | transition: all 100ms linear; 166 | cursor: pointer; 167 | border-radius: 7px; 168 | line-height: 35px; 169 | text-align: center; 170 | display: flex; 171 | justify-content: end; 172 | align-items: center; 173 | font-size: 14px; 174 | color: #6a6a6b; 175 | padding: 0 10px; 176 | } 177 | 178 | .react-ios-time-picker-cell-inner-minute { 179 | width: fit-content; 180 | height: 100%; 181 | transition: all 100ms linear; 182 | cursor: pointer; 183 | border-radius: 7px; 184 | line-height: 35px; 185 | text-align: center; 186 | display: flex; 187 | justify-content: start; 188 | align-items: center; 189 | font-size: 14px; 190 | color: #6a6a6b; 191 | padding: 0 10px; 192 | } 193 | 194 | .react-ios-time-picker-cell-inner-hour:hover, 195 | .react-ios-time-picker-cell-inner-minute:hover, 196 | .react-ios-time-picker-cell-inner-hour-format:hover { 197 | background-color: #ff9d0ac9; 198 | color: white; 199 | } 200 | 201 | .react-ios-time-picker-cell-inner-selected { 202 | /* font-weight: 500; */ 203 | color: #f7f7f7; 204 | font-size: 16px; 205 | } 206 | 207 | .react-ios-time-picker-cell-inner-hour-format-selected { 208 | font-weight: 400; 209 | color: #f7f7f7; 210 | } 211 | 212 | .react-ios-time-picker-btn-container { 213 | position: relative; 214 | display: flex; 215 | justify-content: space-between; 216 | background-color: #292929; 217 | border-bottom: 1px solid #333; 218 | z-index: 100; 219 | } 220 | 221 | .react-ios-time-picker-btn { 222 | padding: 10px 15px; 223 | font-size: 13px; 224 | color: #fe9f06; 225 | transition: all 150ms linear; 226 | font-weight: 500; 227 | z-index: 1; 228 | } 229 | 230 | .react-ios-time-picker-btn:hover { 231 | opacity: 0.6; 232 | } 233 | 234 | .react-ios-time-picker-btn-cancel { 235 | font-size: 12px; 236 | font-weight: 300; 237 | } 238 | 239 | .react-ios-time-picker-popup { 240 | position: fixed; 241 | top: 0; 242 | bottom: 0; 243 | left: 0; 244 | right: 0; 245 | display: flex; 246 | justify-content: center; 247 | align-items: flex-end; 248 | z-index: 99998; 249 | } 250 | 251 | .react-ios-time-picker-popup-overlay { 252 | position: fixed; 253 | top: 0; 254 | bottom: 0; 255 | left: 0; 256 | right: 0; 257 | } 258 | 259 | .react-ios-time-picker-input { 260 | cursor: text; 261 | padding: 5px 10px; 262 | border-radius: 5px; 263 | border: 1px solid #0005; 264 | } 265 | 266 | .react-ios-time-picker-colon { 267 | display: flex; 268 | justify-content: center; 269 | align-items: center; 270 | height: 100%; 271 | color: #f7f7f7; 272 | position: relative; 273 | z-index: 100; 274 | font-weight: 600; 275 | } 276 | 277 | .react-ios-time-picker-cell-inner-hidden { 278 | opacity: 0; 279 | visibility: hidden; 280 | pointer-events: none; 281 | } 282 | 283 | .react-ios-time-picker-hour-format-transition { 284 | transition: transform 100ms ease-out; 285 | } 286 | -------------------------------------------------------------------------------- /src/components/HourFormat.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import { initialNumbersValue, returnSelectedValue } from '../helpers'; 3 | import PickerEffects from './PickerEffects'; 4 | 5 | function HourFormat({ height, value, setValue, onAmPmChange, setHourFormat, hourFormat }) { 6 | const Hours = [ 7 | { 8 | number: 'AM', 9 | translatedValue: (height * 2).toString(), 10 | selected: false, 11 | }, 12 | { 13 | number: 'PM', 14 | translatedValue: height.toString(), 15 | selected: false, 16 | }, 17 | ]; 18 | 19 | const [hours, setHours] = useState([ 20 | { 21 | number: 'AM', 22 | translatedValue: (height * 2).toString(), 23 | selected: hourFormat.hourFormat === 'AM', 24 | }, 25 | { 26 | number: 'PM', 27 | translatedValue: height.toString(), 28 | selected: hourFormat.hourFormat === 'PM', 29 | }, 30 | ]); 31 | const mainListRef = useRef(null); 32 | const [cursorPosition, setCursorPosition] = useState(null); 33 | const [firstCursorPosition, setFirstCursorPosition] = useState(null); 34 | const [currentTranslatedValue, setCurrentTranslatedValue] = useState( 35 | parseInt(hours.filter((item) => item.selected === true)[0].translatedValue), 36 | ); 37 | const [startCapture, setStartCapture] = useState(false); 38 | const [showFinalTranslate, setShowFinalTranslate] = useState(false); 39 | // start and end times 40 | const [dragStartTime, setDragStartTime] = useState(null); 41 | const [dragEndTime, setDragEndTime] = useState(null); 42 | // drag duration 43 | const [dragDuration, setDragDuration] = useState(null); 44 | // drag type fast or slow 45 | const [dragType, setDragType] = useState(null); 46 | // drag direction 47 | const [dragDirection, setDragDirection] = useState(null); 48 | // selected number 49 | const [selectedNumber, setSelectedNumber] = useState(null); 50 | 51 | const handleMouseDown = (e) => { 52 | setShowFinalTranslate(false); 53 | setFirstCursorPosition(e.clientY); 54 | setStartCapture(true); 55 | setDragStartTime(performance.now()); 56 | }; 57 | 58 | const handleTouchStart = (e) => { 59 | setShowFinalTranslate(false); 60 | setFirstCursorPosition(e.targetTouches[0].clientY); 61 | setStartCapture(true); 62 | setDragStartTime(performance.now()); 63 | }; 64 | 65 | const handleMouseUp = (e) => { 66 | setStartCapture(false); 67 | setCurrentTranslatedValue((prev) => prev + cursorPosition); 68 | setShowFinalTranslate(true); 69 | setDragEndTime(performance.now()); 70 | if (performance.now() - dragStartTime <= 100) { 71 | setDragType('fast'); 72 | } else { 73 | setDragType('slow'); 74 | } 75 | if (cursorPosition < 0) { 76 | setDragDirection('down'); 77 | } else { 78 | setDragDirection('up'); 79 | } 80 | }; 81 | 82 | const handleMouseLeave = (e) => { 83 | setStartCapture(false); 84 | setCurrentTranslatedValue((prev) => prev + cursorPosition); 85 | setShowFinalTranslate(true); 86 | setDragEndTime(performance.now()); 87 | 88 | if (cursorPosition < 0) { 89 | setDragDirection('down'); 90 | } else { 91 | setDragDirection('up'); 92 | } 93 | }; 94 | 95 | const handleMouseMove = (e) => { 96 | if (startCapture) { 97 | setCursorPosition(e.clientY - firstCursorPosition); 98 | } else { 99 | setCursorPosition(0); 100 | } 101 | }; 102 | 103 | const handleTouchMove = (e) => { 104 | if (startCapture) { 105 | setCursorPosition(e.targetTouches[0].clientY - firstCursorPosition); 106 | } else { 107 | setCursorPosition(0); 108 | } 109 | }; 110 | 111 | // preview translation 112 | useEffect(() => { 113 | if (startCapture) { 114 | mainListRef.current.style.transform = `translateY(${ 115 | currentTranslatedValue + cursorPosition 116 | }px)`; 117 | } 118 | }, [cursorPosition]); 119 | 120 | // final translation here 121 | useEffect(() => { 122 | if (showFinalTranslate) { 123 | setDragDuration(dragEndTime - dragStartTime); 124 | 125 | let finalValue = Math.round(currentTranslatedValue / height) * height; 126 | if (finalValue < height) finalValue = height; 127 | if (finalValue > height * 2) finalValue = height * 2; 128 | mainListRef.current.style.transform = `translateY(${finalValue}px)`; 129 | setCurrentTranslatedValue(finalValue); 130 | setCursorPosition(0); 131 | } 132 | }, [showFinalTranslate]); 133 | 134 | // return to default position after drag end (handleTransitionEnd) 135 | const handleTransitionEnd = (e) => { 136 | if (e.propertyName === 'transform') { 137 | const selectedValueArray = [ 138 | { 139 | number: 'AM', 140 | translatedValue: (height * 2).toString(), 141 | arrayNumber: 0, 142 | }, 143 | { 144 | number: 'PM', 145 | translatedValue: height.toString(), 146 | arrayNumber: 1, 147 | }, 148 | ]; 149 | selectedValueArray.map((item) => { 150 | if (parseInt(item.translatedValue) === currentTranslatedValue) { 151 | setSelectedNumber(item.arrayNumber); 152 | setHourFormat({ mount: true, hourFormat: item.number }); 153 | setHours(() => { 154 | const newValue = Hours.map((hour) => { 155 | if ( 156 | hour.number == item.number && 157 | hour.translatedValue == currentTranslatedValue 158 | ) { 159 | return { 160 | ...hour, 161 | selected: true, 162 | }; 163 | } 164 | return hour; 165 | }); 166 | return newValue; 167 | }); 168 | } 169 | }); 170 | } 171 | }; 172 | 173 | // handle click to select number 174 | const handleClickToSelect = (e) => { 175 | if (cursorPosition === 0) { 176 | setCurrentTranslatedValue(parseInt(e.target.dataset.translatedValue)); 177 | } 178 | }; 179 | 180 | /** *************************** handle wheel scroll ************************* */ 181 | 182 | const handleWheelScroll = (e) => { 183 | if (e.deltaY > 0) { 184 | if (currentTranslatedValue <= height) { 185 | setCurrentTranslatedValue((prev) => prev + height); 186 | } 187 | } else if (currentTranslatedValue >= height * 2) { 188 | setCurrentTranslatedValue((prev) => prev - height); 189 | } 190 | }; 191 | 192 | return ( 193 |
205 | {/* */} 206 |
212 | {hours.map((hourObj, index) => ( 213 |
218 |
227 | {hourObj.number} 228 |
229 |
230 | ))} 231 |
232 |
233 | ); 234 | } 235 | 236 | export default HourFormat; 237 | -------------------------------------------------------------------------------- /src/components/MinuteWheel.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import { initialNumbersValue, returnSelectedValue } from '../helpers'; 3 | 4 | function MinuteWheel({ height, value, setValue }) { 5 | const [hours, setHours] = useState(initialNumbersValue(height, 60, parseInt(value.slice(3, 6)))); 6 | const mainListRef = useRef(null); 7 | const [cursorPosition, setCursorPosition] = useState(null); 8 | const [firstCursorPosition, setFirstCursorPosition] = useState(null); 9 | const [currentTranslatedValue, setCurrentTranslatedValue] = useState( 10 | parseInt( 11 | initialNumbersValue(height, 60, parseInt(value.slice(3, 6))).filter( 12 | (item) => item.number === value.slice(3, 6) && item.selected === true, 13 | )[0].translatedValue, 14 | ), 15 | ); 16 | const [startCapture, setStartCapture] = useState(false); 17 | const [showFinalTranslate, setShowFinalTranslate] = useState(false); 18 | // start and end times 19 | const [dragStartTime, setDragStartTime] = useState(null); 20 | const [dragEndTime, setDragEndTime] = useState(null); 21 | // drag duration 22 | const [dragDuration, setDragDuration] = useState(null); 23 | // drag type fast or slow 24 | const [dragType, setDragType] = useState(null); 25 | // drag direction 26 | const [dragDirection, setDragDirection] = useState(null); 27 | // selected number 28 | const [selectedNumber, setSelectedNumber] = useState(null); 29 | 30 | const handleMouseDown = (e) => { 31 | setShowFinalTranslate(false); 32 | setFirstCursorPosition(e.clientY); 33 | setStartCapture(true); 34 | setDragStartTime(performance.now()); 35 | }; 36 | 37 | const handleTouchStart = (e) => { 38 | setShowFinalTranslate(false); 39 | setFirstCursorPosition(e.targetTouches[0].clientY); 40 | setStartCapture(true); 41 | setDragStartTime(performance.now()); 42 | }; 43 | 44 | const handleMouseUp = (e) => { 45 | setStartCapture(false); 46 | setCurrentTranslatedValue((prev) => prev + cursorPosition); 47 | setShowFinalTranslate(true); 48 | setDragEndTime(performance.now()); 49 | if (performance.now() - dragStartTime <= 100) { 50 | setDragType('fast'); 51 | } else { 52 | setDragType('slow'); 53 | } 54 | 55 | if (cursorPosition < 0) { 56 | setDragDirection('down'); 57 | } else { 58 | setDragDirection('up'); 59 | } 60 | }; 61 | 62 | const handleMouseLeave = (e) => { 63 | setStartCapture(false); 64 | setCurrentTranslatedValue((prev) => prev + cursorPosition); 65 | setShowFinalTranslate(true); 66 | setDragEndTime(performance.now()); 67 | if (performance.now() - dragStartTime <= 100) { 68 | setDragType('fast'); 69 | } else { 70 | setDragType('slow'); 71 | } 72 | 73 | if (cursorPosition < 0) { 74 | setDragDirection('down'); 75 | } else { 76 | setDragDirection('up'); 77 | } 78 | }; 79 | 80 | const handleMouseMove = (e) => { 81 | if (startCapture) { 82 | setCursorPosition(e.clientY - firstCursorPosition); 83 | } else { 84 | setCursorPosition(0); 85 | } 86 | }; 87 | 88 | const handleTouchMove = (e) => { 89 | if (startCapture) { 90 | setCursorPosition(e.targetTouches[0].clientY - firstCursorPosition); 91 | } else { 92 | setCursorPosition(0); 93 | } 94 | }; 95 | 96 | // preview translation 97 | useEffect(() => { 98 | if (startCapture) { 99 | mainListRef.current.style.transform = `translateY(${ 100 | currentTranslatedValue + cursorPosition 101 | }px)`; 102 | } 103 | }, [cursorPosition]); 104 | 105 | // final translation here 106 | useEffect(() => { 107 | if (showFinalTranslate) { 108 | setDragDuration(dragEndTime - dragStartTime); 109 | if (dragEndTime - dragStartTime <= 100 && cursorPosition !== 0) { 110 | let currentValue; 111 | if (dragDirection === 'down') { 112 | currentValue = currentTranslatedValue - (120 / (dragEndTime - dragStartTime)) * 100; 113 | } else if (dragDirection === 'up') { 114 | currentValue = currentTranslatedValue + (120 / (dragEndTime - dragStartTime)) * 100; 115 | } 116 | let finalValue = Math.round(currentValue / height) * height; 117 | if (finalValue < height * -177) finalValue = height * -177; 118 | if (finalValue > height * 2) finalValue = height * 2; 119 | 120 | mainListRef.current.style.transform = `translateY(${finalValue}px)`; 121 | setCurrentTranslatedValue(finalValue); 122 | } 123 | if (dragEndTime - dragStartTime > 100 && cursorPosition !== 0) { 124 | let finalValue = Math.round(currentTranslatedValue / height) * height; 125 | if (finalValue < height * -177) finalValue = height * -177; 126 | if (finalValue > height * 2) finalValue = height * 2; 127 | mainListRef.current.style.transform = `translateY(${finalValue}px)`; 128 | setCurrentTranslatedValue(finalValue); 129 | } 130 | setCursorPosition(0); 131 | } 132 | }, [showFinalTranslate]); 133 | 134 | // return to default position after drag end (handleTransitionEnd) 135 | const handleTransitionEnd = (e) => { 136 | returnSelectedValue(height, 60).map((item) => { 137 | if (parseInt(item.translatedValue) === currentTranslatedValue) { 138 | setSelectedNumber(item.arrayNumber); 139 | setValue((prev) => `${prev.slice(0, 2)}:${item.number}`); 140 | setHours(() => { 141 | const newValue = initialNumbersValue(height, 60).map((hour) => { 142 | if ( 143 | hour.number == item.number && 144 | hour.translatedValue == currentTranslatedValue 145 | ) { 146 | return { 147 | ...hour, 148 | selected: true, 149 | }; 150 | } 151 | return hour; 152 | }); 153 | return newValue; 154 | }); 155 | } 156 | }); 157 | }; 158 | 159 | // handle click to select number 160 | const handleClickToSelect = (e) => { 161 | if (cursorPosition === 0) { 162 | setCurrentTranslatedValue(parseInt(e.target.dataset.translatedValue)); 163 | } 164 | }; 165 | 166 | const isFastCondition = showFinalTranslate && dragType === 'fast'; 167 | const isSlowCondition = showFinalTranslate && dragType === 'slow'; 168 | 169 | /* *************************** handle wheel scroll ************************* */ 170 | 171 | const handleWheelScroll = (e) => { 172 | if (e.deltaY > 0) { 173 | if (currentTranslatedValue < height * 2) { 174 | setCurrentTranslatedValue((prev) => prev + height); 175 | } 176 | } else if (currentTranslatedValue > height * -177) { 177 | setCurrentTranslatedValue((prev) => prev - height); 178 | } 179 | }; 180 | 181 | return ( 182 |
194 | {/* */} 195 |
203 | {hours.map((hourObj, index) => ( 204 |
209 |
216 | {hourObj.number} 217 |
218 |
219 | ))} 220 |
221 |
222 | ); 223 | } 224 | 225 | export default MinuteWheel; 226 | -------------------------------------------------------------------------------- /src/components/HourWheel.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import { initialNumbersValue, returnSelectedValue } from '../helpers'; 3 | import PickerEffects from './PickerEffects'; 4 | 5 | function HourWheel({ height, value, setValue, use12Hours }) { 6 | const hourLength = use12Hours ? 13 : 24; 7 | const [hours, setHours] = useState( 8 | initialNumbersValue(height, hourLength, parseInt(value.slice(0, 2))), 9 | ); 10 | const mainListRef = useRef(null); 11 | const [cursorPosition, setCursorPosition] = useState(null); 12 | const [firstCursorPosition, setFirstCursorPosition] = useState(null); 13 | const [currentTranslatedValue, setCurrentTranslatedValue] = useState( 14 | parseInt( 15 | initialNumbersValue(height, hourLength, parseInt(value.slice(0, 2))).filter( 16 | (item) => item.number === value.slice(0, 2) && item.selected === true, 17 | )[0].translatedValue, 18 | ), 19 | ); 20 | const [startCapture, setStartCapture] = useState(false); 21 | const [showFinalTranslate, setShowFinalTranslate] = useState(false); 22 | // start and end times 23 | const [dragStartTime, setDragStartTime] = useState(null); 24 | const [dragEndTime, setDragEndTime] = useState(null); 25 | // drag duration 26 | const [dragDuration, setDragDuration] = useState(null); 27 | // drag type fast or slow 28 | const [dragType, setDragType] = useState(null); 29 | // drag direction 30 | const [dragDirection, setDragDirection] = useState(null); 31 | // selected number 32 | const [selectedNumber, setSelectedNumber] = useState(null); 33 | 34 | const handleMouseDown = (e) => { 35 | setShowFinalTranslate(false); 36 | setFirstCursorPosition(e.clientY); 37 | setStartCapture(true); 38 | setDragStartTime(performance.now()); 39 | }; 40 | 41 | const handleTouchStart = (e) => { 42 | setShowFinalTranslate(false); 43 | setFirstCursorPosition(e.targetTouches[0].clientY); 44 | setStartCapture(true); 45 | setDragStartTime(performance.now()); 46 | }; 47 | 48 | const handleMouseUp = (e) => { 49 | setStartCapture(false); 50 | setCurrentTranslatedValue((prev) => prev + cursorPosition); 51 | setShowFinalTranslate(true); 52 | setDragEndTime(performance.now()); 53 | if (performance.now() - dragStartTime <= 100) { 54 | setDragType('fast'); 55 | } else { 56 | setDragType('slow'); 57 | } 58 | if (cursorPosition < 0) { 59 | setDragDirection('down'); 60 | } else { 61 | setDragDirection('up'); 62 | } 63 | }; 64 | 65 | const handleMouseLeave = (e) => { 66 | setStartCapture(false); 67 | setCurrentTranslatedValue((prev) => prev + cursorPosition); 68 | setShowFinalTranslate(true); 69 | setDragEndTime(performance.now()); 70 | if (performance.now() - dragStartTime <= 100) { 71 | setDragType('fast'); 72 | } else { 73 | setDragType('slow'); 74 | } 75 | 76 | if (cursorPosition < 0) { 77 | setDragDirection('down'); 78 | } else { 79 | setDragDirection('up'); 80 | } 81 | }; 82 | 83 | const handleMouseMove = (e) => { 84 | if (startCapture) { 85 | setCursorPosition(e.clientY - firstCursorPosition); 86 | } else { 87 | setCursorPosition(0); 88 | } 89 | }; 90 | 91 | const handleTouchMove = (e) => { 92 | if (startCapture) { 93 | setCursorPosition(e.targetTouches[0].clientY - firstCursorPosition); 94 | } else { 95 | setCursorPosition(0); 96 | } 97 | }; 98 | 99 | // preview translation 100 | useEffect(() => { 101 | if (startCapture) { 102 | mainListRef.current.style.transform = `translateY(${ 103 | currentTranslatedValue + cursorPosition 104 | }px)`; 105 | } 106 | }, [cursorPosition]); 107 | 108 | // final translation here 109 | useEffect(() => { 110 | if (showFinalTranslate) { 111 | setDragDuration(dragEndTime - dragStartTime); 112 | if (dragEndTime - dragStartTime <= 100 && cursorPosition !== 0) { 113 | let currentValue; 114 | if (dragDirection === 'down') { 115 | currentValue = currentTranslatedValue - (120 / (dragEndTime - dragStartTime)) * 100; 116 | } else if (dragDirection === 'up') { 117 | currentValue = currentTranslatedValue + (120 / (dragEndTime - dragStartTime)) * 100; 118 | } 119 | let finalValue = Math.round(currentValue / height) * height; 120 | if (use12Hours) { 121 | if (finalValue < height * -34) finalValue = height * -34; 122 | if (finalValue > height) finalValue = height; 123 | } else { 124 | if (finalValue < height * -69) finalValue = height * -69; 125 | if (finalValue > height * 2) finalValue = height * 2; 126 | } 127 | 128 | mainListRef.current.style.transform = `translateY(${finalValue}px)`; 129 | setCurrentTranslatedValue(finalValue); 130 | } 131 | if (dragEndTime - dragStartTime > 100 && cursorPosition !== 0) { 132 | let finalValue = Math.round(currentTranslatedValue / height) * height; 133 | if (use12Hours) { 134 | if (finalValue < height * -34) finalValue = height * -34; 135 | if (finalValue > height) finalValue = height; 136 | } else { 137 | if (finalValue < height * -69) finalValue = height * -69; 138 | if (finalValue > height * 2) finalValue = height * 2; 139 | } 140 | mainListRef.current.style.transform = `translateY(${finalValue}px)`; 141 | setCurrentTranslatedValue(finalValue); 142 | } 143 | setCursorPosition(0); 144 | } 145 | }, [showFinalTranslate]); 146 | 147 | // return to default position after drag end (handleTransitionEnd) 148 | const handleTransitionEnd = (e) => { 149 | returnSelectedValue(height, hourLength).map((item) => { 150 | if (parseInt(item.translatedValue) === currentTranslatedValue) { 151 | setSelectedNumber(item.arrayNumber); 152 | setValue((prev) => `${item.number}:${prev.slice(3, 6)}`); 153 | setHours(() => { 154 | const newValue = initialNumbersValue(height, hourLength).map((hour) => { 155 | if ( 156 | hour.number == item.number && 157 | hour.translatedValue == currentTranslatedValue 158 | ) { 159 | return { 160 | ...hour, 161 | selected: true, 162 | }; 163 | } 164 | return hour; 165 | }); 166 | return newValue; 167 | }); 168 | } 169 | }); 170 | }; 171 | 172 | // handle click to select number 173 | const handleClickToSelect = (e) => { 174 | if (cursorPosition === 0) { 175 | setCurrentTranslatedValue(parseInt(e.target.dataset.translatedValue)); 176 | } 177 | }; 178 | 179 | const isFastCondition = showFinalTranslate && dragType === 'fast'; 180 | const isSlowCondition = showFinalTranslate && dragType === 'slow'; 181 | 182 | /** *************************** handle wheel scroll ************************* */ 183 | 184 | const handleWheelScroll = (e) => { 185 | if (use12Hours) { 186 | if (e.deltaY > 0) { 187 | if (currentTranslatedValue < height) { 188 | setCurrentTranslatedValue((prev) => prev + height); 189 | } 190 | } else if (currentTranslatedValue > height * -34) { 191 | setCurrentTranslatedValue((prev) => prev - height); 192 | } 193 | } else if (e.deltaY > 0) { 194 | if (currentTranslatedValue < height * 2) { 195 | setCurrentTranslatedValue((prev) => prev + height); 196 | } 197 | } else if (currentTranslatedValue > height * -69) { 198 | setCurrentTranslatedValue((prev) => prev - height); 199 | } 200 | }; 201 | 202 | return ( 203 |
217 | {/* */} 218 |
226 | {hours.map((hourObj, index) => ( 227 |
232 |
239 | {hourObj.number} 240 |
241 |
242 | ))} 243 |
244 |
245 | ); 246 | } 247 | 248 | export default HourWheel; 249 | --------------------------------------------------------------------------------