├── .github ├── FUNDING.yml └── workflows │ ├── ci.yml │ ├── close-stale-issues.yml │ └── publish.yml ├── .gitignore ├── .husky └── pre-commit ├── .vscode ├── extensions.json └── settings.json ├── .yarn └── plugins │ └── @yarnpkg │ └── plugin-nolyfill.cjs ├── .yarnrc.yml ├── LICENSE ├── README.md ├── biome.json ├── package.json ├── packages └── react-datetime-picker │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ ├── DateTimeInput.spec.tsx │ ├── DateTimeInput.tsx │ ├── DateTimeInput │ │ ├── NativeInput.spec.tsx │ │ └── NativeInput.tsx │ ├── DateTimePicker.css │ ├── DateTimePicker.spec.tsx │ ├── DateTimePicker.tsx │ ├── Divider.tsx │ ├── index.ts │ └── shared │ │ ├── dateFormatter.spec.ts │ │ ├── dateFormatter.ts │ │ ├── dates.spec.ts │ │ ├── dates.ts │ │ ├── types.ts │ │ ├── utils.spec.ts │ │ └── utils.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── vitest.config.ts │ └── vitest.setup.ts ├── sample ├── .gitignore ├── Sample.css ├── Sample.tsx ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json ├── vite.config.ts └── yarn.lock ├── test-utils.ts ├── test ├── .gitignore ├── LocaleOptions.tsx ├── MaxDetailOptions.tsx ├── Test.css ├── Test.tsx ├── ValidityOptions.tsx ├── ValueOptions.tsx ├── ViewOptions.tsx ├── index.html ├── index.tsx ├── package.json ├── shared │ └── types.ts ├── tsconfig.json └── vite.config.ts └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: wojtekmaj 2 | open_collective: react-date-picker 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ['*'] 6 | pull_request: 7 | branches: [main] 8 | 9 | env: 10 | HUSKY: 0 11 | 12 | jobs: 13 | lint: 14 | name: Static code analysis 15 | runs-on: ubuntu-24.04-arm 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Biome 22 | uses: biomejs/setup-biome@v2 23 | 24 | - name: Run tests 25 | run: biome lint 26 | 27 | typescript: 28 | name: Type checking 29 | runs-on: ubuntu-24.04-arm 30 | 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v4 34 | 35 | - name: Cache Yarn cache 36 | uses: actions/cache@v4 37 | env: 38 | cache-name: yarn-cache 39 | with: 40 | path: ~/.yarn/berry/cache 41 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} 42 | restore-keys: | 43 | ${{ runner.os }}-${{ env.cache-name }} 44 | 45 | - name: Use Node.js 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: '22' 49 | 50 | - name: Enable Corepack 51 | run: corepack enable 52 | 53 | - name: Install dependencies 54 | run: yarn --immutable 55 | 56 | - name: Build package 57 | run: yarn build 58 | 59 | - name: Run type checking 60 | run: yarn tsc 61 | 62 | format: 63 | name: Formatting 64 | runs-on: ubuntu-24.04-arm 65 | 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v4 69 | 70 | - name: Setup Biome 71 | uses: biomejs/setup-biome@v2 72 | 73 | - name: Run formatting 74 | run: biome format 75 | 76 | unit: 77 | name: Unit tests 78 | runs-on: ubuntu-24.04-arm 79 | 80 | steps: 81 | - name: Checkout 82 | uses: actions/checkout@v4 83 | 84 | - name: Cache Yarn cache 85 | uses: actions/cache@v4 86 | env: 87 | cache-name: yarn-cache 88 | with: 89 | path: ~/.yarn/berry/cache 90 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} 91 | restore-keys: | 92 | ${{ runner.os }}-${{ env.cache-name }} 93 | 94 | - name: Use Node.js 95 | uses: actions/setup-node@v4 96 | with: 97 | node-version: '22' 98 | 99 | - name: Enable Corepack 100 | run: corepack enable 101 | 102 | - name: Install dependencies 103 | run: yarn --immutable 104 | 105 | - name: Run tests 106 | run: yarn unit 107 | -------------------------------------------------------------------------------- /.github/workflows/close-stale-issues.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * 1' # Every Monday 6 | workflow_dispatch: 7 | 8 | jobs: 9 | close-issues: 10 | name: Close stale issues 11 | runs-on: ubuntu-24.04-arm 12 | 13 | steps: 14 | - name: Close stale issues 15 | uses: actions/stale@v8 16 | with: 17 | days-before-issue-stale: 90 18 | days-before-issue-close: 14 19 | stale-issue-label: 'stale' 20 | stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this issue will be closed in 14 days.' 21 | close-issue-message: 'This issue was closed because it has been stalled for 14 days with no activity.' 22 | exempt-issue-labels: 'fresh' 23 | remove-issue-stale-when-updated: true 24 | days-before-pr-stale: -1 25 | days-before-pr-close: -1 26 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | HUSKY: 0 9 | 10 | permissions: 11 | id-token: write 12 | 13 | jobs: 14 | publish: 15 | name: Publish 16 | runs-on: ubuntu-24.04-arm 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | 22 | - name: Cache Yarn cache 23 | uses: actions/cache@v4 24 | env: 25 | cache-name: yarn-cache 26 | with: 27 | path: ~/.yarn/berry/cache 28 | key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-${{ env.cache-name }} 31 | 32 | - name: Use Node.js 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: '22' 36 | registry-url: 'https://registry.npmjs.org' 37 | 38 | - name: Enable Corepack 39 | run: corepack enable 40 | 41 | - name: Install dependencies 42 | run: yarn --immutable 43 | 44 | - name: Publish with latest tag 45 | if: github.event.release.prelease == false 46 | run: yarn npm publish --tag latest --provenance 47 | working-directory: packages/react-datetime-picker 48 | env: 49 | YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 50 | 51 | - name: Publish with next tag 52 | if: github.event.release.prelease == true 53 | run: yarn npm publish --tag next --provenance 54 | working-directory: packages/react-datetime-picker 55 | env: 56 | YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS 2 | .DS_Store 3 | 4 | # Cache 5 | .cache 6 | .playwright 7 | .tmp 8 | *.tsbuildinfo 9 | .eslintcache 10 | 11 | # Yarn 12 | .pnp.* 13 | **/.yarn/* 14 | !**/.yarn/patches 15 | !**/.yarn/plugins 16 | !**/.yarn/releases 17 | !**/.yarn/sdks 18 | !**/.yarn/versions 19 | 20 | # Project-generated directories and files 21 | coverage 22 | dist 23 | node_modules 24 | playwright-report 25 | test-results 26 | package.tgz 27 | 28 | # Logs 29 | npm-debug.log 30 | yarn-error.log 31 | 32 | # .env files 33 | **/.env 34 | **/.env.* 35 | !**/.env.example 36 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | yarn format --staged --no-errors-on-unmatched --write 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"], 3 | "unwantedRecommendations": ["dbaeumer.jshint", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "editor.formatOnSave": true, 4 | "search.exclude": { 5 | "**/.yarn": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.yarn/plugins/@yarnpkg/plugin-nolyfill.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | //prettier-ignore 3 | module.exports = { 4 | name: "@yarnpkg/plugin-nolyfill", 5 | factory: function (require) { 6 | "use strict";var plugin=(()=>{var p=Object.defineProperty;var i=Object.getOwnPropertyDescriptor;var n=Object.getOwnPropertyNames;var y=Object.prototype.hasOwnProperty;var l=(t=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(t,{get:(r,e)=>(typeof require<"u"?require:r)[e]}):t)(function(t){if(typeof require<"u")return require.apply(this,arguments);throw new Error('Dynamic require of "'+t+'" is not supported')});var c=(t,r)=>{for(var e in r)p(t,e,{get:r[e],enumerable:!0})},g=(t,r,e,s)=>{if(r&&typeof r=="object"||typeof r=="function")for(let a of n(r))!y.call(t,a)&&a!==e&&p(t,a,{get:()=>r[a],enumerable:!(s=i(r,a))||s.enumerable});return t};var f=t=>g(p({},"__esModule",{value:!0}),t);var m={};c(m,{default:()=>h});var o=l("@yarnpkg/core"),d=["abab","array-buffer-byte-length","array-includes","array.from","array.of","array.prototype.at","array.prototype.every","array.prototype.find","array.prototype.findlast","array.prototype.findlastindex","array.prototype.flat","array.prototype.flatmap","array.prototype.flatmap","array.prototype.foreach","array.prototype.reduce","array.prototype.toreversed","array.prototype.tosorted","arraybuffer.prototype.slice","assert","asynciterator.prototype","available-typed-arrays","deep-equal","deep-equal-json","define-properties","es-aggregate-error","es-iterator-helpers","es-set-tostringtag","es6-object-assign","function-bind","function.prototype.name","get-symbol-description","globalthis","gopd","harmony-reflect","has","has-property-descriptors","has-proto","has-symbols","has-tostringtag","hasown","internal-slot","is-arguments","is-array-buffer","is-core-module","is-date-object","is-generator-function","is-nan","is-regex","is-shared-array-buffer","is-string","is-symbol","is-typed-array","is-weakref","isarray","iterator.prototype","json-stable-stringify","jsonify","object-is","object-keys","object.assign","object.entries","object.fromentries","object.getownpropertydescriptors","object.groupby","object.hasown","object.values","promise.allsettled","promise.any","reflect.getprototypeof","reflect.ownkeys","regexp.prototype.flags","safe-array-concat","safe-regex-test","set-function-length","side-channel","string.prototype.at","string.prototype.codepointat","string.prototype.includes","string.prototype.matchall","string.prototype.padend","string.prototype.padstart","string.prototype.repeat","string.prototype.replaceall","string.prototype.split","string.prototype.startswith","string.prototype.trim","string.prototype.trimend","string.prototype.trimleft","string.prototype.trimright","string.prototype.trimstart","typed-array-buffer","typed-array-byte-length","typed-array-byte-offset","typed-array-length","typedarray","unbox-primitive","util.promisify","which-boxed-primitive","which-typed-array"],u=new Map(d.map(t=>[o.structUtils.makeIdent(null,t).identHash,o.structUtils.makeIdent("nolyfill",t)])),b={hooks:{reduceDependency:async t=>{let r=u.get(t.identHash);if(r){let e=o.structUtils.makeDescriptor(r,"latest"),s=o.structUtils.makeRange({protocol:"npm:",source:null,selector:o.structUtils.stringifyDescriptor(e),params:null});return o.structUtils.makeDescriptor(t,s)}return t}}},h=b;return f(m);})(); 7 | return plugin; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | logFilters: 2 | - code: YN0076 3 | level: discard 4 | 5 | nodeLinker: node-modules 6 | 7 | plugins: 8 | - checksum: 9b6f8a34bda80f025c0b223fa80836f5e931cf5c8dd83e10ccfa9e677856cf1508b063d027060f74e3ce66ee1c8a936542e85db18a30584f9b88a50379b3f514 9 | path: .yarn/plugins/@yarnpkg/plugin-nolyfill.cjs 10 | spec: "https://raw.githubusercontent.com/wojtekmaj/yarn-plugin-nolyfill/v1.0.1/bundles/@yarnpkg/plugin-nolyfill.js" 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017–2024 Wojciech Maj 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 | packages/react-datetime-picker/README.md -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", 3 | "files": { 4 | "ignore": [".tsimp", ".yarn", "coverage", "dist", ".pnp.cjs", ".pnp.loader.mjs"] 5 | }, 6 | "formatter": { 7 | "lineWidth": 100, 8 | "indentStyle": "space" 9 | }, 10 | "linter": { 11 | "rules": { 12 | "complexity": { 13 | "noUselessSwitchCase": "off" 14 | }, 15 | "correctness": { 16 | "noUnusedImports": "warn", 17 | "noUnusedVariables": "warn" 18 | }, 19 | "suspicious": { 20 | "noConsoleLog": "warn" 21 | } 22 | } 23 | }, 24 | "css": { 25 | "formatter": { 26 | "quoteStyle": "single" 27 | } 28 | }, 29 | "javascript": { 30 | "formatter": { 31 | "quoteStyle": "single" 32 | } 33 | }, 34 | "overrides": [ 35 | { 36 | "include": ["**/package.json"], 37 | "formatter": { 38 | "lineWidth": 1 39 | } 40 | }, 41 | { 42 | "include": ["**/vite.config.ts"], 43 | "linter": { 44 | "rules": { 45 | "suspicious": { 46 | "noConsoleLog": "off" 47 | } 48 | } 49 | } 50 | } 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-datetime-picker-monorepo", 3 | "version": "1.0.0", 4 | "description": "react-datetime-picker monorepo", 5 | "type": "module", 6 | "workspaces": [ 7 | "packages/*", 8 | "test" 9 | ], 10 | "scripts": { 11 | "build": "yarn workspace react-datetime-picker build", 12 | "dev": "yarn workspace react-datetime-picker watch & yarn workspace test dev", 13 | "format": "yarn workspaces foreach --all run format", 14 | "lint": "yarn workspaces foreach --all run lint", 15 | "postinstall": "husky", 16 | "test": "yarn workspaces foreach --all run test", 17 | "tsc": "yarn workspaces foreach --all run tsc", 18 | "unit": "yarn workspaces foreach --all run unit" 19 | }, 20 | "devDependencies": { 21 | "husky": "^9.0.0" 22 | }, 23 | "packageManager": "yarn@4.9.1" 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017–2024 Wojciech Maj 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 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-datetime-picker", 3 | "version": "6.0.1", 4 | "description": "A date range picker for your React app.", 5 | "type": "module", 6 | "sideEffects": [ 7 | "*.css" 8 | ], 9 | "main": "./dist/cjs/index.js", 10 | "module": "./dist/esm/index.js", 11 | "source": "./src/index.ts", 12 | "types": "./dist/cjs/index.d.ts", 13 | "exports": { 14 | ".": { 15 | "import": "./dist/esm/index.js", 16 | "require": "./dist/cjs/index.js" 17 | }, 18 | "./dist/DateTimeInput": { 19 | "import": "./dist/esm/DateTimeInput.js", 20 | "require": "./dist/cjs/DateTimeInput.js" 21 | }, 22 | "./dist/DateTimeInput.js": { 23 | "import": "./dist/esm/DateTimeInput.js", 24 | "require": "./dist/cjs/DateTimeInput.js" 25 | }, 26 | "./dist/cjs/DateTimeInput": "./dist/cjs/DateTimeInput.js", 27 | "./dist/esm/DateTimeInput": "./dist/esm/DateTimeInput.js", 28 | "./*": "./*" 29 | }, 30 | "scripts": { 31 | "build": "yarn build-js && yarn copy-styles", 32 | "build-js": "yarn build-js-esm && yarn build-js-cjs && yarn build-js-cjs-package && yarn build-js-cjs-replace", 33 | "build-js-esm": "tsc --project tsconfig.build.json --outDir dist/esm", 34 | "build-js-cjs": "tsc --project tsconfig.build.json --outDir dist/cjs --module commonjs --moduleResolution node --verbatimModuleSyntax false", 35 | "build-js-cjs-package": "echo '{\n \"type\": \"commonjs\"\n}' > dist/cjs/package.json", 36 | "build-js-cjs-replace": "replace-in-files --string='/dist/esm/' --replacement='/dist/cjs/' dist/cjs/**/*", 37 | "clean": "node -e \"fs.rmSync('./dist', { recursive: true, force: true })\"", 38 | "copy-styles": "cpy 'src/**/*.css' dist", 39 | "format": "biome format", 40 | "lint": "biome lint", 41 | "prepack": "yarn clean && yarn build", 42 | "test": "yarn lint && yarn tsc && yarn format && yarn unit", 43 | "tsc": "tsc", 44 | "unit": "vitest", 45 | "watch": "yarn build-js-esm --watch & yarn build-js-cjs --watch & node --eval \"fs.watch('src', () => child_process.exec('yarn copy-styles'))\"" 46 | }, 47 | "keywords": [ 48 | "calendar", 49 | "date", 50 | "date-picker", 51 | "datetime", 52 | "datetime-picker", 53 | "react", 54 | "time", 55 | "time-picker" 56 | ], 57 | "author": { 58 | "name": "Wojciech Maj", 59 | "email": "kontakt@wojtekmaj.pl" 60 | }, 61 | "license": "MIT", 62 | "dependencies": { 63 | "@wojtekmaj/date-utils": "^1.1.3", 64 | "clsx": "^2.0.0", 65 | "get-user-locale": "^2.2.1", 66 | "make-event-props": "^1.6.0", 67 | "react-calendar": "^5.0.0", 68 | "react-clock": "^5.0.0", 69 | "react-date-picker": "^11.0.0", 70 | "react-fit": "^2.0.0", 71 | "react-time-picker": "^7.0.0" 72 | }, 73 | "devDependencies": { 74 | "@biomejs/biome": "1.9.0", 75 | "@testing-library/dom": "^10.0.0", 76 | "@testing-library/jest-dom": "^6.0.0", 77 | "@testing-library/react": "^16.0.0", 78 | "@testing-library/user-event": "^14.5.0", 79 | "@types/node": "*", 80 | "@types/react": "*", 81 | "@types/react-dom": "*", 82 | "cpy-cli": "^5.0.0", 83 | "happy-dom": "^15.10.2", 84 | "react": "^18.2.0", 85 | "react-dom": "^18.2.0", 86 | "replace-in-files-cli": "^3.0.0", 87 | "typescript": "^5.5.2", 88 | "vitest": "^3.0.5", 89 | "vitest-canvas-mock": "^0.2.2" 90 | }, 91 | "peerDependencies": { 92 | "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 93 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 94 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 95 | }, 96 | "peerDependenciesMeta": { 97 | "@types/react": { 98 | "optional": true 99 | } 100 | }, 101 | "publishConfig": { 102 | "access": "public", 103 | "provenance": true 104 | }, 105 | "files": [ 106 | "dist", 107 | "src" 108 | ], 109 | "repository": { 110 | "type": "git", 111 | "url": "git+https://github.com/wojtekmaj/react-datetime-picker.git", 112 | "directory": "packages/react-datetime-picker" 113 | }, 114 | "funding": "https://github.com/wojtekmaj/react-datetime-picker?sponsor=1" 115 | } 116 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/DateTimeInput.spec.tsx: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import { userEvent } from '@testing-library/user-event'; 4 | 5 | import DateTimeInput from './DateTimeInput.js'; 6 | 7 | import { muteConsole, restoreConsole } from '../../../test-utils.js'; 8 | 9 | vi.useFakeTimers(); 10 | 11 | const hasFullICU = (() => { 12 | try { 13 | const date = new Date(2018, 0, 1, 21); 14 | const formatter = new Intl.DateTimeFormat('de-DE', { hour: 'numeric' }); 15 | return formatter.format(date).includes('21'); 16 | } catch { 17 | return false; 18 | } 19 | })(); 20 | 21 | const itIfFullICU = it.skipIf(!hasFullICU); 22 | 23 | describe('DateTimeInput', () => { 24 | const defaultProps = { 25 | className: 'react-datetime-picker__inputGroup', 26 | }; 27 | 28 | let user: ReturnType; 29 | beforeEach(() => { 30 | user = userEvent.setup({ 31 | advanceTimers: vi.advanceTimersByTime.bind(vi), 32 | }); 33 | }); 34 | 35 | it('renders a native input and custom inputs', () => { 36 | const { container } = render(); 37 | 38 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 39 | const customInputs = container.querySelectorAll('input[data-input]'); 40 | 41 | expect(nativeInput).toBeInTheDocument(); 42 | expect(customInputs).toHaveLength(5); 43 | }); 44 | 45 | it('does not render second input when maxDetail is "minute" or less', () => { 46 | const { container } = render(); 47 | 48 | const customInputs = container.querySelectorAll('input[data-input]'); 49 | const dayInput = container.querySelector('input[name="day"]'); 50 | const monthInput = container.querySelector('input[name="month"]'); 51 | const yearInput = container.querySelector('input[name="year"]'); 52 | const secondInput = container.querySelector('input[name="second"]'); 53 | const minuteInput = container.querySelector('input[name="minute"]'); 54 | const hourInput = container.querySelector('input[name^="hour"]'); 55 | 56 | expect(customInputs).toHaveLength(5); 57 | 58 | expect(yearInput).toBeInTheDocument(); 59 | expect(monthInput).toBeInTheDocument(); 60 | expect(dayInput).toBeInTheDocument(); 61 | expect(hourInput).toBeInTheDocument(); 62 | expect(minuteInput).toBeInTheDocument(); 63 | expect(secondInput).toBeFalsy(); 64 | }); 65 | 66 | it('does not render second and minute inputs when maxDetail is "hour" or less', () => { 67 | const { container } = render(); 68 | 69 | const customInputs = container.querySelectorAll('input[data-input]'); 70 | const dayInput = container.querySelector('input[name="day"]'); 71 | const monthInput = container.querySelector('input[name="month"]'); 72 | const yearInput = container.querySelector('input[name="year"]'); 73 | const secondInput = container.querySelector('input[name="second"]'); 74 | const minuteInput = container.querySelector('input[name="minute"]'); 75 | const hourInput = container.querySelector('input[name^="hour"]'); 76 | 77 | expect(customInputs).toHaveLength(4); 78 | 79 | expect(yearInput).toBeInTheDocument(); 80 | expect(monthInput).toBeInTheDocument(); 81 | expect(dayInput).toBeInTheDocument(); 82 | expect(hourInput).toBeInTheDocument(); 83 | expect(minuteInput).toBeFalsy(); 84 | expect(secondInput).toBeFalsy(); 85 | }); 86 | 87 | it('shows a given date in all inputs correctly given Date (12-hour format)', () => { 88 | const date = new Date(2017, 8, 30, 22, 17, 3); 89 | 90 | const { container } = render( 91 | , 92 | ); 93 | 94 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 95 | const customInputs = container.querySelectorAll('input[data-input]'); 96 | 97 | expect(nativeInput).toHaveValue('2017-09-30T22:17:03'); 98 | expect(customInputs[0]).toHaveValue(9); 99 | expect(customInputs[1]).toHaveValue(30); 100 | expect(customInputs[2]).toHaveValue(2017); 101 | expect(customInputs[3]).toHaveValue(10); 102 | expect(customInputs[4]).toHaveValue(17); 103 | expect(customInputs[5]).toHaveValue(3); 104 | }); 105 | 106 | it('shows a given date in all inputs correctly given ISO string (12-hour format)', () => { 107 | const date = '2017-09-30T22:17:03'; 108 | 109 | const { container } = render( 110 | , 111 | ); 112 | 113 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 114 | const customInputs = container.querySelectorAll('input[data-input]'); 115 | 116 | expect(nativeInput).toHaveValue('2017-09-30T22:17:03'); 117 | expect(customInputs[0]).toHaveValue(9); 118 | expect(customInputs[1]).toHaveValue(30); 119 | expect(customInputs[2]).toHaveValue(2017); 120 | expect(customInputs[3]).toHaveValue(10); 121 | expect(customInputs[4]).toHaveValue(17); 122 | expect(customInputs[5]).toHaveValue(3); 123 | }); 124 | 125 | itIfFullICU('shows a given date in all inputs correctly given Date (24-hour format)', () => { 126 | const date = new Date(2017, 8, 30, 22, 17, 3); 127 | 128 | const { container } = render( 129 | , 130 | ); 131 | 132 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 133 | const customInputs = container.querySelectorAll('input[data-input]'); 134 | 135 | expect(nativeInput).toHaveValue('2017-09-30T22:17:03'); 136 | expect(customInputs[0]).toHaveValue(30); 137 | expect(customInputs[1]).toHaveValue(9); 138 | expect(customInputs[2]).toHaveValue(2017); 139 | expect(customInputs[3]).toHaveValue(22); 140 | expect(customInputs[4]).toHaveValue(17); 141 | expect(customInputs[5]).toHaveValue(3); 142 | }); 143 | 144 | itIfFullICU( 145 | 'shows a given date in all inputs correctly given ISO string (24-hour format)', 146 | () => { 147 | const date = '2017-09-30T22:17:03'; 148 | 149 | const { container } = render( 150 | , 151 | ); 152 | 153 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 154 | const customInputs = container.querySelectorAll('input[data-input]'); 155 | 156 | expect(nativeInput).toHaveValue('2017-09-30T22:17:03'); 157 | expect(customInputs[0]).toHaveValue(30); 158 | expect(customInputs[1]).toHaveValue(9); 159 | expect(customInputs[2]).toHaveValue(2017); 160 | expect(customInputs[3]).toHaveValue(22); 161 | expect(customInputs[4]).toHaveValue(17); 162 | expect(customInputs[5]).toHaveValue(3); 163 | }, 164 | ); 165 | 166 | it('shows empty value in all inputs correctly given null', () => { 167 | const { container } = render( 168 | , 169 | ); 170 | 171 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 172 | const customInputs = container.querySelectorAll('input[data-input]'); 173 | 174 | expect(nativeInput).toHaveAttribute('value', ''); 175 | expect(customInputs[0]).toHaveAttribute('value', ''); 176 | expect(customInputs[1]).toHaveAttribute('value', ''); 177 | expect(customInputs[2]).toHaveAttribute('value', ''); 178 | expect(customInputs[3]).toHaveAttribute('value', ''); 179 | expect(customInputs[4]).toHaveAttribute('value', ''); 180 | expect(customInputs[5]).toHaveAttribute('value', ''); 181 | }); 182 | 183 | it('clears the value correctly', () => { 184 | const date = new Date(2017, 8, 30, 22, 17, 3); 185 | 186 | const { container, rerender } = render( 187 | , 188 | ); 189 | 190 | rerender(); 191 | 192 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 193 | const customInputs = container.querySelectorAll('input[data-input]'); 194 | 195 | expect(nativeInput).toHaveAttribute('value', ''); 196 | expect(customInputs[0]).toHaveAttribute('value', ''); 197 | expect(customInputs[1]).toHaveAttribute('value', ''); 198 | expect(customInputs[2]).toHaveAttribute('value', ''); 199 | expect(customInputs[3]).toHaveAttribute('value', ''); 200 | expect(customInputs[4]).toHaveAttribute('value', ''); 201 | expect(customInputs[5]).toHaveAttribute('value', ''); 202 | }); 203 | 204 | it('renders custom inputs in a proper order (12-hour format)', () => { 205 | const { container } = render(); 206 | 207 | const customInputs = container.querySelectorAll('input[data-input]'); 208 | 209 | expect(customInputs[0]).toHaveAttribute('name', 'month'); 210 | expect(customInputs[1]).toHaveAttribute('name', 'day'); 211 | expect(customInputs[2]).toHaveAttribute('name', 'year'); 212 | expect(customInputs[3]).toHaveAttribute('name', 'hour12'); 213 | expect(customInputs[4]).toHaveAttribute('name', 'minute'); 214 | expect(customInputs[5]).toHaveAttribute('name', 'second'); 215 | }); 216 | 217 | itIfFullICU('renders custom inputs in a proper order (24-hour format)', () => { 218 | const { container } = render( 219 | , 220 | ); 221 | 222 | const customInputs = container.querySelectorAll('input[data-input]'); 223 | 224 | expect(customInputs[0]).toHaveAttribute('name', 'day'); 225 | expect(customInputs[1]).toHaveAttribute('name', 'month'); 226 | expect(customInputs[2]).toHaveAttribute('name', 'year'); 227 | expect(customInputs[3]).toHaveAttribute('name', 'hour24'); 228 | expect(customInputs[4]).toHaveAttribute('name', 'minute'); 229 | expect(customInputs[5]).toHaveAttribute('name', 'second'); 230 | }); 231 | 232 | describe('renders custom inputs in a proper order given format', () => { 233 | it('renders "y" properly', () => { 234 | const { container } = render(); 235 | 236 | const componentInput = container.querySelector('input[name="year"]'); 237 | const customInputs = container.querySelectorAll('input[data-input]'); 238 | 239 | expect(componentInput).toBeInTheDocument(); 240 | expect(customInputs).toHaveLength(1); 241 | }); 242 | 243 | it('renders "yyyy" properly', () => { 244 | const { container } = render(); 245 | 246 | const componentInput = container.querySelector('input[name="year"]'); 247 | const customInputs = container.querySelectorAll('input[data-input]'); 248 | 249 | expect(componentInput).toBeInTheDocument(); 250 | expect(customInputs).toHaveLength(1); 251 | }); 252 | 253 | it('renders "M" properly', () => { 254 | const { container } = render(); 255 | 256 | const componentInput = container.querySelector('input[name="month"]'); 257 | const customInputs = container.querySelectorAll('input[data-input]'); 258 | 259 | expect(componentInput).toBeInTheDocument(); 260 | expect(customInputs).toHaveLength(1); 261 | }); 262 | 263 | it('renders "MM" properly', () => { 264 | const { container } = render(); 265 | 266 | const componentInput = container.querySelector('input[name="month"]'); 267 | const customInputs = container.querySelectorAll('input[data-input]'); 268 | 269 | expect(componentInput).toBeInTheDocument(); 270 | expect(customInputs).toHaveLength(1); 271 | }); 272 | 273 | it('renders "MMM" properly', () => { 274 | const { container } = render(); 275 | 276 | const componentSelect = container.querySelector('select[name="month"]'); 277 | const customInputs = container.querySelectorAll('select'); 278 | 279 | expect(componentSelect).toBeInTheDocument(); 280 | expect(customInputs).toHaveLength(1); 281 | }); 282 | 283 | it('renders "MMMM" properly', () => { 284 | const { container } = render(); 285 | 286 | const componentSelect = container.querySelector('select[name="month"]'); 287 | const customInputs = container.querySelectorAll('select'); 288 | 289 | expect(componentSelect).toBeInTheDocument(); 290 | expect(customInputs).toHaveLength(1); 291 | }); 292 | 293 | it('renders "d" properly', () => { 294 | const { container } = render(); 295 | 296 | const componentInput = container.querySelector('input[name="day"]'); 297 | const customInputs = container.querySelectorAll('input[data-input]'); 298 | 299 | expect(componentInput).toBeInTheDocument(); 300 | expect(customInputs).toHaveLength(1); 301 | }); 302 | 303 | it('renders "dd" properly', () => { 304 | const { container } = render(); 305 | 306 | const componentInput = container.querySelector('input[name="day"]'); 307 | const customInputs = container.querySelectorAll('input[data-input]'); 308 | 309 | expect(componentInput).toBeInTheDocument(); 310 | expect(customInputs).toHaveLength(1); 311 | }); 312 | 313 | it('throws error for "ddd"', () => { 314 | muteConsole(); 315 | 316 | const renderComponent = () => render(); 317 | 318 | expect(renderComponent).toThrow('Unsupported token: ddd'); 319 | 320 | restoreConsole(); 321 | }); 322 | 323 | it('renders "yyyy-MM-dd" properly', () => { 324 | const { container } = render(); 325 | 326 | const monthInput = container.querySelector('input[name="month"]'); 327 | const dayInput = container.querySelector('input[name="day"]'); 328 | const customInputs = container.querySelectorAll('input[data-input]'); 329 | 330 | expect(monthInput).toBeInTheDocument(); 331 | expect(dayInput).toBeInTheDocument(); 332 | expect(customInputs).toHaveLength(3); 333 | expect(customInputs[0]).toHaveAttribute('name', 'year'); 334 | expect(customInputs[1]).toHaveAttribute('name', 'month'); 335 | expect(customInputs[2]).toHaveAttribute('name', 'day'); 336 | }); 337 | 338 | it('renders "h" properly', () => { 339 | const { container } = render(); 340 | 341 | const componentInput = container.querySelector('input[name="hour12"]'); 342 | const customInputs = container.querySelectorAll('input[data-input]'); 343 | 344 | expect(componentInput).toBeInTheDocument(); 345 | expect(customInputs).toHaveLength(1); 346 | }); 347 | 348 | it('renders "hh" properly', () => { 349 | const { container } = render(); 350 | 351 | const componentInput = container.querySelector('input[name="hour12"]'); 352 | const customInputs = container.querySelectorAll('input[data-input]'); 353 | 354 | expect(componentInput).toBeInTheDocument(); 355 | expect(customInputs).toHaveLength(1); 356 | }); 357 | 358 | it('throws error for "hhh"', () => { 359 | muteConsole(); 360 | 361 | const renderComponent = () => render(); 362 | 363 | expect(renderComponent).toThrow('Unsupported token: hhh'); 364 | 365 | restoreConsole(); 366 | }); 367 | 368 | it('renders "H" properly', () => { 369 | const { container } = render(); 370 | 371 | const componentInput = container.querySelector('input[name="hour24"]'); 372 | const customInputs = container.querySelectorAll('input[data-input]'); 373 | 374 | expect(componentInput).toBeInTheDocument(); 375 | expect(customInputs).toHaveLength(1); 376 | }); 377 | 378 | it('renders "HH" properly', () => { 379 | const { container } = render(); 380 | 381 | const componentInput = container.querySelector('input[name="hour24"]'); 382 | const customInputs = container.querySelectorAll('input[data-input]'); 383 | 384 | expect(componentInput).toBeInTheDocument(); 385 | expect(customInputs).toHaveLength(1); 386 | }); 387 | 388 | it('throws error for "HHH"', () => { 389 | muteConsole(); 390 | 391 | const renderComponent = () => render(); 392 | 393 | expect(renderComponent).toThrow('Unsupported token: HHH'); 394 | 395 | restoreConsole(); 396 | }); 397 | 398 | it('renders "m" properly', () => { 399 | const { container } = render(); 400 | 401 | const componentInput = container.querySelector('input[name="minute"]'); 402 | const customInputs = container.querySelectorAll('input[data-input]'); 403 | 404 | expect(componentInput).toBeInTheDocument(); 405 | expect(customInputs).toHaveLength(1); 406 | }); 407 | 408 | it('renders "mm" properly', () => { 409 | const { container } = render(); 410 | 411 | const componentInput = container.querySelector('input[name="minute"]'); 412 | const customInputs = container.querySelectorAll('input[data-input]'); 413 | 414 | expect(componentInput).toBeInTheDocument(); 415 | expect(customInputs).toHaveLength(1); 416 | }); 417 | 418 | it('throws error for "mmm"', () => { 419 | muteConsole(); 420 | 421 | const renderComponent = () => render(); 422 | 423 | expect(renderComponent).toThrow('Unsupported token: mmm'); 424 | 425 | restoreConsole(); 426 | }); 427 | 428 | it('renders "s" properly', () => { 429 | const { container } = render(); 430 | 431 | const componentInput = container.querySelector('input[name="second"]'); 432 | const customInputs = container.querySelectorAll('input[data-input]'); 433 | 434 | expect(componentInput).toBeInTheDocument(); 435 | expect(customInputs).toHaveLength(1); 436 | }); 437 | 438 | it('renders "ss" properly', () => { 439 | const { container } = render(); 440 | 441 | const componentInput = container.querySelector('input[name="second"]'); 442 | const customInputs = container.querySelectorAll('input[data-input]'); 443 | 444 | expect(componentInput).toBeInTheDocument(); 445 | expect(customInputs).toHaveLength(1); 446 | }); 447 | 448 | it('throws error for "sss"', () => { 449 | muteConsole(); 450 | 451 | const renderComponent = () => render(); 452 | 453 | expect(renderComponent).toThrow('Unsupported token: sss'); 454 | 455 | restoreConsole(); 456 | }); 457 | 458 | it('renders "a" properly', () => { 459 | const { container } = render(); 460 | 461 | const componentSelect = container.querySelector('select[name="amPm"]'); 462 | const customInputs = container.querySelectorAll('input[data-input]'); 463 | 464 | expect(componentSelect).toBeInTheDocument(); 465 | expect(customInputs).toHaveLength(0); 466 | }); 467 | }); 468 | 469 | it('renders proper input separators', () => { 470 | const { container } = render(); 471 | 472 | const separators = container.querySelectorAll('.react-datetime-picker__inputGroup__divider'); 473 | 474 | expect(separators).toHaveLength(5); 475 | expect(separators[0]).toHaveTextContent('/'); 476 | expect(separators[1]).toHaveTextContent('/'); 477 | expect(separators[2]).toHaveTextContent(''); // Non-breaking space 478 | expect(separators[3]).toHaveTextContent(':'); 479 | expect(separators[4]).toHaveTextContent(''); // Non-breaking space 480 | }); 481 | 482 | it('renders proper amount of separators', () => { 483 | const { container } = render(); 484 | 485 | const separators = container.querySelectorAll('.react-datetime-picker__inputGroup__divider'); 486 | const customInputs = container.querySelectorAll('input[data-input]'); 487 | const ampm = container.querySelectorAll('select'); 488 | 489 | expect(separators).toHaveLength(customInputs.length + ampm.length - 1); 490 | }); 491 | 492 | it('jumps to the next field when right arrow is pressed', async () => { 493 | const { container } = render(); 494 | 495 | const customInputs = container.querySelectorAll('input[data-input]'); 496 | const monthInput = customInputs[0] as HTMLInputElement; 497 | const dayInput = customInputs[1]; 498 | 499 | await user.type(monthInput, '{arrowright}'); 500 | 501 | expect(dayInput).toHaveFocus(); 502 | }); 503 | 504 | it('jumps to the next field when date separator key is pressed', async () => { 505 | const { container } = render(); 506 | 507 | const customInputs = container.querySelectorAll('input[data-input]'); 508 | const monthInput = customInputs[0] as HTMLInputElement; 509 | const dayInput = customInputs[1]; 510 | 511 | const separators = container.querySelectorAll('.react-datetime-picker__inputGroup__divider'); 512 | const separatorsTexts = Array.from(separators) 513 | .map((el) => el.textContent as string) 514 | .filter((el) => el.trim()); 515 | const separatorKey = separatorsTexts[0] as string; 516 | 517 | await user.type(monthInput, separatorKey); 518 | 519 | expect(dayInput).toHaveFocus(); 520 | }); 521 | 522 | it('jumps to the next field when time separator key is pressed', async () => { 523 | const { container } = render(); 524 | 525 | const customInputs = container.querySelectorAll('input[data-input]'); 526 | const monthInput = customInputs[0] as HTMLInputElement; 527 | const dayInput = customInputs[1]; 528 | 529 | const separators = container.querySelectorAll('.react-datetime-picker__inputGroup__divider'); 530 | const separatorsTexts = Array.from(separators) 531 | .map((el) => el.textContent as string) 532 | .filter((el) => el.trim()); 533 | const separatorKey = separatorsTexts[separatorsTexts.length - 1] as string; 534 | 535 | await user.type(monthInput, separatorKey); 536 | 537 | expect(dayInput).toHaveFocus(); 538 | }); 539 | 540 | // See https://github.com/capricorn86/happy-dom/issues/1592 541 | it.skip('does not jump to the next field when right arrow is pressed when the last input is focused', async () => { 542 | const { container } = render(); 543 | 544 | const select = container.querySelector('select') as HTMLSelectElement; 545 | 546 | await user.type(select, '{arrowright}'); 547 | 548 | expect(select).toHaveFocus(); 549 | }); 550 | 551 | it('jumps to the previous field when left arrow is pressed', async () => { 552 | const { container } = render(); 553 | 554 | const customInputs = container.querySelectorAll('input[data-input]'); 555 | const monthInput = customInputs[0]; 556 | const dayInput = customInputs[1] as HTMLInputElement; 557 | 558 | await user.type(dayInput, '{arrowleft}'); 559 | 560 | expect(monthInput).toHaveFocus(); 561 | }); 562 | 563 | it('does not jump to the previous field when left arrow is pressed when the first input is focused', async () => { 564 | const { container } = render(); 565 | 566 | const customInputs = container.querySelectorAll('input[data-input]'); 567 | const monthInput = customInputs[0] as HTMLInputElement; 568 | 569 | await user.type(monthInput, '{arrowleft}'); 570 | 571 | expect(monthInput).toHaveFocus(); 572 | }); 573 | 574 | it("jumps to the next field when a value which can't be extended to another valid value is entered", async () => { 575 | const { container } = render(); 576 | 577 | const customInputs = container.querySelectorAll('input[data-input]'); 578 | const monthInput = customInputs[0] as HTMLInputElement; 579 | const dayInput = customInputs[1]; 580 | 581 | await user.type(monthInput, '4'); 582 | 583 | expect(dayInput).toHaveFocus(); 584 | }); 585 | 586 | it('jumps to the next field when a value as long as the length of maximum value is entered', async () => { 587 | const { container } = render(); 588 | 589 | const customInputs = container.querySelectorAll('input[data-input]'); 590 | const monthInput = customInputs[0] as HTMLInputElement; 591 | const dayInput = customInputs[1]; 592 | 593 | await user.type(monthInput, '03'); 594 | 595 | expect(dayInput).toHaveFocus(); 596 | }); 597 | 598 | it("jumps to the next field when a value which can't be extended to another valid value is entered by typing with multiple keys", async () => { 599 | function getActiveElement() { 600 | return document.activeElement as HTMLInputElement; 601 | } 602 | 603 | function keyDown(key: string, initial = false) { 604 | const element = getActiveElement(); 605 | fireEvent.keyDown(element, { key }); 606 | fireEvent.keyPress(element, { key }); 607 | element.value = (initial ? '' : element.value) + key; 608 | } 609 | 610 | function keyUp(key: string) { 611 | fireEvent.keyUp(getActiveElement(), { key }); 612 | } 613 | 614 | const date = new Date(2023, 3, 1); 615 | 616 | const { container } = render(); 617 | 618 | const customInputs = container.querySelectorAll('input[data-input]'); 619 | const dayInput = customInputs[0] as HTMLInputElement; 620 | const monthInput = customInputs[1]; 621 | 622 | dayInput.focus(); 623 | expect(dayInput).toHaveFocus(); 624 | 625 | keyDown('1', true); 626 | keyDown('2'); 627 | 628 | keyUp('1'); 629 | expect(dayInput).toHaveFocus(); 630 | 631 | keyUp('2'); 632 | expect(monthInput).toHaveFocus(); 633 | }); 634 | 635 | it('does not jump the next field when a value which can be extended to another valid value is entered', async () => { 636 | const { container } = render(); 637 | 638 | const customInputs = container.querySelectorAll('input[data-input]'); 639 | const monthInput = customInputs[0] as HTMLInputElement; 640 | 641 | await user.type(monthInput, '1'); 642 | 643 | expect(monthInput).toHaveFocus(); 644 | }); 645 | 646 | it('triggers onChange correctly when changed custom input', () => { 647 | const onChange = vi.fn(); 648 | const date = new Date(2017, 8, 30, 22, 17, 0); 649 | 650 | const { container } = render( 651 | , 652 | ); 653 | 654 | const customInputs = container.querySelectorAll('input[data-input]'); 655 | const hourInput = customInputs[3] as HTMLInputElement; 656 | 657 | fireEvent.change(hourInput, { target: { value: '8' } }); 658 | 659 | expect(onChange).toHaveBeenCalled(); 660 | expect(onChange).toHaveBeenCalledWith(new Date(2017, 8, 30, 20, 17, 0), false); 661 | }); 662 | 663 | it('triggers onChange correctly when changed custom input with year < 100', () => { 664 | const onChange = vi.fn(); 665 | const date = new Date(); 666 | date.setFullYear(19, 8, 30); 667 | date.setHours(22, 17, 0, 0); 668 | 669 | const { container } = render( 670 | , 671 | ); 672 | 673 | const customInputs = container.querySelectorAll('input[data-input]'); 674 | const hourInput = customInputs[3] as HTMLInputElement; 675 | 676 | fireEvent.change(hourInput, { target: { value: '8' } }); 677 | 678 | const nextDate = new Date(); 679 | nextDate.setFullYear(19, 8, 30); 680 | nextDate.setHours(20, 17, 0, 0); 681 | 682 | expect(onChange).toHaveBeenCalled(); 683 | expect(onChange).toHaveBeenCalledWith(nextDate, false); 684 | }); 685 | 686 | it('triggers onChange correctly when changed custom input with no year', () => { 687 | const onChange = vi.fn(); 688 | const date = new Date(2017, 8, 30, 22, 17, 0); 689 | 690 | const { container } = render( 691 | , 692 | ); 693 | 694 | const customInputs = container.querySelectorAll('input[data-input]'); 695 | const hourInput = customInputs[2] as HTMLInputElement; 696 | 697 | fireEvent.change(hourInput, { target: { value: '20' } }); 698 | 699 | const currentYear = new Date().getFullYear(); 700 | 701 | expect(onChange).toHaveBeenCalled(); 702 | expect(onChange).toHaveBeenCalledWith(new Date(currentYear, 8, 30, 20, 17, 0), false); 703 | }); 704 | 705 | it('triggers onChange correctly when cleared custom inputs', () => { 706 | const onChange = vi.fn(); 707 | const date = new Date(2017, 8, 30, 22, 17, 3); 708 | 709 | const { container } = render( 710 | , 711 | ); 712 | 713 | const customInputs = Array.from(container.querySelectorAll('input[data-input]')); 714 | 715 | for (const customInput of customInputs) { 716 | fireEvent.change(customInput, { target: { value: '' } }); 717 | } 718 | 719 | expect(onChange).toHaveBeenCalledTimes(1); 720 | expect(onChange).toHaveBeenCalledWith(null, false); 721 | }); 722 | 723 | it('triggers onChange correctly when changed native input', () => { 724 | const onChange = vi.fn(); 725 | const date = new Date(2017, 8, 30, 22, 17, 3); 726 | 727 | const { container } = render( 728 | , 729 | ); 730 | 731 | const nativeInput = container.querySelector('input[type="datetime-local"]') as HTMLInputElement; 732 | 733 | fireEvent.change(nativeInput, { target: { value: '2017-09-30T20:17:03' } }); 734 | 735 | expect(onChange).toHaveBeenCalled(); 736 | expect(onChange).toHaveBeenCalledWith(new Date(2017, 8, 30, 20, 17, 3), false); 737 | }); 738 | 739 | it('triggers onChange correctly when changed native input with year < 100', () => { 740 | const onChange = vi.fn(); 741 | const date = new Date(); 742 | date.setFullYear(19, 8, 20); 743 | date.setHours(22, 17, 3, 0); 744 | 745 | const { container } = render( 746 | , 747 | ); 748 | 749 | const nativeInput = container.querySelector('input[type="datetime-local"]') as HTMLInputElement; 750 | 751 | fireEvent.change(nativeInput, { target: { value: '0019-09-20T20:17:03' } }); 752 | 753 | const nextDate = new Date(); 754 | nextDate.setFullYear(19, 8, 20); 755 | nextDate.setHours(20, 17, 3, 0); 756 | 757 | expect(onChange).toHaveBeenCalled(); 758 | expect(onChange).toHaveBeenCalledWith(nextDate, false); 759 | }); 760 | 761 | it('triggers onChange correctly when cleared native input', () => { 762 | const onChange = vi.fn(); 763 | const date = new Date(2017, 8, 30, 22, 17, 3); 764 | 765 | const { container } = render( 766 | , 767 | ); 768 | 769 | const nativeInput = container.querySelector('input[type="datetime-local"]') as HTMLInputElement; 770 | 771 | fireEvent.change(nativeInput, { target: { value: '' } }); 772 | 773 | expect(onChange).toHaveBeenCalled(); 774 | expect(onChange).toHaveBeenCalledWith(null, false); 775 | }); 776 | }); 777 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/DateTimeInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useEffect, useRef, useState } from 'react'; 4 | import { 5 | getYear, 6 | getMonthHuman, 7 | getDate, 8 | getHours, 9 | getMinutes, 10 | getSeconds, 11 | getHoursMinutesSeconds, 12 | } from '@wojtekmaj/date-utils'; 13 | 14 | import Divider from './Divider.js'; 15 | import DayInput from 'react-date-picker/dist/esm/DateInput/DayInput'; 16 | import MonthInput from 'react-date-picker/dist/esm/DateInput/MonthInput'; 17 | import MonthSelect from 'react-date-picker/dist/esm/DateInput/MonthSelect'; 18 | import YearInput from 'react-date-picker/dist/esm/DateInput/YearInput'; 19 | import Hour12Input from 'react-time-picker/dist/esm/TimeInput/Hour12Input'; 20 | import Hour24Input from 'react-time-picker/dist/esm/TimeInput/Hour24Input'; 21 | import MinuteInput from 'react-time-picker/dist/esm/TimeInput/MinuteInput'; 22 | import SecondInput from 'react-time-picker/dist/esm/TimeInput/SecondInput'; 23 | import AmPm from 'react-time-picker/dist/esm/TimeInput/AmPm'; 24 | import NativeInput from './DateTimeInput/NativeInput.js'; 25 | 26 | import { getFormatter, getNumberFormatter, formatDate } from './shared/dateFormatter.js'; 27 | import { convert12to24, convert24to12 } from './shared/dates.js'; 28 | import { between, getAmPmLabels } from './shared/utils.js'; 29 | 30 | import type { AmPmType, Detail, LooseValuePiece } from './shared/types.js'; 31 | 32 | const getFormatterOptionsCache: Record = {}; 33 | 34 | const defaultMinDate = new Date(); 35 | defaultMinDate.setFullYear(1, 0, 1); 36 | defaultMinDate.setHours(0, 0, 0, 0); 37 | const defaultMaxDate = new Date(8.64e15); 38 | const allViews = ['hour', 'minute', 'second'] as const; 39 | 40 | function toDate(value: Date | string): Date { 41 | if (value instanceof Date) { 42 | return value; 43 | } 44 | 45 | return new Date(value); 46 | } 47 | 48 | function isSameDate(date: Date, year: string | null, month: string | null, day: string | null) { 49 | return ( 50 | year === getYear(date).toString() && 51 | month === getMonthHuman(date).toString() && 52 | day === getDate(date).toString() 53 | ); 54 | } 55 | 56 | function getValue( 57 | value: string | Date | null | undefined | (string | Date | null | undefined)[], 58 | index: 0 | 1, 59 | ): Date | null { 60 | const rawValue = Array.isArray(value) ? value[index] : value; 61 | 62 | if (!rawValue) { 63 | return null; 64 | } 65 | 66 | const valueDate = toDate(rawValue); 67 | 68 | if (Number.isNaN(valueDate.getTime())) { 69 | throw new Error(`Invalid date: ${value}`); 70 | } 71 | 72 | return valueDate; 73 | } 74 | 75 | type DetailArgs = { 76 | value?: LooseValuePiece; 77 | minDate?: Date; 78 | maxDate?: Date; 79 | }; 80 | 81 | function getDetailValue({ value, minDate, maxDate }: DetailArgs, index: 0 | 1) { 82 | const valuePiece = getValue(value, index); 83 | 84 | if (!valuePiece) { 85 | return null; 86 | } 87 | 88 | return between(valuePiece, minDate, maxDate); 89 | } 90 | 91 | const getDetailValueFrom = (args: DetailArgs) => getDetailValue(args, 0); 92 | 93 | function isInternalInput(element: HTMLElement) { 94 | return element.dataset.input === 'true'; 95 | } 96 | 97 | function findInput( 98 | element: HTMLElement, 99 | property: 'previousElementSibling' | 'nextElementSibling', 100 | ) { 101 | let nextElement: HTMLElement | null = element; 102 | do { 103 | nextElement = nextElement[property] as HTMLElement | null; 104 | } while (nextElement && !isInternalInput(nextElement)); 105 | return nextElement; 106 | } 107 | 108 | function focus(element?: HTMLElement | null) { 109 | if (element) { 110 | element.focus(); 111 | } 112 | } 113 | 114 | type RenderFunction = (match: string, index: number) => React.ReactNode; 115 | 116 | function renderCustomInputs( 117 | placeholder: string, 118 | elementFunctions: Record, 119 | allowMultipleInstances: boolean, 120 | ) { 121 | const usedFunctions: RenderFunction[] = []; 122 | const pattern = new RegExp( 123 | Object.keys(elementFunctions) 124 | .map((el) => `${el}+`) 125 | .join('|'), 126 | 'g', 127 | ); 128 | const matches = placeholder.match(pattern); 129 | 130 | return placeholder.split(pattern).reduce((arr, element, index) => { 131 | const divider = element && ( 132 | // biome-ignore lint/suspicious/noArrayIndexKey: index is stable here 133 | {element} 134 | ); 135 | arr.push(divider); 136 | const currentMatch = matches?.[index]; 137 | 138 | if (currentMatch) { 139 | const renderFunction = 140 | elementFunctions[currentMatch] || 141 | elementFunctions[ 142 | Object.keys(elementFunctions).find((elementFunction) => 143 | currentMatch.match(elementFunction), 144 | ) as string 145 | ]; 146 | 147 | if (!renderFunction) { 148 | return arr; 149 | } 150 | 151 | if (!allowMultipleInstances && usedFunctions.includes(renderFunction)) { 152 | arr.push(currentMatch); 153 | } else { 154 | arr.push(renderFunction(currentMatch, index)); 155 | usedFunctions.push(renderFunction); 156 | } 157 | } 158 | 159 | return arr; 160 | }, []); 161 | } 162 | 163 | const formatNumber = getNumberFormatter({ useGrouping: false }); 164 | 165 | type DateTimeInputProps = { 166 | amPmAriaLabel?: string; 167 | autoFocus?: boolean; 168 | className: string; 169 | dayAriaLabel?: string; 170 | dayPlaceholder?: string; 171 | disabled?: boolean; 172 | format?: string; 173 | hourAriaLabel?: string; 174 | hourPlaceholder?: string; 175 | isWidgetOpen?: boolean | null; 176 | locale?: string; 177 | maxDate?: Date; 178 | maxDetail?: Detail; 179 | minDate?: Date; 180 | minuteAriaLabel?: string; 181 | minutePlaceholder?: string; 182 | monthAriaLabel?: string; 183 | monthPlaceholder?: string; 184 | name?: string; 185 | nativeInputAriaLabel?: string; 186 | onChange?: (value: Date | null, shouldCloseWidgets: boolean) => void; 187 | onInvalidChange?: () => void; 188 | required?: boolean; 189 | secondAriaLabel?: string; 190 | secondPlaceholder?: string; 191 | showLeadingZeros?: boolean; 192 | value?: string | Date | null; 193 | yearAriaLabel?: string; 194 | yearPlaceholder?: string; 195 | }; 196 | 197 | export default function DateTimeInput({ 198 | amPmAriaLabel, 199 | autoFocus, 200 | className, 201 | dayAriaLabel, 202 | dayPlaceholder, 203 | disabled, 204 | format, 205 | hourAriaLabel, 206 | hourPlaceholder, 207 | isWidgetOpen: isWidgetOpenProps, 208 | locale, 209 | maxDate, 210 | maxDetail = 'minute', 211 | minDate, 212 | minuteAriaLabel, 213 | minutePlaceholder, 214 | monthAriaLabel, 215 | monthPlaceholder, 216 | name = 'datetime', 217 | nativeInputAriaLabel, 218 | onChange: onChangeProps, 219 | onInvalidChange, 220 | required, 221 | secondAriaLabel, 222 | secondPlaceholder, 223 | showLeadingZeros, 224 | value: valueProps, 225 | yearAriaLabel, 226 | yearPlaceholder, 227 | }: DateTimeInputProps): React.ReactElement { 228 | const [amPm, setAmPm] = useState(null); 229 | const [year, setYear] = useState(null); 230 | const [month, setMonth] = useState(null); 231 | const [day, setDay] = useState(null); 232 | const [hour, setHour] = useState(null); 233 | const [minute, setMinute] = useState(null); 234 | const [second, setSecond] = useState(null); 235 | const [value, setValue] = useState(null); 236 | const amPmInput = useRef(null); 237 | const yearInput = useRef(null); 238 | const monthInput = useRef(null); 239 | const monthSelect = useRef(null); 240 | const dayInput = useRef(null); 241 | const hour12Input = useRef(null); 242 | const hour24Input = useRef(null); 243 | const minuteInput = useRef(null); 244 | const secondInput = useRef(null); 245 | const [isWidgetOpen, setIsWidgetOpenOpen] = useState(isWidgetOpenProps); 246 | const lastPressedKey = useRef(undefined); 247 | 248 | useEffect(() => { 249 | setIsWidgetOpenOpen(isWidgetOpenProps); 250 | }, [isWidgetOpenProps]); 251 | 252 | // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect intentionally triggered on props change 253 | useEffect(() => { 254 | const nextValue = getDetailValueFrom({ 255 | value: valueProps, 256 | minDate, 257 | maxDate, 258 | }); 259 | 260 | if (nextValue) { 261 | setAmPm(convert24to12(getHours(nextValue))[1]); 262 | setYear(getYear(nextValue).toString()); 263 | setMonth(getMonthHuman(nextValue).toString()); 264 | setDay(getDate(nextValue).toString()); 265 | setHour(getHours(nextValue).toString()); 266 | setMinute(getMinutes(nextValue).toString()); 267 | setSecond(getSeconds(nextValue).toString()); 268 | setValue(toDate(nextValue)); 269 | } else { 270 | setAmPm(null); 271 | setYear(null); 272 | setMonth(null); 273 | setDay(null); 274 | setHour(null); 275 | setMinute(null); 276 | setSecond(null); 277 | setValue(null); 278 | } 279 | }, [ 280 | valueProps, 281 | minDate, 282 | maxDate, 283 | // Toggling widget visibility resets values 284 | isWidgetOpen, 285 | ]); 286 | 287 | const valueType = maxDetail; 288 | 289 | const formatTime = (() => { 290 | const level = allViews.indexOf(maxDetail); 291 | const formatterOptions = 292 | getFormatterOptionsCache[level] || 293 | (() => { 294 | const options: Intl.DateTimeFormatOptions = { hour: 'numeric' }; 295 | if (level >= 1) { 296 | options.minute = 'numeric'; 297 | } 298 | if (level >= 2) { 299 | options.second = 'numeric'; 300 | } 301 | 302 | getFormatterOptionsCache[level] = options; 303 | 304 | return options; 305 | })(); 306 | 307 | return getFormatter(formatterOptions); 308 | })(); 309 | 310 | const datePlaceholder = (() => { 311 | const year = 2017; 312 | const monthIndex = 11; 313 | const day = 11; 314 | 315 | const date = new Date(year, monthIndex, day); 316 | const formattedDate = formatDate(locale, date); 317 | 318 | const datePieces = ['year', 'month', 'day'] as const; 319 | const datePieceReplacements = ['y', 'M', 'd']; 320 | 321 | function formatDatePiece(name: keyof Intl.DateTimeFormatOptions, dateToFormat: Date) { 322 | const formatterOptions = 323 | getFormatterOptionsCache[name] || 324 | (() => { 325 | const options = { [name]: 'numeric' }; 326 | 327 | getFormatterOptionsCache[name] = options; 328 | 329 | return options; 330 | })(); 331 | 332 | return getFormatter(formatterOptions)(locale, dateToFormat).match(/\d{1,}/); 333 | } 334 | 335 | let placeholder = formattedDate; 336 | datePieces.forEach((datePiece, index) => { 337 | const match = formatDatePiece(datePiece, date); 338 | 339 | if (match) { 340 | const formattedDatePiece = match[0]; 341 | const datePieceReplacement = datePieceReplacements[index] as string; 342 | placeholder = placeholder.replace(formattedDatePiece, datePieceReplacement); 343 | } 344 | }); 345 | // See https://github.com/wojtekmaj/react-date-picker/issues/396 346 | placeholder = placeholder.replace('17', 'y'); 347 | 348 | return placeholder; 349 | })(); 350 | 351 | const timePlaceholder = (() => { 352 | const hour24 = 21; 353 | const hour12 = 9; 354 | const minute = 13; 355 | const second = 14; 356 | const date = new Date(2017, 0, 1, hour24, minute, second); 357 | 358 | return formatTime(locale, date) 359 | .replace(formatNumber(locale, hour12), 'h') 360 | .replace(formatNumber(locale, hour24), 'H') 361 | .replace(formatNumber(locale, minute), 'mm') 362 | .replace(formatNumber(locale, second), 'ss') 363 | .replace(new RegExp(getAmPmLabels(locale).join('|')), 'a'); 364 | })(); 365 | 366 | const placeholder = format || `${datePlaceholder}\u00a0${timePlaceholder}`; 367 | 368 | const dateDivider = (() => { 369 | const dividers = datePlaceholder.match(/[^0-9a-z]/i); 370 | return dividers ? dividers[0] : null; 371 | })(); 372 | 373 | const timeDivider = (() => { 374 | const dividers = timePlaceholder.match(/[^0-9a-z]/i); 375 | return dividers ? dividers[0] : null; 376 | })(); 377 | 378 | const maxTime = (() => { 379 | if (!maxDate) { 380 | return undefined; 381 | } 382 | 383 | if (!isSameDate(maxDate, year, month, day)) { 384 | return undefined; 385 | } 386 | 387 | return getHoursMinutesSeconds(maxDate || defaultMaxDate); 388 | })(); 389 | 390 | const minTime = (() => { 391 | if (!minDate) { 392 | return undefined; 393 | } 394 | 395 | if (!isSameDate(minDate, year, month, day)) { 396 | return undefined; 397 | } 398 | 399 | return getHoursMinutesSeconds(minDate || defaultMinDate); 400 | })(); 401 | 402 | function onClick(event: React.MouseEvent & { target: HTMLDivElement }) { 403 | if (event.target === event.currentTarget) { 404 | // Wrapper was directly clicked 405 | const firstInput = event.target.children[1] as HTMLInputElement; 406 | focus(firstInput); 407 | } 408 | } 409 | 410 | function onKeyDown( 411 | event: 412 | | (React.KeyboardEvent & { target: HTMLInputElement }) 413 | | (React.KeyboardEvent & { target: HTMLSelectElement }), 414 | ) { 415 | lastPressedKey.current = event.key; 416 | 417 | switch (event.key) { 418 | case 'ArrowLeft': 419 | case 'ArrowRight': 420 | case dateDivider: 421 | case timeDivider: { 422 | event.preventDefault(); 423 | 424 | const { target: input } = event; 425 | const property = 426 | event.key === 'ArrowLeft' ? 'previousElementSibling' : 'nextElementSibling'; 427 | const nextInput = findInput(input, property); 428 | focus(nextInput); 429 | break; 430 | } 431 | default: 432 | } 433 | } 434 | 435 | function onKeyUp(event: React.KeyboardEvent & { target: HTMLInputElement }) { 436 | const { key, target: input } = event; 437 | 438 | const isLastPressedKey = lastPressedKey.current === key; 439 | 440 | if (!isLastPressedKey) { 441 | return; 442 | } 443 | 444 | const isNumberKey = !Number.isNaN(Number(key)); 445 | 446 | if (!isNumberKey) { 447 | return; 448 | } 449 | 450 | const max = input.getAttribute('max'); 451 | 452 | if (!max) { 453 | return; 454 | } 455 | 456 | const { value } = input; 457 | 458 | /** 459 | * Given 1, the smallest possible number the user could type by adding another digit is 10. 460 | * 10 would be a valid value given max = 12, so we won't jump to the next input. 461 | * However, given 2, smallers possible number would be 20, and thus keeping the focus in 462 | * this field doesn't make sense. 463 | */ 464 | if (Number(value) * 10 > Number(max) || value.length >= max.length) { 465 | const property = 'nextElementSibling'; 466 | const nextInput = findInput(input, property); 467 | focus(nextInput); 468 | } 469 | } 470 | 471 | /** 472 | * Called after internal onChange. Checks input validity. If all fields are valid, 473 | * calls props.onChange. 474 | */ 475 | function onChangeExternal() { 476 | if (!onChangeProps) { 477 | return; 478 | } 479 | 480 | type NonFalsy = T extends false | 0 | '' | null | undefined | 0n ? never : T; 481 | 482 | function filterBoolean(value: T): value is NonFalsy { 483 | return Boolean(value); 484 | } 485 | 486 | const formElements = [ 487 | amPmInput.current, 488 | dayInput.current, 489 | monthInput.current, 490 | monthSelect.current, 491 | yearInput.current, 492 | hour12Input.current, 493 | hour24Input.current, 494 | minuteInput.current, 495 | secondInput.current, 496 | ].filter(filterBoolean); 497 | 498 | const formElementsWithoutSelect = formElements.slice(1); 499 | 500 | const values: Record & { 501 | amPm?: AmPmType; 502 | } = {}; 503 | for (const formElement of formElements) { 504 | values[formElement.name] = 505 | formElement.type === 'number' ? formElement.valueAsNumber : formElement.value; 506 | } 507 | 508 | const isEveryValueEmpty = formElementsWithoutSelect.every((formElement) => !formElement.value); 509 | 510 | if (isEveryValueEmpty) { 511 | onChangeProps(null, false); 512 | return; 513 | } 514 | 515 | const isEveryValueFilled = formElements.every((formElement) => formElement.value); 516 | const isEveryValueValid = formElements.every((formElement) => formElement.validity.valid); 517 | 518 | if (isEveryValueFilled && isEveryValueValid) { 519 | const year = Number(values.year || new Date().getFullYear()); 520 | const monthIndex = Number(values.month || 1) - 1; 521 | const day = Number(values.day || 1); 522 | const hour = Number( 523 | values.hour24 || 524 | (values.hour12 && values.amPm && convert12to24(values.hour12, values.amPm)) || 525 | 0, 526 | ); 527 | const minute = Number(values.minute || 0); 528 | const second = Number(values.second || 0); 529 | 530 | const proposedValue = new Date(); 531 | proposedValue.setFullYear(year, monthIndex, day); 532 | proposedValue.setHours(hour, minute, second, 0); 533 | 534 | onChangeProps(proposedValue, false); 535 | return; 536 | } 537 | 538 | if (!onInvalidChange) { 539 | return; 540 | } 541 | 542 | onInvalidChange(); 543 | } 544 | 545 | /** 546 | * Called when non-native date input is changed. 547 | */ 548 | function onChange(event: React.ChangeEvent) { 549 | const { name, value } = event.target; 550 | 551 | switch (name) { 552 | case 'amPm': 553 | setAmPm(value as AmPmType); 554 | break; 555 | case 'year': 556 | setYear(value); 557 | break; 558 | case 'month': 559 | setMonth(value); 560 | break; 561 | case 'day': 562 | setDay(value); 563 | break; 564 | case 'hour12': 565 | setHour(value ? convert12to24(value, amPm || 'am').toString() : ''); 566 | break; 567 | case 'hour24': 568 | setHour(value); 569 | break; 570 | case 'minute': 571 | setMinute(value); 572 | break; 573 | case 'second': 574 | setSecond(value); 575 | break; 576 | } 577 | 578 | onChangeExternal(); 579 | } 580 | 581 | /** 582 | * Called when native date input is changed. 583 | */ 584 | function onChangeNative(event: React.ChangeEvent) { 585 | const { value } = event.target; 586 | 587 | if (!onChangeProps) { 588 | return; 589 | } 590 | 591 | const processedValue = (() => { 592 | if (!value) { 593 | return null; 594 | } 595 | 596 | const [valueDate, valueTime] = value.split('T') as [string, string]; 597 | 598 | const [yearString, monthString, dayString] = valueDate.split('-') as [string, string, string]; 599 | const year = Number(yearString); 600 | const monthIndex = Number(monthString) - 1 || 0; 601 | const day = Number(dayString) || 1; 602 | 603 | const [hourString, minuteString, secondString] = valueTime.split(':') as [ 604 | string, 605 | string, 606 | string, 607 | ]; 608 | const hour = Number(hourString) || 0; 609 | const minute = Number(minuteString) || 0; 610 | const second = Number(secondString) || 0; 611 | 612 | const proposedValue = new Date(); 613 | proposedValue.setFullYear(year, monthIndex, day); 614 | proposedValue.setHours(hour, minute, second, 0); 615 | 616 | return proposedValue; 617 | })(); 618 | 619 | onChangeProps(processedValue, false); 620 | } 621 | 622 | const commonInputProps = { 623 | className, 624 | disabled, 625 | maxDate: maxDate || defaultMaxDate, 626 | minDate: minDate || defaultMinDate, 627 | onChange, 628 | onKeyDown, 629 | onKeyUp, 630 | // This is only for showing validity when editing 631 | required: Boolean(required || isWidgetOpen), 632 | }; 633 | 634 | const commonTimeInputProps = { 635 | maxTime, 636 | minTime, 637 | }; 638 | 639 | function renderDay(currentMatch: string, index: number) { 640 | if (currentMatch && currentMatch.length > 2) { 641 | throw new Error(`Unsupported token: ${currentMatch}`); 642 | } 643 | 644 | const showLeadingZerosFromFormat = currentMatch && currentMatch.length === 2; 645 | 646 | return ( 647 | 659 | ); 660 | } 661 | 662 | function renderMonth(currentMatch: string, index: number) { 663 | if (currentMatch && currentMatch.length > 4) { 664 | throw new Error(`Unsupported token: ${currentMatch}`); 665 | } 666 | 667 | if (currentMatch.length > 2) { 668 | return ( 669 | 681 | ); 682 | } 683 | 684 | const showLeadingZerosFromFormat = currentMatch && currentMatch.length === 2; 685 | 686 | return ( 687 | 698 | ); 699 | } 700 | 701 | function renderYear(_currentMatch: string, index: number) { 702 | return ( 703 | 713 | ); 714 | } 715 | 716 | function renderHour12(currentMatch: string, index: number) { 717 | if (currentMatch && currentMatch.length > 2) { 718 | throw new Error(`Unsupported token: ${currentMatch}`); 719 | } 720 | 721 | const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false; 722 | 723 | return ( 724 | 736 | ); 737 | } 738 | 739 | function renderHour24(currentMatch: string, index: number) { 740 | if (currentMatch && currentMatch.length > 2) { 741 | throw new Error(`Unsupported token: ${currentMatch}`); 742 | } 743 | 744 | const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false; 745 | 746 | return ( 747 | 758 | ); 759 | } 760 | 761 | function renderHour(currentMatch: string, index: number) { 762 | if (/h/.test(currentMatch)) { 763 | return renderHour12(currentMatch, index); 764 | } 765 | 766 | return renderHour24(currentMatch, index); 767 | } 768 | 769 | function renderMinute(currentMatch: string, index: number) { 770 | if (currentMatch && currentMatch.length > 2) { 771 | throw new Error(`Unsupported token: ${currentMatch}`); 772 | } 773 | 774 | const showLeadingZeros = currentMatch ? currentMatch.length === 2 : false; 775 | 776 | return ( 777 | 789 | ); 790 | } 791 | 792 | function renderSecond(currentMatch: string, index: number) { 793 | if (currentMatch && currentMatch.length > 2) { 794 | throw new Error(`Unsupported token: ${currentMatch}`); 795 | } 796 | 797 | const showLeadingZeros = currentMatch ? currentMatch.length === 2 : true; 798 | 799 | return ( 800 | 813 | ); 814 | } 815 | 816 | function renderAmPm(_currentMatch: string, index: number) { 817 | return ( 818 | 829 | ); 830 | } 831 | 832 | function renderCustomInputsInternal() { 833 | const elementFunctions = { 834 | d: renderDay, 835 | M: renderMonth, 836 | y: renderYear, 837 | h: renderHour, 838 | H: renderHour, 839 | m: renderMinute, 840 | s: renderSecond, 841 | a: renderAmPm, 842 | }; 843 | 844 | const allowMultipleInstances = typeof format !== 'undefined'; 845 | return renderCustomInputs(placeholder, elementFunctions, allowMultipleInstances); 846 | } 847 | 848 | function renderNativeInput() { 849 | return ( 850 | 862 | ); 863 | } 864 | 865 | return ( 866 | // biome-ignore lint/a11y/useKeyWithClickEvents: This interaction is designed for mouse users only 867 |
868 | {renderNativeInput()} 869 | {renderCustomInputsInternal()} 870 |
871 | ); 872 | } 873 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/DateTimeInput/NativeInput.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import NativeInput from './NativeInput.js'; 5 | 6 | describe('NativeInput', () => { 7 | const defaultProps = { 8 | onChange: () => { 9 | // Intentionally empty 10 | }, 11 | valueType: 'second', 12 | } satisfies React.ComponentProps; 13 | 14 | it('renders an input', () => { 15 | const { container } = render(); 16 | 17 | const input = container.querySelector('input'); 18 | 19 | expect(input).toBeInTheDocument(); 20 | }); 21 | 22 | it('applies given aria-label properly', () => { 23 | const nativeInputAriaLabel = 'Date'; 24 | 25 | const { container } = render( 26 | , 27 | ); 28 | 29 | const input = container.querySelector('input'); 30 | 31 | expect(input).toHaveAttribute('aria-label', nativeInputAriaLabel); 32 | }); 33 | 34 | it('has proper name defined', () => { 35 | const name = 'testName'; 36 | 37 | const { container } = render(); 38 | 39 | const input = container.querySelector('input'); 40 | 41 | expect(input).toHaveAttribute('name', name); 42 | }); 43 | 44 | // TODO: Investigate why ".000" is added here 45 | it.each` 46 | valueType | parsedValue 47 | ${'second'} | ${'2017-09-30T22:17:41'} 48 | ${'minute'} | ${'2017-09-30T22:17'} 49 | ${'hour'} | ${'2017-09-30T22:00'} 50 | `('displays given value properly if valueType is $valueType', ({ valueType, parsedValue }) => { 51 | const value = new Date(2017, 8, 30, 22, 17, 41); 52 | 53 | const { container } = render( 54 | , 55 | ); 56 | 57 | const input = container.querySelector('input'); 58 | 59 | expect(input).toHaveValue(parsedValue); 60 | }); 61 | 62 | it('does not disable input by default', () => { 63 | const { container } = render(); 64 | 65 | const input = container.querySelector('input'); 66 | 67 | expect(input).not.toBeDisabled(); 68 | }); 69 | 70 | it('disables input given disabled flag', () => { 71 | const { container } = render(); 72 | 73 | const input = container.querySelector('input'); 74 | 75 | expect(input).toBeDisabled(); 76 | }); 77 | 78 | it('is not required input by default', () => { 79 | const { container } = render(); 80 | 81 | const input = container.querySelector('input'); 82 | 83 | expect(input).not.toBeRequired(); 84 | }); 85 | 86 | it('required input given required flag', () => { 87 | const { container } = render(); 88 | 89 | const input = container.querySelector('input'); 90 | 91 | expect(input).toBeRequired(); 92 | }); 93 | 94 | it('has no min by default', () => { 95 | const { container } = render(); 96 | 97 | const input = container.querySelector('input'); 98 | 99 | expect(input).not.toHaveAttribute('min'); 100 | }); 101 | 102 | it.each` 103 | valueType | parsedMin 104 | ${'second'} | ${'2017-09-30T22:00:00'} 105 | ${'minute'} | ${'2017-09-30T22:00'} 106 | ${'hour'} | ${'2017-09-30T22:00'} 107 | `( 108 | 'has proper min for minDate which is a full hour if valueType is $valueType', 109 | ({ valueType, parsedMin }) => { 110 | const { container } = render( 111 | , 116 | ); 117 | 118 | const input = container.querySelector('input'); 119 | 120 | expect(input).toHaveAttribute('min', parsedMin); 121 | }, 122 | ); 123 | 124 | it.each` 125 | valueType | parsedMin 126 | ${'second'} | ${'2017-09-30T22:17:41'} 127 | ${'minute'} | ${'2017-09-30T22:17'} 128 | ${'hour'} | ${'2017-09-30T22:00'} 129 | `( 130 | 'has proper min for minDate which is not a full hour if valueType is $valueType', 131 | ({ valueType, parsedMin }) => { 132 | const { container } = render( 133 | , 138 | ); 139 | 140 | const input = container.querySelector('input'); 141 | 142 | expect(input).toHaveAttribute('min', parsedMin); 143 | }, 144 | ); 145 | 146 | it('has no max by default', () => { 147 | const { container } = render(); 148 | 149 | const input = container.querySelector('input'); 150 | 151 | expect(input).not.toHaveAttribute('max'); 152 | }); 153 | 154 | it.each` 155 | valueType | parsedMax 156 | ${'second'} | ${'2017-09-30T22:00:00'} 157 | ${'minute'} | ${'2017-09-30T22:00'} 158 | ${'hour'} | ${'2017-09-30T22:00'} 159 | `( 160 | 'has proper max for maxDate which is a full hour if valueType is $valueType', 161 | ({ valueType, parsedMax }) => { 162 | const { container } = render( 163 | , 168 | ); 169 | 170 | const input = container.querySelector('input'); 171 | 172 | expect(input).toHaveAttribute('max', parsedMax); 173 | }, 174 | ); 175 | 176 | it.each` 177 | valueType | parsedMax 178 | ${'second'} | ${'2017-09-30T22:17:41'} 179 | ${'minute'} | ${'2017-09-30T22:17'} 180 | ${'hour'} | ${'2017-09-30T22:00'} 181 | `( 182 | 'has proper max for maxDate which is not a full hour if valueType is $valueType', 183 | ({ valueType, parsedMax }) => { 184 | const { container } = render( 185 | , 190 | ); 191 | 192 | const input = container.querySelector('input'); 193 | 194 | expect(input).toHaveAttribute('max', parsedMax); 195 | }, 196 | ); 197 | }); 198 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/DateTimeInput/NativeInput.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | getHours, 3 | getHoursMinutes, 4 | getISOLocalDate, 5 | getISOLocalDateTime, 6 | } from '@wojtekmaj/date-utils'; 7 | 8 | type NativeInputProps = { 9 | ariaLabel?: string; 10 | disabled?: boolean; 11 | maxDate?: Date; 12 | minDate?: Date; 13 | name?: string; 14 | onChange?: (event: React.ChangeEvent) => void; 15 | required?: boolean; 16 | value?: Date | null; 17 | valueType: 'hour' | 'minute' | 'second'; 18 | }; 19 | 20 | export default function NativeInput({ 21 | ariaLabel, 22 | disabled, 23 | maxDate, 24 | minDate, 25 | name, 26 | onChange, 27 | required, 28 | value, 29 | valueType, 30 | }: NativeInputProps): React.ReactElement { 31 | const nativeValueParser = (() => { 32 | switch (valueType) { 33 | case 'hour': 34 | return (receivedValue: Date) => 35 | `${getISOLocalDate(receivedValue)}T${getHours(receivedValue)}:00`; 36 | case 'minute': 37 | return (receivedValue: Date) => 38 | `${getISOLocalDate(receivedValue)}T${getHoursMinutes(receivedValue)}`; 39 | case 'second': 40 | return getISOLocalDateTime; 41 | default: 42 | throw new Error('Invalid valueType'); 43 | } 44 | })(); 45 | 46 | const step = (() => { 47 | switch (valueType) { 48 | case 'hour': 49 | return 3600; 50 | case 'minute': 51 | return 60; 52 | case 'second': 53 | return 1; 54 | default: 55 | throw new Error('Invalid valueType'); 56 | } 57 | })(); 58 | 59 | function stopPropagation(event: React.FocusEvent) { 60 | event.stopPropagation(); 61 | } 62 | 63 | return ( 64 | 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/DateTimePicker.css: -------------------------------------------------------------------------------- 1 | .react-datetime-picker { 2 | display: inline-flex; 3 | position: relative; 4 | } 5 | 6 | .react-datetime-picker, 7 | .react-datetime-picker *, 8 | .react-datetime-picker *:before, 9 | .react-datetime-picker *:after { 10 | -moz-box-sizing: border-box; 11 | -webkit-box-sizing: border-box; 12 | box-sizing: border-box; 13 | } 14 | 15 | .react-datetime-picker--disabled { 16 | background-color: #f0f0f0; 17 | color: #6d6d6d; 18 | } 19 | 20 | .react-datetime-picker__wrapper { 21 | display: flex; 22 | flex-grow: 1; 23 | flex-shrink: 0; 24 | border: thin solid gray; 25 | } 26 | 27 | .react-datetime-picker__inputGroup { 28 | min-width: calc(4px + (4px * 3) + 0.54em * 6 + 0.217em * 2); 29 | flex-grow: 1; 30 | padding: 0 2px; 31 | } 32 | 33 | .react-datetime-picker__inputGroup__divider { 34 | padding: 1px 0; 35 | white-space: pre; 36 | } 37 | 38 | .react-datetime-picker__inputGroup__divider, 39 | .react-datetime-picker__inputGroup__leadingZero { 40 | display: inline-block; 41 | font: inherit; 42 | } 43 | 44 | .react-datetime-picker__inputGroup__input { 45 | min-width: 0.54em; 46 | height: calc(100% - 2px); 47 | position: relative; 48 | padding: 1px; 49 | border: 0; 50 | background: none; 51 | color: currentColor; 52 | font: inherit; 53 | box-sizing: content-box; 54 | -webkit-appearance: textfield; 55 | -moz-appearance: textfield; 56 | appearance: textfield; 57 | } 58 | 59 | .react-datetime-picker__inputGroup__input::-webkit-outer-spin-button, 60 | .react-datetime-picker__inputGroup__input::-webkit-inner-spin-button { 61 | -webkit-appearance: none; 62 | -moz-appearance: none; 63 | appearance: none; 64 | margin: 0; 65 | } 66 | 67 | .react-datetime-picker__inputGroup__input:invalid { 68 | background: rgba(255, 0, 0, 0.1); 69 | } 70 | 71 | .react-datetime-picker__inputGroup__input--hasLeadingZero { 72 | margin-left: -0.54em; 73 | padding-left: calc(1px + 0.54em); 74 | } 75 | 76 | .react-datetime-picker__inputGroup__amPm { 77 | font: inherit; 78 | -webkit-appearance: menulist; 79 | -moz-appearance: menulist; 80 | appearance: menulist; 81 | } 82 | 83 | .react-datetime-picker__button { 84 | border: 0; 85 | background: transparent; 86 | padding: 4px 6px; 87 | } 88 | 89 | .react-datetime-picker__button:enabled { 90 | cursor: pointer; 91 | } 92 | 93 | .react-datetime-picker__button:enabled:hover .react-datetime-picker__button__icon, 94 | .react-datetime-picker__button:enabled:focus .react-datetime-picker__button__icon { 95 | stroke: #0078d7; 96 | } 97 | 98 | .react-datetime-picker__button:disabled .react-datetime-picker__button__icon { 99 | stroke: #6d6d6d; 100 | } 101 | 102 | .react-datetime-picker__button svg { 103 | display: inherit; 104 | } 105 | 106 | .react-datetime-picker__calendar, 107 | .react-datetime-picker__clock { 108 | z-index: 1; 109 | } 110 | 111 | .react-datetime-picker__calendar--closed, 112 | .react-datetime-picker__clock--closed { 113 | display: none; 114 | } 115 | 116 | .react-datetime-picker__calendar { 117 | width: 350px; 118 | max-width: 100vw; 119 | } 120 | 121 | .react-datetime-picker__calendar .react-calendar { 122 | border-width: thin; 123 | } 124 | 125 | .react-datetime-picker__clock { 126 | width: 200px; 127 | height: 200px; 128 | max-width: 100vw; 129 | padding: 25px; 130 | background-color: white; 131 | border: thin solid #a0a096; 132 | } 133 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/DateTimePicker.spec.tsx: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from 'vitest'; 2 | import { act, fireEvent, render, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; 3 | import { userEvent } from '@testing-library/user-event'; 4 | 5 | import DateTimePicker from './DateTimePicker.js'; 6 | 7 | async function waitForElementToBeRemovedOrHidden(callback: () => HTMLElement | null) { 8 | const element = callback(); 9 | 10 | if (element) { 11 | try { 12 | await waitFor(() => 13 | expect(element).toHaveAttribute('class', expect.stringContaining('--closed')), 14 | ); 15 | } catch { 16 | await waitForElementToBeRemoved(element); 17 | } 18 | } 19 | } 20 | 21 | describe('DateTimePicker', () => { 22 | it('passes default name to DateTimeInput', () => { 23 | const { container } = render(); 24 | 25 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 26 | 27 | expect(nativeInput).toHaveAttribute('name', 'datetime'); 28 | }); 29 | 30 | it('passes custom name to DateTimeInput', () => { 31 | const name = 'testName'; 32 | 33 | const { container } = render(); 34 | 35 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 36 | 37 | expect(nativeInput).toHaveAttribute('name', name); 38 | }); 39 | 40 | it('passes autoFocus flag to DateTimeInput', () => { 41 | const { container } = render(); 42 | 43 | const customInputs = container.querySelectorAll('input[data-input]'); 44 | 45 | expect(customInputs[0]).toHaveFocus(); 46 | }); 47 | 48 | it('passes disabled flag to DateTimeInput', () => { 49 | const { container } = render(); 50 | 51 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 52 | 53 | expect(nativeInput).toBeDisabled(); 54 | }); 55 | 56 | it('passes format to DateTimeInput', () => { 57 | const { container } = render(); 58 | 59 | const customInputs = container.querySelectorAll('input[data-input]'); 60 | 61 | expect(customInputs).toHaveLength(1); 62 | expect(customInputs[0]).toHaveAttribute('name', 'second'); 63 | }); 64 | 65 | it('passes aria-label props to DateInput', () => { 66 | const ariaLabelProps = { 67 | amPmAriaLabel: 'Select AM/PM', 68 | calendarAriaLabel: 'Toggle calendar', 69 | clearAriaLabel: 'Clear value', 70 | dayAriaLabel: 'Day', 71 | hourAriaLabel: 'Hour', 72 | minuteAriaLabel: 'Minute', 73 | monthAriaLabel: 'Month', 74 | nativeInputAriaLabel: 'Date and time', 75 | secondAriaLabel: 'Second', 76 | yearAriaLabel: 'Year', 77 | }; 78 | 79 | const { container } = render(); 80 | 81 | const calendarButton = container.querySelector('button.react-datetime-picker__calendar-button'); 82 | const clearButton = container.querySelector('button.react-datetime-picker__clear-button'); 83 | 84 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 85 | const dayInput = container.querySelector('input[name="day"]'); 86 | const monthInput = container.querySelector('input[name="month"]'); 87 | const yearInput = container.querySelector('input[name="year"]'); 88 | const hourInput = container.querySelector('input[name="hour12"]'); 89 | const minuteInput = container.querySelector('input[name="minute"]'); 90 | const secondInput = container.querySelector('input[name="second"]'); 91 | 92 | expect(calendarButton).toHaveAttribute('aria-label', ariaLabelProps.calendarAriaLabel); 93 | expect(clearButton).toHaveAttribute('aria-label', ariaLabelProps.clearAriaLabel); 94 | 95 | expect(nativeInput).toHaveAttribute('aria-label', ariaLabelProps.nativeInputAriaLabel); 96 | expect(dayInput).toHaveAttribute('aria-label', ariaLabelProps.dayAriaLabel); 97 | expect(monthInput).toHaveAttribute('aria-label', ariaLabelProps.monthAriaLabel); 98 | expect(yearInput).toHaveAttribute('aria-label', ariaLabelProps.yearAriaLabel); 99 | expect(hourInput).toHaveAttribute('aria-label', ariaLabelProps.hourAriaLabel); 100 | expect(minuteInput).toHaveAttribute('aria-label', ariaLabelProps.minuteAriaLabel); 101 | expect(secondInput).toHaveAttribute('aria-label', ariaLabelProps.secondAriaLabel); 102 | }); 103 | 104 | it('passes placeholder props to DateInput', () => { 105 | const placeholderProps = { 106 | dayPlaceholder: 'Day', 107 | hourPlaceholder: 'Hour', 108 | minutePlaceholder: 'Minute', 109 | monthPlaceholder: 'Month', 110 | secondPlaceholder: 'Second', 111 | yearPlaceholder: 'Year', 112 | }; 113 | 114 | const { container } = render(); 115 | 116 | const dayInput = container.querySelector('input[name="day"]'); 117 | const monthInput = container.querySelector('input[name="month"]'); 118 | const yearInput = container.querySelector('input[name="year"]'); 119 | const hourInput = container.querySelector('input[name="hour12"]'); 120 | const minuteInput = container.querySelector('input[name="minute"]'); 121 | const secondInput = container.querySelector('input[name="second"]'); 122 | 123 | expect(dayInput).toHaveAttribute('placeholder', placeholderProps.dayPlaceholder); 124 | expect(monthInput).toHaveAttribute('placeholder', placeholderProps.monthPlaceholder); 125 | expect(yearInput).toHaveAttribute('placeholder', placeholderProps.yearPlaceholder); 126 | expect(hourInput).toHaveAttribute('placeholder', placeholderProps.hourPlaceholder); 127 | expect(minuteInput).toHaveAttribute('placeholder', placeholderProps.minutePlaceholder); 128 | expect(secondInput).toHaveAttribute('placeholder', placeholderProps.secondPlaceholder); 129 | }); 130 | 131 | describe('passes value to DateTimeInput', () => { 132 | it('passes single value to DateTimeInput', () => { 133 | const value = new Date(2019, 0, 1); 134 | 135 | const { container } = render(); 136 | 137 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 138 | 139 | expect(nativeInput).toHaveValue('2019-01-01T00:00'); 140 | }); 141 | 142 | it('passes the first item of an array of values to DateTimeInput', () => { 143 | const value1 = new Date(2019, 0, 1); 144 | const value2 = new Date(2019, 6, 1); 145 | 146 | const { container } = render(); 147 | 148 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 149 | 150 | expect(nativeInput).toHaveValue('2019-01-01T00:00'); 151 | }); 152 | }); 153 | 154 | it('applies className to its wrapper when given a string', () => { 155 | const className = 'testClassName'; 156 | 157 | const { container } = render(); 158 | 159 | const wrapper = container.firstElementChild; 160 | 161 | expect(wrapper).toHaveClass(className); 162 | }); 163 | 164 | it('applies "--open" className to its wrapper when given isCalendarOpen flag', () => { 165 | const { container } = render(); 166 | 167 | const wrapper = container.firstElementChild; 168 | 169 | expect(wrapper).toHaveClass('react-datetime-picker--open'); 170 | }); 171 | 172 | it('applies "--open" className to its wrapper when given isClockOpen flag', () => { 173 | const { container } = render(); 174 | 175 | const wrapper = container.firstElementChild; 176 | 177 | expect(wrapper).toHaveClass('react-datetime-picker--open'); 178 | }); 179 | 180 | it('applies calendar className to the calendar when given a string', () => { 181 | const calendarClassName = 'testClassName'; 182 | 183 | const { container } = render( 184 | , 185 | ); 186 | 187 | const calendar = container.querySelector('.react-calendar'); 188 | 189 | expect(calendar).toHaveClass(calendarClassName); 190 | }); 191 | 192 | it('applies clock className to the clock when given a string', () => { 193 | const clockClassName = 'testClassName'; 194 | 195 | const { container } = render( 196 | , 197 | ); 198 | 199 | const clock = container.querySelector('.react-clock'); 200 | 201 | expect(clock).toHaveClass(clockClassName); 202 | }); 203 | 204 | it('renders DateTimeInput component', () => { 205 | const { container } = render(); 206 | 207 | const nativeInput = container.querySelector('input[type="datetime-local"]'); 208 | 209 | expect(nativeInput).toBeInTheDocument(); 210 | }); 211 | 212 | describe('renders clear button properly', () => { 213 | it('renders clear button', () => { 214 | const { container } = render(); 215 | 216 | const clearButton = container.querySelector('button.react-datetime-picker__clear-button'); 217 | 218 | expect(clearButton).toBeInTheDocument(); 219 | }); 220 | 221 | it('renders clear icon by default when clearIcon is not given', () => { 222 | const { container } = render(); 223 | 224 | const clearButton = container.querySelector( 225 | 'button.react-datetime-picker__clear-button', 226 | ) as HTMLButtonElement; 227 | 228 | const clearIcon = clearButton.querySelector('svg'); 229 | 230 | expect(clearIcon).toBeInTheDocument(); 231 | }); 232 | 233 | it('renders clear icon when given clearIcon as a string', () => { 234 | const { container } = render(); 235 | 236 | const clearButton = container.querySelector('button.react-datetime-picker__clear-button'); 237 | 238 | expect(clearButton).toHaveTextContent('❌'); 239 | }); 240 | 241 | it('renders clear icon when given clearIcon as a React element', () => { 242 | function ClearIcon() { 243 | return <>❌; 244 | } 245 | 246 | const { container } = render(} />); 247 | 248 | const clearButton = container.querySelector('button.react-datetime-picker__clear-button'); 249 | 250 | expect(clearButton).toHaveTextContent('❌'); 251 | }); 252 | 253 | it('renders clear icon when given clearIcon as a function', () => { 254 | function ClearIcon() { 255 | return <>❌; 256 | } 257 | 258 | const { container } = render(); 259 | 260 | const clearButton = container.querySelector('button.react-datetime-picker__clear-button'); 261 | 262 | expect(clearButton).toHaveTextContent('❌'); 263 | }); 264 | }); 265 | 266 | describe('renders calendar button properly', () => { 267 | it('renders calendar button', () => { 268 | const { container } = render(); 269 | 270 | const calendarButton = container.querySelector( 271 | 'button.react-datetime-picker__calendar-button', 272 | ); 273 | 274 | expect(calendarButton).toBeInTheDocument(); 275 | }); 276 | 277 | it('renders calendar icon by default when calendarIcon is not given', () => { 278 | const { container } = render(); 279 | 280 | const calendarButton = container.querySelector( 281 | 'button.react-datetime-picker__calendar-button', 282 | ) as HTMLButtonElement; 283 | 284 | const calendarIcon = calendarButton.querySelector('svg'); 285 | 286 | expect(calendarIcon).toBeInTheDocument(); 287 | }); 288 | 289 | it('renders calendar icon when given calendarIcon as a string', () => { 290 | const { container } = render(); 291 | 292 | const calendarButton = container.querySelector( 293 | 'button.react-datetime-picker__calendar-button', 294 | ); 295 | 296 | expect(calendarButton).toHaveTextContent('📅'); 297 | }); 298 | 299 | it('renders calendar icon when given calendarIcon as a React element', () => { 300 | function CalendarIcon() { 301 | return <>📅; 302 | } 303 | 304 | const { container } = render(} />); 305 | 306 | const calendarButton = container.querySelector( 307 | 'button.react-datetime-picker__calendar-button', 308 | ); 309 | 310 | expect(calendarButton).toHaveTextContent('📅'); 311 | }); 312 | 313 | it('renders calendar icon when given calendarIcon as a function', () => { 314 | function CalendarIcon() { 315 | return <>📅; 316 | } 317 | 318 | const { container } = render(); 319 | 320 | const calendarButton = container.querySelector( 321 | 'button.react-datetime-picker__calendar-button', 322 | ); 323 | 324 | expect(calendarButton).toHaveTextContent('📅'); 325 | }); 326 | }); 327 | 328 | it('renders Calendar component when given isCalendarOpen flag', () => { 329 | const { container } = render(); 330 | 331 | const calendar = container.querySelector('.react-calendar'); 332 | 333 | expect(calendar).toBeInTheDocument(); 334 | }); 335 | 336 | it('renders Clock component when given isClockOpen flag', () => { 337 | const { container } = render(); 338 | 339 | const clock = container.querySelector('.react-clock'); 340 | 341 | expect(clock).toBeInTheDocument(); 342 | }); 343 | 344 | it('does not render Calendar component when given disableCalendar & isCalendarOpen flags', () => { 345 | const { container } = render(); 346 | 347 | const calendar = container.querySelector('.react-calendar'); 348 | 349 | expect(calendar).toBeFalsy(); 350 | }); 351 | 352 | it('does not render Clock component when given disableClock & isClockOpen flags', () => { 353 | const { container } = render(); 354 | 355 | const clock = container.querySelector('.react-clock'); 356 | 357 | expect(clock).toBeFalsy(); 358 | }); 359 | 360 | it('opens Calendar component when given isCalendarOpen flag by changing props', () => { 361 | const { container, rerender } = render(); 362 | 363 | const calendar = container.querySelector('.react-calendar'); 364 | 365 | expect(calendar).toBeFalsy(); 366 | 367 | rerender(); 368 | 369 | const calendar2 = container.querySelector('.react-calendar'); 370 | 371 | expect(calendar2).toBeInTheDocument(); 372 | }); 373 | 374 | it('opens Clock component when given isClockOpen flag by changing props', () => { 375 | const { container, rerender } = render(); 376 | 377 | const clock = container.querySelector('.react-clock'); 378 | 379 | expect(clock).toBeFalsy(); 380 | 381 | rerender(); 382 | 383 | const clock2 = container.querySelector('.react-clock'); 384 | 385 | expect(clock2).toBeInTheDocument(); 386 | }); 387 | 388 | it('opens Calendar component when clicking on a button', () => { 389 | const { container } = render(); 390 | 391 | const calendar = container.querySelector('.react-calendar'); 392 | const button = container.querySelector( 393 | 'button.react-datetime-picker__calendar-button', 394 | ) as HTMLButtonElement; 395 | 396 | expect(calendar).toBeFalsy(); 397 | 398 | fireEvent.click(button); 399 | 400 | const calendar2 = container.querySelector('.react-calendar'); 401 | 402 | expect(calendar2).toBeInTheDocument(); 403 | }); 404 | 405 | describe('handles opening Calendar component when focusing on an input inside properly', () => { 406 | it('opens Calendar component when focusing on an input inside by default', () => { 407 | const { container } = render(); 408 | 409 | const calendar = container.querySelector('.react-calendar'); 410 | const input = container.querySelector('input[name="day"]') as HTMLInputElement; 411 | 412 | expect(calendar).toBeFalsy(); 413 | 414 | fireEvent.focus(input); 415 | 416 | const calendar2 = container.querySelector('.react-calendar'); 417 | 418 | expect(calendar2).toBeInTheDocument(); 419 | }); 420 | 421 | it('opens Calendar component when focusing on an input inside given openWidgetsOnFocus = true', () => { 422 | const { container } = render(); 423 | 424 | const calendar = container.querySelector('.react-calendar'); 425 | const input = container.querySelector('input[name="day"]') as HTMLInputElement; 426 | 427 | expect(calendar).toBeFalsy(); 428 | 429 | fireEvent.focus(input); 430 | 431 | const calendar2 = container.querySelector('.react-calendar'); 432 | 433 | expect(calendar2).toBeInTheDocument(); 434 | }); 435 | 436 | it('does not open Calendar component when focusing on an input inside given openWidgetsOnFocus = false', () => { 437 | const { container } = render(); 438 | 439 | const calendar = container.querySelector('.react-calendar'); 440 | const input = container.querySelector('input[name="day"]') as HTMLInputElement; 441 | 442 | expect(calendar).toBeFalsy(); 443 | 444 | fireEvent.focus(input); 445 | 446 | const calendar2 = container.querySelector('.react-calendar'); 447 | 448 | expect(calendar2).toBeFalsy(); 449 | }); 450 | 451 | it('does not open Calendar component when focusing on an input inside given shouldOpenWidgets function returning false', () => { 452 | const shouldOpenWidgets = () => false; 453 | 454 | const { container } = render(); 455 | 456 | const calendar = container.querySelector('.react-calendar'); 457 | const input = container.querySelector('input[name="day"]') as HTMLInputElement; 458 | 459 | expect(calendar).toBeFalsy(); 460 | 461 | fireEvent.focus(input); 462 | 463 | const calendar2 = container.querySelector('.react-calendar'); 464 | 465 | expect(calendar2).toBeFalsy(); 466 | }); 467 | 468 | it('does not open Calendar component when focusing on a select element', () => { 469 | const { container } = render(); 470 | 471 | const calendar = container.querySelector('.react-calendar'); 472 | const select = container.querySelector('select[name="month"]') as HTMLSelectElement; 473 | 474 | expect(calendar).toBeFalsy(); 475 | 476 | fireEvent.focus(select); 477 | 478 | const calendar2 = container.querySelector('.react-calendar'); 479 | 480 | expect(calendar2).toBeFalsy(); 481 | }); 482 | }); 483 | 484 | describe('handles opening Clock component when focusing on an input inside properly', () => { 485 | it('opens Clock component when focusing on an input inside by default', () => { 486 | const { container } = render(); 487 | 488 | const clock = container.querySelector('.react-clock'); 489 | const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; 490 | 491 | expect(clock).toBeFalsy(); 492 | 493 | fireEvent.focus(input); 494 | 495 | const clock2 = container.querySelector('.react-clock'); 496 | 497 | expect(clock2).toBeInTheDocument(); 498 | }); 499 | 500 | it('opens Clock component when focusing on an input inside given openWidgetsOnFocus = true', () => { 501 | const { container } = render(); 502 | 503 | const clock = container.querySelector('.react-clock'); 504 | const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; 505 | 506 | expect(clock).toBeFalsy(); 507 | 508 | fireEvent.focus(input); 509 | 510 | const clock2 = container.querySelector('.react-clock'); 511 | 512 | expect(clock2).toBeInTheDocument(); 513 | }); 514 | 515 | it('does not open Clock component when focusing on an input inside given openWidgetsOnFocus = false', () => { 516 | const { container } = render(); 517 | 518 | const clock = container.querySelector('.react-clock'); 519 | const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; 520 | 521 | expect(clock).toBeFalsy(); 522 | 523 | fireEvent.focus(input); 524 | 525 | const clock2 = container.querySelector('.react-clock'); 526 | 527 | expect(clock2).toBeFalsy(); 528 | }); 529 | 530 | it('does not open Clock component when focusing on an input inside given shouldOpenWidgets function returning false', () => { 531 | const shouldOpenWidgets = () => false; 532 | 533 | const { container } = render(); 534 | 535 | const clock = container.querySelector('.react-clock'); 536 | const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; 537 | 538 | expect(clock).toBeFalsy(); 539 | 540 | fireEvent.focus(input); 541 | 542 | const clock2 = container.querySelector('.react-clock'); 543 | 544 | expect(clock2).toBeFalsy(); 545 | }); 546 | 547 | it('does not open Clock component when focusing on a select element', () => { 548 | const { container } = render(); 549 | 550 | const clock = container.querySelector('.react-clock'); 551 | const select = container.querySelector('select[name="amPm"]') as HTMLSelectElement; 552 | 553 | expect(clock).toBeFalsy(); 554 | 555 | fireEvent.focus(select); 556 | 557 | const clock2 = container.querySelector('.react-clock'); 558 | 559 | expect(clock2).toBeFalsy(); 560 | }); 561 | }); 562 | 563 | it('closes Calendar component when clicked outside', async () => { 564 | const { container } = render(); 565 | 566 | userEvent.click(document.body); 567 | 568 | await waitForElementToBeRemovedOrHidden(() => 569 | container.querySelector('.react-datetime-picker__calendar'), 570 | ); 571 | }); 572 | 573 | it('closes Calendar component when focused outside', async () => { 574 | const { container } = render(); 575 | 576 | fireEvent.focusIn(document.body); 577 | 578 | await waitForElementToBeRemovedOrHidden(() => 579 | container.querySelector('.react-datetime-picker__calendar'), 580 | ); 581 | }); 582 | 583 | it('closes Calendar component when tapped outside', async () => { 584 | const { container } = render(); 585 | 586 | fireEvent.touchStart(document.body); 587 | 588 | await waitForElementToBeRemovedOrHidden(() => 589 | container.querySelector('.react-datetime-picker__calendar'), 590 | ); 591 | }); 592 | 593 | it('closes Clock component when clicked outside', async () => { 594 | const { container } = render(); 595 | 596 | userEvent.click(document.body); 597 | 598 | await waitForElementToBeRemovedOrHidden(() => 599 | container.querySelector('.react-datetime-picker__clock'), 600 | ); 601 | }); 602 | 603 | it('closes Clock component when focused outside', async () => { 604 | const { container } = render(); 605 | 606 | fireEvent.focusIn(document.body); 607 | 608 | await waitForElementToBeRemovedOrHidden(() => 609 | container.querySelector('.react-datetime-picker__clock'), 610 | ); 611 | }); 612 | 613 | it('closes Clock component when tapped outside', async () => { 614 | const { container } = render(); 615 | 616 | fireEvent.touchStart(document.body); 617 | 618 | await waitForElementToBeRemovedOrHidden(() => 619 | container.querySelector('.react-datetime-picker__clock'), 620 | ); 621 | }); 622 | 623 | it('does not close Calendar component when focused within date inputs', () => { 624 | const { container } = render(); 625 | 626 | const customInputs = container.querySelectorAll('input[data-input]'); 627 | const monthInput = customInputs[0] as HTMLInputElement; 628 | const dayInput = customInputs[1] as HTMLInputElement; 629 | 630 | fireEvent.blur(monthInput); 631 | fireEvent.focus(dayInput); 632 | 633 | const calendar = container.querySelector('.react-calendar'); 634 | 635 | expect(calendar).toBeInTheDocument(); 636 | }); 637 | 638 | it('does not close Clock component when focused within time inputs', () => { 639 | const { container } = render(); 640 | 641 | const customInputs = container.querySelectorAll('input[data-input]'); 642 | const hourInput = customInputs[3] as HTMLInputElement; 643 | const minuteInput = customInputs[4] as HTMLInputElement; 644 | 645 | fireEvent.blur(hourInput); 646 | fireEvent.focus(minuteInput); 647 | 648 | const clock = container.querySelector('.react-clock'); 649 | 650 | expect(clock).toBeInTheDocument(); 651 | }); 652 | 653 | it('closes Clock when Calendar is opened by a click on the calendar icon', async () => { 654 | const { container } = render(); 655 | 656 | const clock = container.querySelector('.react-clock'); 657 | const button = container.querySelector( 658 | 'button.react-datetime-picker__calendar-button', 659 | ) as HTMLButtonElement; 660 | 661 | expect(clock).toBeInTheDocument(); 662 | 663 | fireEvent.click(button); 664 | 665 | await waitForElementToBeRemovedOrHidden(() => 666 | container.querySelector('.react-datetime-picker__clock'), 667 | ); 668 | }); 669 | 670 | it('opens Calendar component, followed by Clock component, when focusing on inputs inside', () => { 671 | const { container } = render(); 672 | 673 | const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; 674 | 675 | fireEvent.focus(dayInput); 676 | 677 | const calendar = container.querySelector('.react-calendar'); 678 | 679 | expect(calendar).toBeInTheDocument(); 680 | 681 | const minuteInput = container.querySelector('input[name="minute"]') as HTMLInputElement; 682 | 683 | fireEvent.focus(minuteInput); 684 | 685 | const clock = container.querySelector('.react-clock'); 686 | 687 | expect(clock).toBeInTheDocument(); 688 | }); 689 | 690 | it('closes Calendar when changing value by default', async () => { 691 | const { container } = render(); 692 | 693 | const firstTile = container.querySelector('.react-calendar__tile') as HTMLButtonElement; 694 | 695 | act(() => { 696 | fireEvent.click(firstTile); 697 | }); 698 | 699 | await waitForElementToBeRemovedOrHidden(() => 700 | container.querySelector('.react-datetime-picker__calendar'), 701 | ); 702 | }); 703 | 704 | it('closes Calendar when changing value with prop closeWidgets = true', async () => { 705 | const { container } = render(); 706 | 707 | const firstTile = container.querySelector('.react-calendar__tile') as HTMLButtonElement; 708 | 709 | act(() => { 710 | fireEvent.click(firstTile); 711 | }); 712 | 713 | await waitForElementToBeRemovedOrHidden(() => 714 | container.querySelector('.react-datetime-picker__calendar'), 715 | ); 716 | }); 717 | 718 | it('does not close Calendar when changing value with prop closeWidgets = false', () => { 719 | const { container } = render(); 720 | 721 | const firstTile = container.querySelector('.react-calendar__tile') as HTMLButtonElement; 722 | 723 | act(() => { 724 | fireEvent.click(firstTile); 725 | }); 726 | 727 | const calendar = container.querySelector('.react-calendar'); 728 | 729 | expect(calendar).toBeInTheDocument(); 730 | }); 731 | 732 | it('does not close Calendar when changing value with shouldCloseWidgets function returning false', () => { 733 | const shouldCloseWidgets = () => false; 734 | 735 | const { container } = render( 736 | , 737 | ); 738 | 739 | const firstTile = container.querySelector('.react-calendar__tile') as HTMLButtonElement; 740 | 741 | act(() => { 742 | fireEvent.click(firstTile); 743 | }); 744 | 745 | const calendar = container.querySelector('.react-calendar'); 746 | 747 | expect(calendar).toBeInTheDocument(); 748 | }); 749 | 750 | it('does not close Calendar when changing value using inputs', () => { 751 | const { container } = render(); 752 | 753 | const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; 754 | 755 | act(() => { 756 | fireEvent.change(dayInput, { target: { value: '1' } }); 757 | }); 758 | 759 | const calendar = container.querySelector('.react-calendar'); 760 | 761 | expect(calendar).toBeInTheDocument(); 762 | }); 763 | 764 | it('does not close Clock when changing value using inputs', () => { 765 | const { container } = render(); 766 | 767 | const hourInput = container.querySelector('input[name="hour12"]') as HTMLInputElement; 768 | 769 | act(() => { 770 | fireEvent.change(hourInput, { target: { value: '9' } }); 771 | }); 772 | 773 | const clock = container.querySelector('.react-clock'); 774 | 775 | expect(clock).toBeInTheDocument(); 776 | }); 777 | 778 | it('calls onChange callback when changing value', () => { 779 | const value = new Date(2023, 0, 31, 21, 40, 11); 780 | const onChange = vi.fn(); 781 | 782 | const { container } = render( 783 | , 784 | ); 785 | 786 | const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; 787 | 788 | act(() => { 789 | fireEvent.change(dayInput, { target: { value: '1' } }); 790 | }); 791 | 792 | expect(onChange).toHaveBeenCalledWith(new Date(2023, 0, 1, 21, 40, 11)); 793 | }); 794 | 795 | it('calls onChange callback with merged new date & old time when calling internal onDateChange given Date', () => { 796 | const hours = 21; 797 | const minutes = 40; 798 | const seconds = 11; 799 | const ms = 458; 800 | 801 | const onChange = vi.fn(); 802 | const value = new Date(2018, 6, 17, hours, minutes, seconds, ms); 803 | const nextValue = new Date(2019, 0, 1, hours, minutes, seconds, ms); 804 | 805 | const { container, getByRole } = render( 806 | , 807 | ); 808 | 809 | // Navigate up the calendar 810 | const drillUpButton = container.querySelector( 811 | '.react-calendar__navigation__label', 812 | ) as HTMLButtonElement; 813 | fireEvent.click(drillUpButton); // To year 2018 814 | fireEvent.click(drillUpButton); // To 2011 – 2020 decade 815 | 816 | // Click year 2019 817 | const twentyNineteenButton = getByRole('button', { name: '2019' }); 818 | fireEvent.click(twentyNineteenButton); 819 | 820 | // Click January 821 | const januaryButton = getByRole('button', { name: 'January 2019' }); 822 | fireEvent.click(januaryButton); 823 | 824 | // Click 1st 825 | const firstButton = getByRole('button', { name: 'January 1, 2019' }); 826 | fireEvent.click(firstButton); 827 | 828 | expect(onChange).toHaveBeenCalledWith(nextValue); 829 | }); 830 | 831 | it('calls onChange callback with merged new date & old time when calling internal onDateChange given ISO string', () => { 832 | const hours = 21; 833 | const minutes = 40; 834 | const seconds = 11; 835 | const ms = 458; 836 | 837 | const onChange = vi.fn(); 838 | const value = new Date(2018, 6, 17, hours, minutes, seconds, ms).toISOString(); 839 | const nextValue = new Date(2019, 0, 1, hours, minutes, seconds, ms); 840 | 841 | const { container, getByRole } = render( 842 | , 843 | ); 844 | 845 | // Navigate up the calendar 846 | const drillUpButton = container.querySelector( 847 | '.react-calendar__navigation__label', 848 | ) as HTMLButtonElement; 849 | fireEvent.click(drillUpButton); // To year 2018 850 | fireEvent.click(drillUpButton); // To 2011 – 2020 decade 851 | 852 | // Click year 2019 853 | const twentyNineteenButton = getByRole('button', { name: '2019' }); 854 | fireEvent.click(twentyNineteenButton); 855 | 856 | // Click January 857 | const januaryButton = getByRole('button', { name: 'January 2019' }); 858 | fireEvent.click(januaryButton); 859 | 860 | // Click 1st 861 | const firstButton = getByRole('button', { name: 'January 1, 2019' }); 862 | fireEvent.click(firstButton); 863 | 864 | expect(onChange).toHaveBeenCalledWith(nextValue); 865 | }); 866 | 867 | it('calls onInvalidChange callback when changing value to an invalid one', () => { 868 | const value = new Date(2023, 0, 31, 21, 40, 11); 869 | const onInvalidChange = vi.fn(); 870 | 871 | const { container } = render( 872 | , 873 | ); 874 | 875 | const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; 876 | 877 | act(() => { 878 | fireEvent.change(dayInput, { target: { value: '32' } }); 879 | }); 880 | 881 | expect(onInvalidChange).toHaveBeenCalled(); 882 | }); 883 | 884 | it('clears the value when clicking on a button', () => { 885 | const onChange = vi.fn(); 886 | 887 | const { container } = render(); 888 | 889 | const calendar = container.querySelector('.react-calendar'); 890 | const button = container.querySelector( 891 | 'button.react-datetime-picker__clear-button', 892 | ) as HTMLButtonElement; 893 | 894 | expect(calendar).toBeFalsy(); 895 | 896 | fireEvent.click(button); 897 | 898 | expect(onChange).toHaveBeenCalledWith(null); 899 | }); 900 | 901 | it('calls onClick callback when clicked a page (sample of mouse events family)', () => { 902 | const onClick = vi.fn(); 903 | 904 | const { container } = render(); 905 | 906 | const wrapper = container.firstElementChild as HTMLDivElement; 907 | fireEvent.click(wrapper); 908 | 909 | expect(onClick).toHaveBeenCalled(); 910 | }); 911 | 912 | it('calls onTouchStart callback when touched a page (sample of touch events family)', () => { 913 | const onTouchStart = vi.fn(); 914 | 915 | const { container } = render(); 916 | 917 | const wrapper = container.firstElementChild as HTMLDivElement; 918 | fireEvent.touchStart(wrapper); 919 | 920 | expect(onTouchStart).toHaveBeenCalled(); 921 | }); 922 | }); 923 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/DateTimePicker.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; 4 | import { createPortal } from 'react-dom'; 5 | import makeEventProps from 'make-event-props'; 6 | import clsx from 'clsx'; 7 | import Calendar from 'react-calendar'; 8 | import Clock from 'react-clock'; 9 | import Fit from 'react-fit'; 10 | 11 | import DateTimeInput from './DateTimeInput.js'; 12 | 13 | import type { 14 | ClassName, 15 | CloseReason, 16 | Detail, 17 | LooseValue, 18 | OpenReason, 19 | Value, 20 | } from './shared/types.js'; 21 | 22 | const baseClassName = 'react-datetime-picker'; 23 | const outsideActionEvents = ['mousedown', 'focusin', 'touchstart'] as const; 24 | const allViews = ['hour', 'minute', 'second'] as const; 25 | 26 | const iconProps = { 27 | xmlns: 'http://www.w3.org/2000/svg', 28 | width: 19, 29 | height: 19, 30 | viewBox: '0 0 19 19', 31 | stroke: 'black', 32 | strokeWidth: 2, 33 | }; 34 | 35 | const CalendarIcon = ( 36 | 45 | ); 46 | 47 | const ClearIcon = ( 48 | 56 | ); 57 | 58 | type ReactNodeLike = React.ReactNode | string | number | boolean | null | undefined; 59 | 60 | type Icon = ReactNodeLike | ReactNodeLike[]; 61 | 62 | type IconOrRenderFunction = Icon | React.ComponentType | React.ReactElement; 63 | 64 | type CalendarProps = Omit< 65 | React.ComponentPropsWithoutRef, 66 | 'onChange' | 'selectRange' | 'value' 67 | >; 68 | 69 | type ClockProps = Omit, 'value'>; 70 | 71 | type EventProps = ReturnType; 72 | 73 | export type DateTimePickerProps = { 74 | /** 75 | * `aria-label` for the AM/PM select input. 76 | * 77 | * @example 'Select AM/PM' 78 | */ 79 | amPmAriaLabel?: string; 80 | /** 81 | * Automatically focuses the input on mount. 82 | * 83 | * @example true 84 | */ 85 | autoFocus?: boolean; 86 | /** 87 | * `aria-label` for the calendar button. 88 | * 89 | * @example 'Toggle calendar' 90 | */ 91 | calendarAriaLabel?: string; 92 | /** 93 | * Content of the calendar button. Setting the value explicitly to `null` will hide the icon. 94 | * 95 | * @example 'Calendar' 96 | * @example 97 | * @example CalendarIcon 98 | */ 99 | calendarIcon?: IconOrRenderFunction | null; 100 | /** 101 | * Props to pass to React-Calendar component. 102 | */ 103 | calendarProps?: CalendarProps; 104 | /** 105 | * Class name(s) that will be added along with `"react-datetime-picker"` to the main React-DateTime-Picker `
` element. 106 | * 107 | * @example 'class1 class2' 108 | * @example ['class1', 'class2 class3'] 109 | */ 110 | className?: ClassName; 111 | /** 112 | * `aria-label` for the clear button. 113 | * 114 | * @example 'Clear value' 115 | */ 116 | clearAriaLabel?: string; 117 | /** 118 | * Content of the clear button. Setting the value explicitly to `null` will hide the icon. 119 | * 120 | * @example 'Clear' 121 | * @example 122 | * @example ClearIcon 123 | */ 124 | clearIcon?: IconOrRenderFunction | null; 125 | /** 126 | * Props to pass to React-Clock component. 127 | */ 128 | clockProps?: ClockProps; 129 | /** 130 | * Whether to close the widgets on value selection. **Note**: It's recommended to use `shouldCloseWidgets` function instead. 131 | * 132 | * @default true 133 | * @example false 134 | */ 135 | closeWidgets?: boolean; 136 | /** 137 | * `data-testid` attribute for the main React-DateTime-Picker `
` element. 138 | * 139 | * @example 'datetime-picker' 140 | */ 141 | 'data-testid'?: string; 142 | /** 143 | * `aria-label` for the day input. 144 | * 145 | * @example 'Day' 146 | */ 147 | dayAriaLabel?: string; 148 | /** 149 | * `placeholder` for the day input. 150 | * 151 | * @default '--' 152 | * @example 'dd' 153 | */ 154 | dayPlaceholder?: string; 155 | /** 156 | * When set to `true`, will remove the calendar and the button toggling its visibility. 157 | * 158 | * @default false 159 | * @example true 160 | */ 161 | disableCalendar?: boolean; 162 | /** 163 | * When set to `true`, will remove the clock. 164 | * 165 | * @default false 166 | * @example true 167 | */ 168 | disableClock?: boolean; 169 | /** 170 | * Whether the date time picker should be disabled. 171 | * 172 | * @default false 173 | * @example true 174 | */ 175 | disabled?: boolean; 176 | /** 177 | * Input format based on [Unicode Technical Standard #35](https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table). Supported values are: `y`, `M`, `MM`, `MMM`, `MMMM`, `d`, `dd`, `H`, `HH`, `h`, `hh`, `m`, `mm`, `s`, `ss`, `a`. 178 | * 179 | * **Note**: When using SSR, setting this prop may help resolving hydration errors caused by locale mismatch between server and client. 180 | * 181 | * @example 'y-MM-dd h:mm:ss a' 182 | */ 183 | format?: string; 184 | /** 185 | * `aria-label` for the hour input. 186 | * 187 | * @example 'Hour' 188 | */ 189 | hourAriaLabel?: string; 190 | /** 191 | * `placeholder` for the hour input. 192 | * 193 | * @default '--' 194 | * @example 'hh' 195 | */ 196 | hourPlaceholder?: string; 197 | /** 198 | * `id` attribute for the main React-DateTime-Picker `
` element. 199 | * 200 | * @example 'datetime-picker' 201 | */ 202 | id?: string; 203 | /** 204 | * Whether the calendar should be opened. 205 | * 206 | * @default false 207 | * @example true 208 | */ 209 | isCalendarOpen?: boolean; 210 | /** 211 | * Whether the clock should be opened. 212 | * 213 | * @default false 214 | * @example true 215 | */ 216 | isClockOpen?: boolean; 217 | /** 218 | * Locale that should be used by the datetime picker and the calendar. Can be any [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag). 219 | * 220 | * **Note**: When using SSR, setting this prop may help resolving hydration errors caused by locale mismatch between server and client. 221 | * 222 | * @example 'hu-HU' 223 | */ 224 | locale?: string; 225 | /** 226 | * Maximum date that the user can select. Periods partially overlapped by maxDate will also be selectable, although React-DateTime-Picker will ensure that no later date is selected. 227 | * 228 | * @example new Date() 229 | */ 230 | maxDate?: Date; 231 | /** 232 | * The most detailed calendar view that the user shall see. View defined here also becomes the one on which clicking an item in the calendar will select a date and pass it to onChange. Can be `"hour"`, `"minute"` or `"second"`. 233 | * 234 | * Don't need hour picking? Try [React-Date-Picker](https://github.com/wojtekmaj/react-date-picker)! 235 | * 236 | * @default 'minute' 237 | * @example 'second' 238 | */ 239 | maxDetail?: Detail; 240 | /** 241 | * Minimum date that the user can select. Periods partially overlapped by minDate will also be selectable, although React-DateTimeRange-Picker will ensure that no earlier date is selected. 242 | * 243 | * @example new Date() 244 | */ 245 | minDate?: Date; 246 | /** 247 | * `aria-label` for the minute input. 248 | * 249 | * @example 'Minute' 250 | */ 251 | minuteAriaLabel?: string; 252 | /** 253 | * `placeholder` for the minute input. 254 | * 255 | * @default '--' 256 | * @example 'mm' 257 | */ 258 | minutePlaceholder?: string; 259 | /** 260 | * `aria-label` for the month input. 261 | * 262 | * @example 'Month' 263 | */ 264 | monthAriaLabel?: string; 265 | /** 266 | * `placeholder` for the month input. 267 | * 268 | * @default '--' 269 | * @example 'mm' 270 | */ 271 | monthPlaceholder?: string; 272 | /** 273 | * Input name. 274 | * 275 | * @default 'datetime' 276 | */ 277 | name?: string; 278 | /** 279 | * `aria-label` for the native datetime input. 280 | * 281 | * @example 'Date' 282 | */ 283 | nativeInputAriaLabel?: string; 284 | /** 285 | * Function called when the calendar closes. 286 | * 287 | * @example () => alert('Calendar closed') 288 | */ 289 | onCalendarClose?: () => void; 290 | /** 291 | * Function called when the calendar opens. 292 | * 293 | * @example () => alert('Calendar opened') 294 | */ 295 | onCalendarOpen?: () => void; 296 | /** 297 | * Function called when the user picks a valid datetime. If any of the fields were excluded using custom `format`, `new Date(y, 0, 1, 0, 0, 0)`, where `y` is the current year, is going to serve as a "base". 298 | * 299 | * @example (value) => alert('New date is: ', value) 300 | */ 301 | onChange?: (value: Value) => void; 302 | /** 303 | * Function called when the clock closes. 304 | * 305 | * @example () => alert('Clock closed') 306 | */ 307 | onClockClose?: () => void; 308 | /** 309 | * Function called when the clock opens. 310 | * 311 | * @example () => alert('Clock opened') 312 | */ 313 | onClockOpen?: () => void; 314 | /** 315 | * Function called when the user focuses an input. 316 | * 317 | * @example (event) => alert('Focused input: ', event.target.name) 318 | */ 319 | onFocus?: (event: React.FocusEvent) => void; 320 | /** 321 | * Function called when the user picks an invalid datetime. 322 | * 323 | * @example () => alert('Invalid datetime'); 324 | */ 325 | onInvalidChange?: () => void; 326 | /** 327 | * Whether to open the widgets on input focus. 328 | * 329 | * **Note**: It's recommended to use `shouldOpenWidgets` function instead. 330 | * 331 | * @default true 332 | * @example false 333 | */ 334 | openWidgetsOnFocus?: boolean; 335 | /** 336 | * Element to render the widgets in using portal. 337 | * 338 | * @example document.getElementById('my-div') 339 | */ 340 | portalContainer?: HTMLElement | null; 341 | /** 342 | * Whether datetime input should be required. 343 | * 344 | * @default false 345 | * @example true 346 | */ 347 | required?: boolean; 348 | /** 349 | * `aria-label` for the second input. 350 | * 351 | * @example 'Second' 352 | */ 353 | secondAriaLabel?: string; 354 | /** 355 | * `placeholder` for the second input. 356 | * 357 | * @default '--' 358 | * @example 'ss' 359 | */ 360 | secondPlaceholder?: string; 361 | /** 362 | * Function called before the widgets close. `reason` can be `"buttonClick"`, `"escape"`, `"outsideAction"`, or `"select"`. `widget` can be `"calendar"` or `"clock"`. If it returns `false`, the widget will not close. 363 | * 364 | * @example ({ reason, widget }) => reason !== 'outsideAction' && widget === 'calendar'` 365 | */ 366 | shouldCloseWidgets?: (props: { reason: CloseReason; widget: 'calendar' | 'clock' }) => boolean; 367 | /** 368 | * Function called before the widgets open. `reason` can be `"buttonClick"` or `"focus"`. `widget` can be `"calendar"` or `"clock"`. If it returns `false`, the widget will not open. 369 | * 370 | * @example ({ reason, widget }) => reason !== 'focus' && widget === 'calendar'` 371 | */ 372 | shouldOpenWidgets?: (props: { reason: OpenReason; widget: 'calendar' | 'clock' }) => boolean; 373 | /** 374 | * Whether leading zeros should be rendered in datetime inputs. 375 | * 376 | * @default false 377 | * @example true 378 | */ 379 | showLeadingZeros?: boolean; 380 | /** 381 | * Input value. Note that if you pass an array of values, only first value will be fully utilized. 382 | * 383 | * @example new Date(2017, 0, 1, 22, 15) 384 | * @example [new Date(2017, 0, 1, 22, 15), new Date(2017, 0, 1, 23, 45)] 385 | * @example ["2017-01-01T22:15:00", "2017-01-01T23:45:00"] 386 | */ 387 | value?: LooseValue; 388 | /** 389 | * `aria-label` for the year input. 390 | * 391 | * @example 'Year' 392 | */ 393 | yearAriaLabel?: string; 394 | /** 395 | * `placeholder` for the year input. 396 | * 397 | * @default '----' 398 | * @example 'yyyy' 399 | */ 400 | yearPlaceholder?: string; 401 | } & Omit; 402 | 403 | export default function DateTimePicker(props: DateTimePickerProps): React.ReactElement { 404 | const { 405 | amPmAriaLabel, 406 | autoFocus, 407 | calendarAriaLabel, 408 | calendarIcon = CalendarIcon, 409 | className, 410 | clearAriaLabel, 411 | clearIcon = ClearIcon, 412 | closeWidgets: shouldCloseWidgetsOnSelect = true, 413 | 'data-testid': dataTestid, 414 | dayAriaLabel, 415 | dayPlaceholder, 416 | disableCalendar, 417 | disableClock, 418 | disabled, 419 | format, 420 | hourAriaLabel, 421 | hourPlaceholder, 422 | id, 423 | isCalendarOpen: isCalendarOpenProps = null, 424 | isClockOpen: isClockOpenProps = null, 425 | locale, 426 | maxDate, 427 | maxDetail = 'minute', 428 | minDate, 429 | minuteAriaLabel, 430 | minutePlaceholder, 431 | monthAriaLabel, 432 | monthPlaceholder, 433 | name = 'datetime', 434 | nativeInputAriaLabel, 435 | onCalendarClose, 436 | onCalendarOpen, 437 | onChange: onChangeProps, 438 | onClockClose, 439 | onClockOpen, 440 | onFocus: onFocusProps, 441 | onInvalidChange, 442 | openWidgetsOnFocus = true, 443 | required, 444 | secondAriaLabel, 445 | secondPlaceholder, 446 | shouldCloseWidgets, 447 | shouldOpenWidgets, 448 | showLeadingZeros, 449 | value, 450 | yearAriaLabel, 451 | yearPlaceholder, 452 | ...otherProps 453 | } = props; 454 | 455 | const [isCalendarOpen, setIsCalendarOpen] = useState(isCalendarOpenProps); 456 | const [isClockOpen, setIsClockOpen] = useState(isClockOpenProps); 457 | const wrapper = useRef(null); 458 | const calendarWrapper = useRef(null); 459 | const clockWrapper = useRef(null); 460 | 461 | useEffect(() => { 462 | setIsCalendarOpen(isCalendarOpenProps); 463 | }, [isCalendarOpenProps]); 464 | 465 | useEffect(() => { 466 | setIsClockOpen(isClockOpenProps); 467 | }, [isClockOpenProps]); 468 | 469 | function openCalendar({ reason }: { reason: OpenReason }) { 470 | if (shouldOpenWidgets) { 471 | if (!shouldOpenWidgets({ reason, widget: 'calendar' })) { 472 | return; 473 | } 474 | } 475 | 476 | setIsClockOpen(isClockOpen ? false : isClockOpen); 477 | setIsCalendarOpen(true); 478 | 479 | if (onCalendarOpen) { 480 | onCalendarOpen(); 481 | } 482 | } 483 | 484 | const closeCalendar = useCallback( 485 | ({ reason }: { reason: CloseReason }) => { 486 | if (shouldCloseWidgets) { 487 | if (!shouldCloseWidgets({ reason, widget: 'calendar' })) { 488 | return; 489 | } 490 | } 491 | 492 | setIsCalendarOpen(false); 493 | 494 | if (onCalendarClose) { 495 | onCalendarClose(); 496 | } 497 | }, 498 | [onCalendarClose, shouldCloseWidgets], 499 | ); 500 | 501 | function toggleCalendar() { 502 | if (isCalendarOpen) { 503 | closeCalendar({ reason: 'buttonClick' }); 504 | } else { 505 | openCalendar({ reason: 'buttonClick' }); 506 | } 507 | } 508 | 509 | function openClock({ reason }: { reason: OpenReason }) { 510 | if (shouldOpenWidgets) { 511 | if (!shouldOpenWidgets({ reason, widget: 'clock' })) { 512 | return; 513 | } 514 | } 515 | 516 | setIsCalendarOpen(isCalendarOpen ? false : isCalendarOpen); 517 | setIsClockOpen(true); 518 | 519 | if (onClockOpen) { 520 | onClockOpen(); 521 | } 522 | } 523 | 524 | const closeClock = useCallback( 525 | ({ reason }: { reason: CloseReason }) => { 526 | if (shouldCloseWidgets) { 527 | if (!shouldCloseWidgets({ reason, widget: 'clock' })) { 528 | return; 529 | } 530 | } 531 | 532 | setIsClockOpen(false); 533 | 534 | if (onClockClose) { 535 | onClockClose(); 536 | } 537 | }, 538 | [onClockClose, shouldCloseWidgets], 539 | ); 540 | 541 | const closeWidgets = useCallback( 542 | ({ reason }: { reason: CloseReason }) => { 543 | closeCalendar({ reason }); 544 | closeClock({ reason }); 545 | }, 546 | [closeCalendar, closeClock], 547 | ); 548 | 549 | function onChange(value: Value, shouldCloseWidgets: boolean = shouldCloseWidgetsOnSelect) { 550 | if (shouldCloseWidgets) { 551 | closeWidgets({ reason: 'select' }); 552 | } 553 | 554 | if (onChangeProps) { 555 | onChangeProps(value); 556 | } 557 | } 558 | 559 | type DatePiece = Date | null; 560 | 561 | function onDateChange( 562 | nextValue: DatePiece | [DatePiece, DatePiece], 563 | shouldCloseWidgets?: boolean, 564 | ) { 565 | // React-Calendar passes an array of values when selectRange is enabled 566 | const [nextValueFrom] = Array.isArray(nextValue) ? nextValue : [nextValue]; 567 | const [valueFrom] = Array.isArray(value) ? value : [value]; 568 | 569 | if (valueFrom && nextValueFrom) { 570 | const valueFromDate = new Date(valueFrom); 571 | const nextValueFromWithHour = new Date(nextValueFrom); 572 | nextValueFromWithHour.setHours( 573 | valueFromDate.getHours(), 574 | valueFromDate.getMinutes(), 575 | valueFromDate.getSeconds(), 576 | valueFromDate.getMilliseconds(), 577 | ); 578 | 579 | onChange(nextValueFromWithHour, shouldCloseWidgets); 580 | } else { 581 | onChange(nextValueFrom, shouldCloseWidgets); 582 | } 583 | } 584 | 585 | function onFocus(event: React.FocusEvent) { 586 | if (onFocusProps) { 587 | onFocusProps(event); 588 | } 589 | 590 | if ( 591 | // Internet Explorer still fires onFocus on disabled elements 592 | disabled || 593 | !openWidgetsOnFocus || 594 | event.target.dataset.select === 'true' 595 | ) { 596 | return; 597 | } 598 | 599 | switch (event.target.name) { 600 | case 'day': 601 | case 'month': 602 | case 'year': { 603 | if (isCalendarOpen) { 604 | return; 605 | } 606 | 607 | openCalendar({ reason: 'focus' }); 608 | break; 609 | } 610 | case 'hour12': 611 | case 'hour24': 612 | case 'minute': 613 | case 'second': { 614 | if (isClockOpen) { 615 | return; 616 | } 617 | 618 | openClock({ reason: 'focus' }); 619 | break; 620 | } 621 | default: 622 | } 623 | } 624 | 625 | const onKeyDown = useCallback( 626 | (event: KeyboardEvent) => { 627 | if (event.key === 'Escape') { 628 | closeWidgets({ reason: 'escape' }); 629 | } 630 | }, 631 | [closeWidgets], 632 | ); 633 | 634 | function clear() { 635 | onChange(null); 636 | } 637 | 638 | function stopPropagation(event: React.FocusEvent) { 639 | event.stopPropagation(); 640 | } 641 | 642 | const onOutsideAction = useCallback( 643 | (event: Event) => { 644 | const { current: wrapperEl } = wrapper; 645 | const { current: calendarWrapperEl } = calendarWrapper; 646 | const { current: clockWrapperEl } = clockWrapper; 647 | 648 | // Try event.composedPath first to handle clicks inside a Shadow DOM. 649 | const target = ( 650 | 'composedPath' in event ? event.composedPath()[0] : (event as Event).target 651 | ) as HTMLElement; 652 | 653 | if ( 654 | target && 655 | wrapperEl && 656 | !wrapperEl.contains(target) && 657 | (!calendarWrapperEl || !calendarWrapperEl.contains(target)) && 658 | (!clockWrapperEl || !clockWrapperEl.contains(target)) 659 | ) { 660 | closeWidgets({ reason: 'outsideAction' }); 661 | } 662 | }, 663 | [closeWidgets], 664 | ); 665 | 666 | const handleOutsideActionListeners = useCallback( 667 | (shouldListen = isCalendarOpen || isClockOpen) => { 668 | for (const event of outsideActionEvents) { 669 | if (shouldListen) { 670 | document.addEventListener(event, onOutsideAction); 671 | } else { 672 | document.removeEventListener(event, onOutsideAction); 673 | } 674 | } 675 | 676 | if (shouldListen) { 677 | document.addEventListener('keydown', onKeyDown); 678 | } else { 679 | document.removeEventListener('keydown', onKeyDown); 680 | } 681 | }, 682 | [isCalendarOpen, isClockOpen, onOutsideAction, onKeyDown], 683 | ); 684 | 685 | useEffect(() => { 686 | handleOutsideActionListeners(); 687 | 688 | return () => { 689 | handleOutsideActionListeners(false); 690 | }; 691 | }, [handleOutsideActionListeners]); 692 | 693 | function renderInputs() { 694 | const [valueFrom] = Array.isArray(value) ? value : [value]; 695 | 696 | const ariaLabelProps = { 697 | amPmAriaLabel, 698 | dayAriaLabel, 699 | hourAriaLabel, 700 | minuteAriaLabel, 701 | monthAriaLabel, 702 | nativeInputAriaLabel, 703 | secondAriaLabel, 704 | yearAriaLabel, 705 | }; 706 | 707 | const placeholderProps = { 708 | dayPlaceholder, 709 | hourPlaceholder, 710 | minutePlaceholder, 711 | monthPlaceholder, 712 | secondPlaceholder, 713 | yearPlaceholder, 714 | }; 715 | 716 | return ( 717 |
718 | 737 | {clearIcon !== null && ( 738 | 748 | )} 749 | {calendarIcon !== null && !disableCalendar && ( 750 | 761 | )} 762 |
763 | ); 764 | } 765 | 766 | function renderCalendar() { 767 | if (isCalendarOpen === null || disableCalendar) { 768 | return null; 769 | } 770 | 771 | const { calendarProps, portalContainer, value } = props; 772 | 773 | const className = `${baseClassName}__calendar`; 774 | const classNames = clsx(className, `${className}--${isCalendarOpen ? 'open' : 'closed'}`); 775 | 776 | const calendar = ( 777 | onDateChange(value)} 782 | value={value} 783 | {...calendarProps} 784 | /> 785 | ); 786 | 787 | return portalContainer ? ( 788 | createPortal( 789 |
790 | {calendar} 791 |
, 792 | portalContainer, 793 | ) 794 | ) : ( 795 | 796 |
{ 798 | if (ref && !isCalendarOpen) { 799 | ref.removeAttribute('style'); 800 | } 801 | }} 802 | className={classNames} 803 | > 804 | {calendar} 805 |
806 |
807 | ); 808 | } 809 | 810 | function renderClock() { 811 | if (isClockOpen === null || disableClock) { 812 | return null; 813 | } 814 | 815 | const { clockProps, maxDetail = 'minute', portalContainer, value } = props; 816 | 817 | const className = `${baseClassName}__clock`; 818 | const classNames = clsx(className, `${className}--${isClockOpen ? 'open' : 'closed'}`); 819 | 820 | const [valueFrom] = Array.isArray(value) ? value : [value]; 821 | 822 | const maxDetailIndex = allViews.indexOf(maxDetail); 823 | 824 | const clock = ( 825 | 0} 828 | renderSecondHand={maxDetailIndex > 1} 829 | value={valueFrom} 830 | {...clockProps} 831 | /> 832 | ); 833 | 834 | return portalContainer ? ( 835 | createPortal( 836 |
837 | {clock} 838 |
, 839 | portalContainer, 840 | ) 841 | ) : ( 842 | 843 |
{ 845 | if (ref && !isClockOpen) { 846 | ref.removeAttribute('style'); 847 | } 848 | }} 849 | className={classNames} 850 | > 851 | {clock} 852 |
853 |
854 | ); 855 | } 856 | 857 | const eventProps = useMemo( 858 | () => makeEventProps(otherProps), 859 | // biome-ignore lint/correctness/useExhaustiveDependencies: FIXME 860 | [otherProps], 861 | ); 862 | 863 | return ( 864 |
877 | {renderInputs()} 878 | {renderCalendar()} 879 | {renderClock()} 880 |
881 | ); 882 | } 883 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/Divider.tsx: -------------------------------------------------------------------------------- 1 | type DividerProps = { 2 | children?: React.ReactNode; 3 | }; 4 | 5 | export default function Divider({ children }: DividerProps): React.ReactElement { 6 | return {children}; 7 | } 8 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/index.ts: -------------------------------------------------------------------------------- 1 | import DateTimePicker from './DateTimePicker.js'; 2 | 3 | export type { DateTimePickerProps } from './DateTimePicker.js'; 4 | 5 | export { DateTimePicker }; 6 | 7 | export default DateTimePicker; 8 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/shared/dateFormatter.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { formatDate } from './dateFormatter.js'; 3 | 4 | describe('formatDate', () => { 5 | it('returns proper full numeric date', () => { 6 | const date = new Date(2017, 1, 1); 7 | 8 | const formattedDate = formatDate('en-US', date); 9 | 10 | expect(formattedDate).toBe('2/1/2017'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/shared/dateFormatter.ts: -------------------------------------------------------------------------------- 1 | import getUserLocale from 'get-user-locale'; 2 | 3 | const formatterCache = new Map(); 4 | 5 | export function getFormatter( 6 | options: Intl.DateTimeFormatOptions, 7 | ): (locale: string | undefined, date: Date) => string { 8 | return function formatter(locale: string | undefined, date: Date): string { 9 | const localeWithDefault = locale || getUserLocale(); 10 | 11 | if (!formatterCache.has(localeWithDefault)) { 12 | formatterCache.set(localeWithDefault, new Map()); 13 | } 14 | 15 | const formatterCacheLocale = formatterCache.get(localeWithDefault); 16 | 17 | if (!formatterCacheLocale.has(options)) { 18 | formatterCacheLocale.set( 19 | options, 20 | new Intl.DateTimeFormat(localeWithDefault || undefined, options).format, 21 | ); 22 | } 23 | 24 | return formatterCacheLocale.get(options)(date); 25 | }; 26 | } 27 | 28 | const numberFormatterCache = new Map(); 29 | 30 | export function getNumberFormatter( 31 | options: Intl.NumberFormatOptions, 32 | ): (locale: string | undefined, number: number) => string { 33 | return (locale: string | undefined, number: number): string => { 34 | const localeWithDefault = locale || getUserLocale(); 35 | 36 | if (!numberFormatterCache.has(localeWithDefault)) { 37 | numberFormatterCache.set(localeWithDefault, new Map()); 38 | } 39 | 40 | const numberFormatterCacheLocale = numberFormatterCache.get(localeWithDefault); 41 | 42 | if (!numberFormatterCacheLocale.has(options)) { 43 | numberFormatterCacheLocale.set( 44 | options, 45 | new Intl.NumberFormat(localeWithDefault || undefined, options).format, 46 | ); 47 | } 48 | 49 | return numberFormatterCacheLocale.get(options)(number); 50 | }; 51 | } 52 | 53 | const formatDateOptions = { 54 | day: 'numeric', 55 | month: 'numeric', 56 | year: 'numeric', 57 | } satisfies Intl.DateTimeFormatOptions; 58 | 59 | export const formatDate: (locale: string | undefined, date: Date) => string = 60 | getFormatter(formatDateOptions); 61 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/shared/dates.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { convert12to24, convert24to12 } from './dates.js'; 3 | 4 | describe('convert12to24', () => { 5 | it.each` 6 | hour12 | amPm | hour24 7 | ${12} | ${'am'} | ${0} 8 | ${1} | ${'am'} | ${1} 9 | ${2} | ${'am'} | ${2} 10 | ${3} | ${'am'} | ${3} 11 | ${4} | ${'am'} | ${4} 12 | ${5} | ${'am'} | ${5} 13 | ${6} | ${'am'} | ${6} 14 | ${7} | ${'am'} | ${7} 15 | ${8} | ${'am'} | ${8} 16 | ${9} | ${'am'} | ${9} 17 | ${10} | ${'am'} | ${10} 18 | ${11} | ${'am'} | ${11} 19 | ${12} | ${'pm'} | ${12} 20 | ${1} | ${'pm'} | ${13} 21 | ${2} | ${'pm'} | ${14} 22 | ${3} | ${'pm'} | ${15} 23 | ${4} | ${'pm'} | ${16} 24 | ${5} | ${'pm'} | ${17} 25 | ${6} | ${'pm'} | ${18} 26 | ${7} | ${'pm'} | ${19} 27 | ${8} | ${'pm'} | ${20} 28 | ${9} | ${'pm'} | ${21} 29 | ${10} | ${'pm'} | ${22} 30 | ${11} | ${'pm'} | ${23} 31 | `('returns $hour24 for $hour12 $amPm', ({ hour12, amPm, hour24 }) => { 32 | expect(convert12to24(hour12, amPm)).toBe(hour24); 33 | }); 34 | }); 35 | 36 | describe('convert24to12', () => { 37 | it.each` 38 | hour24 | hour12 | amPm 39 | ${0} | ${12} | ${'am'} 40 | ${1} | ${1} | ${'am'} 41 | ${2} | ${2} | ${'am'} 42 | ${3} | ${3} | ${'am'} 43 | ${4} | ${4} | ${'am'} 44 | ${5} | ${5} | ${'am'} 45 | ${6} | ${6} | ${'am'} 46 | ${7} | ${7} | ${'am'} 47 | ${8} | ${8} | ${'am'} 48 | ${9} | ${9} | ${'am'} 49 | ${10} | ${10} | ${'am'} 50 | ${11} | ${11} | ${'am'} 51 | ${12} | ${12} | ${'pm'} 52 | ${13} | ${1} | ${'pm'} 53 | ${14} | ${2} | ${'pm'} 54 | ${15} | ${3} | ${'pm'} 55 | ${16} | ${4} | ${'pm'} 56 | ${17} | ${5} | ${'pm'} 57 | ${18} | ${6} | ${'pm'} 58 | ${19} | ${7} | ${'pm'} 59 | ${20} | ${8} | ${'pm'} 60 | ${21} | ${9} | ${'pm'} 61 | ${22} | ${10} | ${'pm'} 62 | ${23} | ${11} | ${'pm'} 63 | `('returns $hour12 $amPm for $hour24', ({ hour24, hour12, amPm }) => { 64 | expect(convert24to12(hour24)).toEqual([hour12, amPm]); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/shared/dates.ts: -------------------------------------------------------------------------------- 1 | import type { AmPmType } from './types.js'; 2 | 3 | export function convert12to24(hour12: string | number, amPm: AmPmType): number { 4 | let hour24 = Number(hour12); 5 | 6 | if (amPm === 'am' && hour24 === 12) { 7 | hour24 = 0; 8 | } else if (amPm === 'pm' && hour24 < 12) { 9 | hour24 += 12; 10 | } 11 | 12 | return hour24; 13 | } 14 | 15 | export function convert24to12(hour24: string | number): [number, AmPmType] { 16 | const hour12 = Number(hour24) % 12 || 12; 17 | 18 | return [hour12, Number(hour24) < 12 ? 'am' : 'pm']; 19 | } 20 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/shared/types.ts: -------------------------------------------------------------------------------- 1 | export type Range = [T, T]; 2 | 3 | export type AmPmType = 'am' | 'pm'; 4 | 5 | export type ClassName = string | null | undefined | (string | null | undefined)[]; 6 | 7 | export type CloseReason = 'buttonClick' | 'escape' | 'outsideAction' | 'select'; 8 | 9 | export type Detail = 'hour' | 'minute' | 'second'; 10 | 11 | export type LooseValuePiece = string | Date | null; 12 | 13 | export type LooseValue = LooseValuePiece | Range; 14 | 15 | export type OpenReason = 'buttonClick' | 'focus'; 16 | 17 | export type Value = Date | null; 18 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/shared/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { between } from './utils.js'; 3 | 4 | describe('between', () => { 5 | it('returns value when value is within set boundaries', () => { 6 | const value = new Date(2017, 6, 1); 7 | const min = new Date(2017, 0, 1); 8 | const max = new Date(2017, 11, 1); 9 | const result = between(value, min, max); 10 | 11 | expect(result).toBe(value); 12 | }); 13 | 14 | it('returns min when value is smaller than min', () => { 15 | const value = new Date(2017, 0, 1); 16 | const min = new Date(2017, 6, 1); 17 | const max = new Date(2017, 11, 1); 18 | const result = between(value, min, max); 19 | 20 | expect(result).toBe(min); 21 | }); 22 | 23 | it('returns max when value is larger than max', () => { 24 | const value = new Date(2017, 11, 1); 25 | const min = new Date(2017, 0, 1); 26 | const max = new Date(2017, 6, 1); 27 | const result = between(value, min, max); 28 | 29 | expect(result).toBe(max); 30 | }); 31 | 32 | it('returns value when min and max are not provided', () => { 33 | const value = new Date(2017, 6, 1); 34 | const result = between(value, null, undefined); 35 | 36 | expect(result).toBe(value); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/src/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { getFormatter } from './dateFormatter.js'; 2 | 3 | /** 4 | * Returns a value no smaller than min and no larger than max. 5 | * 6 | * @param {Date} value Value to return. 7 | * @param {Date} min Minimum return value. 8 | * @param {Date} max Maximum return value. 9 | * @returns {Date} Value between min and max. 10 | */ 11 | export function between(value: T, min?: T | null, max?: T | null): T { 12 | if (min && min > value) { 13 | return min; 14 | } 15 | 16 | if (max && max < value) { 17 | return max; 18 | } 19 | 20 | return value; 21 | } 22 | 23 | const nines = ['9', '٩']; 24 | const ninesRegExp = new RegExp(`[${nines.join('')}]`); 25 | const amPmFormatter = getFormatter({ hour: 'numeric' }); 26 | 27 | export function getAmPmLabels(locale: string | undefined): [string, string] { 28 | const amString = amPmFormatter(locale, new Date(2017, 0, 1, 9)); 29 | const pmString = amPmFormatter(locale, new Date(2017, 0, 1, 21)); 30 | 31 | const [am1, am2] = amString.split(ninesRegExp) as [string, string]; 32 | const [pm1, pm2] = pmString.split(ninesRegExp) as [string, string]; 33 | 34 | if (pm2 !== undefined) { 35 | // If pm2 is undefined, nine was not found in pmString - this locale is not using 12-hour time 36 | if (am1 !== pm1) { 37 | return [am1, pm1].map((el) => el.trim()) as [string, string]; 38 | } 39 | 40 | if (am2 !== pm2) { 41 | return [am2, pm2].map((el) => el.trim()) as [string, string]; 42 | } 43 | } 44 | 45 | // Fallback 46 | return ['AM', 'PM']; 47 | } 48 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "dist", 6 | "rootDir": "src" 7 | }, 8 | "include": ["src"], 9 | "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "esModuleInterop": true, 5 | "isolatedDeclarations": true, 6 | "isolatedModules": true, 7 | "jsx": "react-jsx", 8 | "module": "nodenext", 9 | "moduleDetection": "force", 10 | "noEmit": true, 11 | "noUncheckedIndexedAccess": true, 12 | "outDir": "dist", 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "target": "es5", 16 | "verbatimModuleSyntax": true 17 | }, 18 | "exclude": ["dist"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: 'happy-dom', 6 | server: { 7 | deps: { 8 | inline: ['vitest-canvas-mock'], 9 | }, 10 | }, 11 | setupFiles: 'vitest.setup.ts', 12 | watch: false, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /packages/react-datetime-picker/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { afterEach, beforeEach } from 'vitest'; 2 | import { cleanup } from '@testing-library/react'; 3 | import '@testing-library/jest-dom/vitest'; 4 | import 'vitest-canvas-mock'; 5 | 6 | // Workaround for a bug in Vitest 3 or happy-dom 7 | const IntlNumberFormat = Intl.NumberFormat; 8 | 9 | beforeEach(() => { 10 | Intl.NumberFormat = IntlNumberFormat; 11 | }); 12 | 13 | afterEach(() => { 14 | cleanup(); 15 | }); 16 | -------------------------------------------------------------------------------- /sample/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /sample/Sample.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | font-family: Segoe UI, Tahoma, sans-serif; 9 | } 10 | 11 | .Sample input, 12 | .Sample button { 13 | font: inherit; 14 | } 15 | 16 | .Sample header { 17 | background-color: #323639; 18 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.5); 19 | padding: 20px; 20 | color: white; 21 | } 22 | 23 | .Sample header h1 { 24 | font-size: inherit; 25 | margin: 0; 26 | } 27 | 28 | .Sample__container { 29 | display: flex; 30 | flex-direction: row; 31 | flex-wrap: wrap; 32 | align-items: flex-start; 33 | margin: 10px 0; 34 | padding: 10px; 35 | } 36 | 37 | .Sample__container > * > * { 38 | margin: 10px; 39 | } 40 | 41 | .Sample__container__content { 42 | display: flex; 43 | max-width: 100%; 44 | flex-basis: 420px; 45 | flex-direction: column; 46 | flex-grow: 100; 47 | align-items: stretch; 48 | } 49 | -------------------------------------------------------------------------------- /sample/Sample.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import DateTimePicker from 'react-datetime-picker'; 3 | 4 | import './Sample.css'; 5 | 6 | type ValuePiece = Date | null; 7 | 8 | type Value = ValuePiece | [ValuePiece, ValuePiece]; 9 | 10 | export default function Sample() { 11 | const [value, onChange] = useState(new Date()); 12 | 13 | return ( 14 |
15 |
16 |

react-datetime-picker sample page

17 |
18 |
19 |
20 | 35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | react-datetime-picker sample page 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /sample/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | 3 | import Sample from './Sample.js'; 4 | 5 | const root = document.getElementById('root'); 6 | 7 | if (!root) { 8 | throw new Error('Could not find root element'); 9 | } 10 | 11 | createRoot(root).render(); 12 | -------------------------------------------------------------------------------- /sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-datetime-picker-sample-page", 3 | "version": "3.0.0", 4 | "description": "A sample page for React-DateTime-Picker.", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "build": "vite build", 9 | "dev": "vite", 10 | "preview": "vite preview" 11 | }, 12 | "author": { 13 | "name": "Wojciech Maj", 14 | "email": "kontakt@wojtekmaj.pl" 15 | }, 16 | "license": "MIT", 17 | "dependencies": { 18 | "react": "^18.2.0", 19 | "react-datetime-picker": "latest", 20 | "react-dom": "^18.2.0" 21 | }, 22 | "devDependencies": { 23 | "@vitejs/plugin-react": "^4.3.4", 24 | "typescript": "^5.0.0", 25 | "vite": "^6.2.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /sample/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "isolatedModules": true, 4 | "jsx": "react-jsx", 5 | "module": "preserve", 6 | "moduleDetection": "force", 7 | "noEmit": true, 8 | "noUncheckedIndexedAccess": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "target": "esnext", 12 | "verbatimModuleSyntax": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /sample/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | base: './', 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /test-utils.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export function muteConsole() { 4 | vi.spyOn(globalThis.console, 'log').mockImplementation(() => { 5 | // Intentionally empty 6 | }); 7 | vi.spyOn(globalThis.console, 'error').mockImplementation(() => { 8 | // Intentionally empty 9 | }); 10 | vi.spyOn(globalThis.console, 'warn').mockImplementation(() => { 11 | // Intentionally empty 12 | }); 13 | } 14 | 15 | export function restoreConsole() { 16 | vi.mocked(globalThis.console.log).mockRestore(); 17 | vi.mocked(globalThis.console.error).mockRestore(); 18 | vi.mocked(globalThis.console.warn).mockRestore(); 19 | } 20 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /test/LocaleOptions.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | type LocaleOptionsProps = { 4 | locale: string | undefined; 5 | setLocale: (locale: string | undefined) => void; 6 | }; 7 | 8 | export default function LocaleOptions({ locale, setLocale }: LocaleOptionsProps) { 9 | const customLocale = useRef(null); 10 | 11 | function onChange(event: React.ChangeEvent) { 12 | const { value: nextLocale } = event.target; 13 | 14 | if (nextLocale === 'undefined') { 15 | setLocale(undefined); 16 | } else { 17 | setLocale(nextLocale); 18 | } 19 | } 20 | 21 | function onCustomChange(event: React.FormEvent) { 22 | event.preventDefault(); 23 | 24 | const input = customLocale.current; 25 | const { value: nextLocale } = input as HTMLInputElement; 26 | 27 | setLocale(nextLocale); 28 | } 29 | 30 | function resetLocale() { 31 | setLocale(undefined); 32 | } 33 | 34 | return ( 35 |
36 | Locale 37 | 38 |
39 | 47 | 48 |
49 |
50 | 58 | 59 |
60 |
61 | 69 | 70 |
71 |
72 | 80 | 81 |
82 |
83 | 84 |   85 | 94 |   95 | 98 | 101 |
102 |
103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /test/MaxDetailOptions.tsx: -------------------------------------------------------------------------------- 1 | import type { Detail } from './shared/types.js'; 2 | 3 | const allViews = ['hour', 'minute', 'second'] as const; 4 | 5 | function upperCaseFirstLetter(str: string) { 6 | return str.slice(0, 1).toUpperCase() + str.slice(1); 7 | } 8 | 9 | type MaxDetailOptionsProps = { 10 | maxDetail: Detail; 11 | setMaxDetail: (maxDetail: Detail) => void; 12 | }; 13 | 14 | export default function MaxDetailOptions({ maxDetail, setMaxDetail }: MaxDetailOptionsProps) { 15 | function onChange(event: React.ChangeEvent) { 16 | const { value } = event.target; 17 | 18 | setMaxDetail(value as Detail); 19 | } 20 | 21 | return ( 22 |
23 | Maximum detail 24 | 25 | {allViews.map((view) => ( 26 |
27 | 35 | {/* biome-ignore lint/a11y/noLabelWithoutControl: Pinky promise this label won't ever be empty */} 36 | 37 |
38 | ))} 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /test/Test.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: Segoe UI, Tahoma, sans-serif; 4 | } 5 | 6 | .Test header { 7 | background-color: #323639; 8 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.5); 9 | padding: 20px; 10 | color: white; 11 | } 12 | 13 | .Test header h1 { 14 | font-size: inherit; 15 | margin: 0; 16 | } 17 | 18 | .Test__container { 19 | display: flex; 20 | flex-direction: row; 21 | flex-wrap: wrap; 22 | align-items: flex-start; 23 | margin: 10px 0; 24 | padding: 10px; 25 | } 26 | 27 | .Test__container > * > * { 28 | margin: 10px; 29 | } 30 | 31 | .Test__container__options { 32 | display: flex; 33 | flex-basis: 400px; 34 | flex-grow: 1; 35 | flex-wrap: wrap; 36 | margin: 0; 37 | } 38 | 39 | .Test__container__options input, 40 | .Test__container__options button { 41 | font: inherit; 42 | } 43 | 44 | .Test__container__options fieldset { 45 | border: 1px solid black; 46 | flex-grow: 1; 47 | position: relative; 48 | top: -10px; 49 | } 50 | 51 | .Test__container__options fieldset legend { 52 | font-weight: 600; 53 | } 54 | 55 | .Test__container__options fieldset legend + * { 56 | margin-top: 0 !important; 57 | } 58 | 59 | .Test__container__options fieldset label { 60 | font-weight: 600; 61 | display: block; 62 | } 63 | 64 | .Test__container__options fieldset label:not(:first-of-type) { 65 | margin-top: 1em; 66 | } 67 | 68 | .Test__container__options fieldset input[type='checkbox'] + label, 69 | .Test__container__options fieldset input[type='radio'] + label { 70 | font-weight: normal; 71 | display: inline-block; 72 | margin: 0; 73 | } 74 | 75 | .Test__container__options fieldset form:not(:first-child), 76 | .Test__container__options fieldset div:not(:first-child) { 77 | margin-top: 1em; 78 | } 79 | 80 | .Test__container__options fieldset form:not(:last-child), 81 | .Test__container__options fieldset div:not(:last-child) { 82 | margin-bottom: 1em; 83 | } 84 | 85 | .Test__container__content { 86 | display: flex; 87 | max-width: 100%; 88 | flex-basis: 420px; 89 | flex-direction: column; 90 | flex-grow: 100; 91 | align-items: stretch; 92 | } 93 | -------------------------------------------------------------------------------- /test/Test.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | import DateTimePicker from 'react-datetime-picker'; 3 | import 'react-datetime-picker/dist/DateTimePicker.css'; 4 | import 'react-calendar/dist/Calendar.css'; 5 | import 'react-clock/dist/Clock.css'; 6 | 7 | import ValidityOptions from './ValidityOptions.js'; 8 | import MaxDetailOptions from './MaxDetailOptions.js'; 9 | import LocaleOptions from './LocaleOptions.js'; 10 | import ValueOptions from './ValueOptions.js'; 11 | import ViewOptions from './ViewOptions.js'; 12 | 13 | import './Test.css'; 14 | 15 | import type { Detail, LooseValue } from './shared/types.js'; 16 | 17 | const now = new Date(); 18 | 19 | const ariaLabelProps = { 20 | amPmAriaLabel: 'Select AM/PM', 21 | calendarAriaLabel: 'Toggle calendar', 22 | clearAriaLabel: 'Clear value', 23 | dayAriaLabel: 'Day', 24 | hourAriaLabel: 'Hour', 25 | minuteAriaLabel: 'Minute', 26 | monthAriaLabel: 'Month', 27 | nativeInputAriaLabel: 'Date and time', 28 | secondAriaLabel: 'Second', 29 | yearAriaLabel: 'Year', 30 | }; 31 | 32 | const placeholderProps = { 33 | dayPlaceholder: 'dd', 34 | hourPlaceholder: 'hh', 35 | minutePlaceholder: 'mm', 36 | monthPlaceholder: 'mm', 37 | secondPlaceholder: 'ss', 38 | yearPlaceholder: 'yyyy', 39 | }; 40 | 41 | const nineteenNinetyFive = new Date(1995, now.getUTCMonth() + 1, 15, 12); 42 | const fifteenthOfNextMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 15, 12); 43 | 44 | export default function Test() { 45 | const portalContainer = useRef(null); 46 | const [disabled, setDisabled] = useState(false); 47 | const [locale, setLocale] = useState(); 48 | const [maxDate, setMaxDate] = useState(fifteenthOfNextMonth); 49 | const [maxDetail, setMaxDetail] = useState('minute'); 50 | const [minDate, setMinDate] = useState(nineteenNinetyFive); 51 | const [renderInPortal, setRenderInPortal] = useState(false); 52 | const [required, setRequired] = useState(true); 53 | const [showLeadingZeros, setShowLeadingZeros] = useState(true); 54 | const [showNeighboringMonth, setShowNeighboringMonth] = useState(false); 55 | const [showWeekNumbers, setShowWeekNumbers] = useState(false); 56 | const [value, setValue] = useState(now); 57 | 58 | return ( 59 |
60 |
61 |

react-datetime-picker test page

62 |
63 |
64 | 89 |
90 |
{ 92 | event.preventDefault(); 93 | 94 | console.warn('DateTimePicker triggered submitting the form.'); 95 | console.log(event); 96 | }} 97 | > 98 | console.log('Calendar closed')} 116 | onCalendarOpen={() => console.log('Calendar opened')} 117 | onChange={setValue} 118 | onClockClose={() => console.log('Clock closed')} 119 | onClockOpen={() => console.log('Clock opened')} 120 | portalContainer={renderInPortal ? portalContainer.current : undefined} 121 | required={required} 122 | showLeadingZeros={showLeadingZeros} 123 | value={value} 124 | /> 125 |
126 |
127 |
128 | 131 | 132 |
133 |
134 |
135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /test/ValidityOptions.tsx: -------------------------------------------------------------------------------- 1 | import { getISOLocalDate } from '@wojtekmaj/date-utils'; 2 | 3 | type ValidityOptionsProps = { 4 | maxDate?: Date; 5 | minDate?: Date; 6 | required?: boolean; 7 | setMaxDate: (maxDate: Date | undefined) => void; 8 | setMinDate: (minDate: Date | undefined) => void; 9 | setRequired: (required: boolean) => void; 10 | }; 11 | 12 | export default function ValidityOptions({ 13 | maxDate, 14 | minDate, 15 | required, 16 | setMaxDate, 17 | setMinDate, 18 | setRequired, 19 | }: ValidityOptionsProps) { 20 | function onMinChange(event: React.ChangeEvent) { 21 | const { value } = event.target; 22 | 23 | setMinDate(value ? new Date(value) : undefined); 24 | } 25 | 26 | function onMaxChange(event: React.ChangeEvent) { 27 | const { value } = event.target; 28 | 29 | setMaxDate(value ? new Date(value) : undefined); 30 | } 31 | 32 | return ( 33 |
34 | Minimum and maximum date 35 | 36 |
37 | 38 | 44 |   45 | 48 |
49 | 50 |
51 | 52 | 58 |   59 | 62 |
63 | 64 |
65 | setRequired(event.target.checked)} 69 | type="checkbox" 70 | /> 71 | 72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /test/ValueOptions.tsx: -------------------------------------------------------------------------------- 1 | import { getISOLocalDateTime } from '@wojtekmaj/date-utils'; 2 | 3 | import type { LooseValue } from './shared/types.js'; 4 | 5 | type ValueOptionsProps = { 6 | setValue: (value: LooseValue) => void; 7 | value?: LooseValue; 8 | }; 9 | 10 | export default function ValueOptions({ setValue, value }: ValueOptionsProps) { 11 | const [date] = Array.isArray(value) ? value : [value]; 12 | 13 | function onChange(event: React.ChangeEvent) { 14 | const { value: nextValue } = event.target; 15 | 16 | setValue(nextValue && new Date(nextValue)); 17 | } 18 | 19 | return ( 20 |
21 | Set date and time externally 22 | 23 |
24 | 25 | 31 |   32 | 35 | 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /test/ViewOptions.tsx: -------------------------------------------------------------------------------- 1 | type ViewOptionsProps = { 2 | disabled: boolean; 3 | renderInPortal: boolean; 4 | setDisabled: (disabled: boolean) => void; 5 | setRenderInPortal: (renderInPortal: boolean) => void; 6 | setShowLeadingZeros: (showLeadingZeros: boolean) => void; 7 | setShowNeighboringMonth: (showNeighboringMonth: boolean) => void; 8 | setShowWeekNumbers: (showWeekNumbers: boolean) => void; 9 | showLeadingZeros: boolean; 10 | showNeighboringMonth: boolean; 11 | showWeekNumbers: boolean; 12 | }; 13 | 14 | export default function ViewOptions({ 15 | disabled, 16 | renderInPortal, 17 | setDisabled, 18 | setRenderInPortal, 19 | setShowLeadingZeros, 20 | setShowNeighboringMonth, 21 | setShowWeekNumbers, 22 | showLeadingZeros, 23 | showNeighboringMonth, 24 | showWeekNumbers, 25 | }: ViewOptionsProps) { 26 | function onDisabledChange(event: React.ChangeEvent) { 27 | const { checked } = event.target; 28 | 29 | setDisabled(checked); 30 | } 31 | 32 | function onShowLeadingZerosChange(event: React.ChangeEvent) { 33 | const { checked } = event.target; 34 | 35 | setShowLeadingZeros(checked); 36 | } 37 | 38 | function onShowWeekNumbersChange(event: React.ChangeEvent) { 39 | const { checked } = event.target; 40 | 41 | setShowWeekNumbers(checked); 42 | } 43 | 44 | function onShowNeighboringMonthChange(event: React.ChangeEvent) { 45 | const { checked } = event.target; 46 | 47 | setShowNeighboringMonth(checked); 48 | } 49 | 50 | function onRenderInPortalChange(event: React.ChangeEvent) { 51 | const { checked } = event.target; 52 | 53 | setRenderInPortal(checked); 54 | } 55 | 56 | return ( 57 |
58 | View options 59 | 60 |
61 | 62 | 63 |
64 | 65 |
66 | 72 | 73 |
74 | 75 |
76 | 82 | 83 |
84 | 85 |
86 | 92 | 93 |
94 | 95 |
96 | 102 | 103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | react-datetime-picker test page 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import Test from './Test.js'; 5 | 6 | const root = document.getElementById('root'); 7 | 8 | if (!root) { 9 | throw new Error('Could not find root element'); 10 | } 11 | 12 | createRoot(root).render( 13 | 14 | 15 | , 16 | ); 17 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "2.1.1", 4 | "description": "A test page for React-DateTime-Picker.", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "build": "vite build", 9 | "dev": "vite", 10 | "format": "biome format", 11 | "lint": "biome lint", 12 | "preview": "vite preview", 13 | "test": "yarn lint && yarn tsc && yarn format", 14 | "tsc": "tsc" 15 | }, 16 | "author": { 17 | "name": "Wojciech Maj", 18 | "email": "kontakt@wojtekmaj.pl" 19 | }, 20 | "license": "MIT", 21 | "dependencies": { 22 | "@wojtekmaj/date-utils": "^1.1.3", 23 | "react": "^18.2.0", 24 | "react-datetime-picker": "workspace:packages/react-datetime-picker", 25 | "react-dom": "^18.2.0" 26 | }, 27 | "devDependencies": { 28 | "@biomejs/biome": "1.9.0", 29 | "@types/react": "*", 30 | "@vitejs/plugin-react": "^4.3.4", 31 | "typescript": "^5.5.2", 32 | "vite": "^6.2.4" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/shared/types.ts: -------------------------------------------------------------------------------- 1 | type Range = [T, T]; 2 | 3 | export type Detail = 'hour' | 'minute' | 'second'; 4 | 5 | type LooseValuePiece = string | Date | null; 6 | 7 | export type LooseValue = LooseValuePiece | Range; 8 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "isolatedModules": true, 4 | "jsx": "react-jsx", 5 | "module": "preserve", 6 | "moduleDetection": "force", 7 | "noEmit": true, 8 | "noUncheckedIndexedAccess": true, 9 | "skipLibCheck": true, 10 | "strict": true, 11 | "target": "esnext", 12 | "verbatimModuleSyntax": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /test/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | base: './', 6 | plugins: [react()], 7 | }); 8 | --------------------------------------------------------------------------------