├── .gitattributes ├── .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-datetimerange-picker │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── src │ ├── DateTimeRangePicker.css │ ├── DateTimeRangePicker.spec.tsx │ ├── DateTimeRangePicker.tsx │ ├── index.ts │ └── shared │ │ └── types.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 ├── .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 /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.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-datetimerange-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-datetimerange-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) 2018–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-datetimerange-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": "@wojtekmaj/react-datetimerange-picker-monorepo", 3 | "version": "1.0.0", 4 | "description": "@wojtekmaj/react-datetimerange-picker monorepo", 5 | "type": "module", 6 | "workspaces": [ 7 | "packages/*", 8 | "test" 9 | ], 10 | "scripts": { 11 | "build": "yarn workspace @wojtekmaj/react-datetimerange-picker build", 12 | "dev": "yarn workspace @wojtekmaj/react-datetimerange-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-datetimerange-picker/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018–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-datetimerange-picker/README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/@wojtekmaj/react-datetimerange-picker.svg)](https://www.npmjs.com/package/@wojtekmaj/react-datetimerange-picker) ![downloads](https://img.shields.io/npm/dt/@wojtekmaj/react-datetimerange-picker.svg) [![CI](https://github.com/wojtekmaj/react-datetimerange-picker/actions/workflows/ci.yml/badge.svg)](https://github.com/wojtekmaj/react-datetimerange-picker/actions) 2 | 3 | # React-DateTimeRange-Picker 4 | 5 | A datetime range picker for your React app. 6 | 7 | - Pick days, months, years, or even decades 8 | - Supports virtually any language 9 | - No moment.js needed 10 | 11 | ## tl;dr 12 | 13 | - Install by executing `npm install @wojtekmaj/react-datetimerange-picker` or `yarn add @wojtekmaj/react-datetimerange-picker`. 14 | - Import by adding `import DateTimeRangePicker from '@wojtekmaj/react-datetimerange-picker'`. 15 | - Use by adding ``. Use `onChange` prop for getting new values. 16 | 17 | ## Demo 18 | 19 | A minimal demo page can be found in `sample` directory. 20 | 21 | [Online demo](https://projects.wojtekmaj.pl/react-datetimerange-picker/) is also available! 22 | 23 | ## Consider native alternative 24 | 25 | If you don't need to support legacy browsers and don't need the advanced features this package provides, consider using native datetime input instead. It's more accessible, adds no extra weight to your bundle, and works better on mobile devices. 26 | 27 | ```tsx 28 | 29 | 30 | ``` 31 | 32 | ## Looking for a time picker or a datetime picker? 33 | 34 | React-DateTimeRange-Picker will play nicely with [React-Date-Picker](https://github.com/wojtekmaj/react-date-picker), [React-Time-Picker](https://github.com/wojtekmaj/react-time-picker) and [React-DateTime-Picker](https://github.com/wojtekmaj/react-datetime-picker). Check them out! 35 | 36 | ## Getting started 37 | 38 | ### Compatibility 39 | 40 | Your project needs to use React 16.3 or later. If you use an older version of React, please refer to the table below to find a suitable React-DateTimeRange-Picker version. 41 | 42 | | React version | Newest compatible React-DateTimeRange-Picker version | 43 | | ------------- | ---------------------------------------------------- | 44 | | ≥16.8 | latest | 45 | | ≥16.3 | 4.x | 46 | | ≥16.0 | 2.x | 47 | 48 | [React-Calendar](https://github.com/wojtekmaj/react-calendar), on which React-DateTimeRange-Picker relies heavily, uses modern web technologies. That's why it's so fast, lightweight and easy to style. This, however, comes at a cost of [supporting only modern browsers](https://caniuse.com/#feat=internationalization). 49 | 50 | ### Installation 51 | 52 | Add React-DateTimeRange-Picker to your project by executing `npm install @wojtekmaj/react-datetimerange-picker` or `yarn add @wojtekmaj/react-datetimerange-picker`. 53 | 54 | ### Usage 55 | 56 | Here's an example of basic usage: 57 | 58 | ```tsx 59 | import { useState } from 'react'; 60 | import DateTimeRangePicker from '@wojtekmaj/react-datetimerange-picker'; 61 | 62 | type ValuePiece = Date | null; 63 | 64 | type Value = ValuePiece | [ValuePiece, ValuePiece]; 65 | 66 | function MyApp() { 67 | const [value, onChange] = useState([new Date(), new Date()]); 68 | 69 | return ( 70 |
71 | 72 |
73 | ); 74 | } 75 | ``` 76 | 77 | ### Custom styling 78 | 79 | If you want to use default React-Date-Picker, React-Calendar and React-Clock styling to build upon it, you can import them by using: 80 | 81 | ```ts 82 | import '@wojtekmaj/react-datetimerange-picker/dist/DateTimeRangePicker.css'; 83 | import 'react-calendar/dist/Calendar.css'; 84 | import 'react-clock/dist/Clock.css'; 85 | ``` 86 | 87 | ## User guide 88 | 89 | ### DateTimeRangePicker 90 | 91 | Displays an input field complete with custom inputs, native input, and a calendar. 92 | 93 | #### Props 94 | 95 | | Prop name | Description | Default value | Example values | 96 | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 97 | | amPmAriaLabel | `aria-label` for the AM/PM select input. | n/a | `"Select AM/PM"` | 98 | | autoFocus | Automatically focuses the input on mount. | n/a | `true` | 99 | | calendarAriaLabel | `aria-label` for the calendar button. | n/a | `"Toggle calendar"` | 100 | | calendarProps | Props to pass to React-Calendar component. | n/a | See [React-Calendar documentation](https://github.com/wojtekmaj/react-calendar) | 101 | | calendarIcon | Content of the calendar button. Setting the value explicitly to `null` will hide the icon. | (default icon) | | 102 | | className | Class name(s) that will be added along with `"react-datetimerange-picker"` to the main React-DateTimeRange-Picker `
` element. | n/a |
  • String: `"class1 class2"`
  • Array of strings: `["class1", "class2 class3"]`
| 103 | | clearAriaLabel | `aria-label` for the clear button. | n/a | `"Clear value"` | 104 | | clearIcon | Content of the clear button. Setting the value explicitly to `null` will hide the icon. | (default icon) |
  • String: `"Clear"`
  • React element: ``
  • React function: `ClearIcon`
| 105 | | clockProps | Props to pass to React-Clock component. | n/a | See [React-Clock documentation](https://github.com/wojtekmaj/react-clock) | 106 | | closeWidgets | Whether to close the widgets on value selection. **Note**: It's recommended to use `shouldCloseWidgets` function instead. | `true` | `false` | 107 | | data-testid | `data-testid` attribute for the main React-DateTimeRange-Picker `
` element. | n/a | `"datetimerange-picker"` | 108 | | dayAriaLabel | `aria-label` for the day input. | n/a | `"Day"` | 109 | | disableCalendar | When set to `true`, will remove the calendar and the button toggling its visibility. | `false` | `true` | 110 | | disableClock | When set to `true`, will remove the clock. | `false` | `true` | 111 | | disabled | Whether the datetime range picker should be disabled. | `false` | `true` | 112 | | format | 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`. **Note**: When using SSR, setting this prop may help resolving hydration errors caused by locale mismatch between server and client. | n/a | `"y-MM-dd h:mm:ss a"` | 113 | | hourAriaLabel | `aria-label` for the hour input. | n/a | `"Hour"` | 114 | | id | `id` attribute for the main React-DateTimeRange-Picker `
` element. | n/a | `"datetimerange-picker"` | 115 | | isCalendarOpen | Whether the calendar should be opened. | `false` | `true` | 116 | | isClockOpen | Whether the clock should be opened. | `false` | `true` | 117 | | locale | Locale that should be used by the datetime range picker and the calendar. Can be any [IETF language tag](https://en.wikipedia.org/wiki/IETF_language_tag). **Note**: When using SSR, setting this prop may help resolving hydration errors caused by locale mismatch between server and client. | Server locale/User's browser settings | `"hu-HU"` | 118 | | maxDate | Maximum date that the user can select. Periods partially overlapped by maxDate will also be selectable, although React-DateTimeRange-Picker will ensure that no later date is selected. | n/a | Date: `new Date()` | 119 | | maxDetail | 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"`. Don't need hour picking? Try [React-DateRange-Picker](https://github.com/wojtekmaj/react-daterange-picker)! | `"minute"` | `"second"` | 120 | | minDate | 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. | n/a | Date: `new Date()` | 121 | | minuteAriaLabel | `aria-label` for the minute input. | n/a | `"Minute"` | 122 | | minutePlaceholder | `placeholder` for the minute input. | `"--"` | `"mm"` | 123 | | monthAriaLabel | `aria-label` for the month input. | n/a | `"Month"` | 124 | | monthPlaceholder | `placeholder` for the month input. | `"--"` | `"mm"` | 125 | | name | Input name prefix. Date from/Date to fields will be named `"yourprefix_from"` and `"yourprefix_to"` respectively. | `"datetimerange"` | `"myCustomName"` | 126 | | nativeInputAriaLabel | `aria-label` for the native datetime input. | n/a | `"Date"` | 127 | | onCalendarClose | Function called when the calendar closes. | n/a | `() => alert('Calendar closed')` | 128 | | onCalendarOpen | Function called when the calendar opens. | n/a | `() => alert('Calendar opened')` | 129 | | onChange | 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". | n/a | `(value) => alert('New date is: ', value)` | 130 | | onClockClose | Function called when the clock closes. | n/a | `() => alert('Clock closed')` | 131 | | onClockOpen | Function called when the clock opens. | n/a | `() => alert('Clock opened')` | 132 | | onFocus | Function called when the user focuses an input. | n/a | `(event) => alert('Focused input: ', event.target.name)` | 133 | | onInvalidChange | Function called when the user picks an invalid datetime. | n/a | `() => alert('Invalid datetime')` | 134 | | openWidgetsOnFocus | Whether to open the widgets on input focus. **Note**: It's recommended to use `shouldOpenWidgets` function instead. | `true` | `false` | 135 | | portalContainer | Element to render the widgets in using portal. | n/a | `document.getElementById('my-div')` | 136 | | rangeDivider | Divider between datetime inputs. | `"–"` | `" to "` | 137 | | required | Whether datetime input should be required. | `false` | `true` | 138 | | secondAriaLabel | `aria-label` for the second input. | n/a | `"Second"` | 139 | | shouldCloseWidgets | 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. | n/a | `({ reason, widget }) => reason !== 'outsideAction' && widget === 'calendar'` | 140 | | shouldOpenWidgets | 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. | n/a | `({ reason, widget }) => reason !== 'focus' && widget === 'calendar'` | 141 | | showLeadingZeros | Whether leading zeros should be rendered in datetime inputs. | `false` | `true` | 142 | | value | Input value. | n/a |
  • Date: `new Date(2017, 0, 1, 22, 15)`
  • String: `"2017-01-01T22:15:00"`
  • An array of dates: `[new Date(2017, 0, 1, 22, 15), new Date(2017, 0, 1, 23, 45)]`
  • An array of strings: `["2017-01-01T22:15:00", "2017-01-01T23:45:00"]`
| 143 | | yearAriaLabel | `aria-label` for the year input. | n/a | `"Year"` | 144 | 145 | ### Calendar 146 | 147 | DateTimeRangePicker component passes all props to React-Calendar, with the exception of `className` (you can use `calendarClassName` for that instead). There are tons of customizations you can do! For more information, see [Calendar component props](https://github.com/wojtekmaj/react-calendar#props). 148 | 149 | ## License 150 | 151 | The MIT License. 152 | 153 | ## Author 154 | 155 | 156 | 157 | 160 | 163 | 164 |
158 | Wojciech Maj 159 | 161 | Wojciech Maj 162 |
165 | -------------------------------------------------------------------------------- /packages/react-datetimerange-picker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wojtekmaj/react-datetimerange-picker", 3 | "version": "6.0.0", 4 | "description": "A datetime 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 | "./*": "./*" 19 | }, 20 | "scripts": { 21 | "build": "yarn build-js && yarn copy-styles", 22 | "build-js": "yarn build-js-esm && yarn build-js-cjs && yarn build-js-cjs-package && yarn build-js-cjs-replace", 23 | "build-js-esm": "tsc --project tsconfig.build.json --outDir dist/esm", 24 | "build-js-cjs": "tsc --project tsconfig.build.json --outDir dist/cjs --module commonjs --moduleResolution node --verbatimModuleSyntax false", 25 | "build-js-cjs-package": "echo '{\n \"type\": \"commonjs\"\n}' > dist/cjs/package.json", 26 | "build-js-cjs-replace": "replace-in-files --string='/dist/esm/' --replacement='/dist/cjs/' dist/cjs/**/*", 27 | "clean": "node -e \"fs.rmSync('./dist', { recursive: true, force: true })\"", 28 | "copy-styles": "cpy 'src/**/*.css' dist", 29 | "format": "biome format", 30 | "lint": "biome lint", 31 | "prepack": "yarn clean && yarn build", 32 | "test": "yarn lint && yarn tsc && yarn format && yarn unit", 33 | "tsc": "tsc", 34 | "unit": "vitest", 35 | "watch": "yarn build-js-esm --watch & yarn build-js-cjs --watch & node --eval \"fs.watch('src', () => child_process.exec('yarn copy-styles'))\"" 36 | }, 37 | "keywords": [ 38 | "calendar", 39 | "date", 40 | "date-picker", 41 | "date-range", 42 | "date-range-picker", 43 | "datetime", 44 | "datetime-picker", 45 | "datetime-range", 46 | "datetime-range-picker", 47 | "react", 48 | "time", 49 | "time-picker" 50 | ], 51 | "author": { 52 | "name": "Wojciech Maj", 53 | "email": "kontakt@wojtekmaj.pl" 54 | }, 55 | "license": "MIT", 56 | "dependencies": { 57 | "clsx": "^2.0.0", 58 | "make-event-props": "^1.6.0", 59 | "react-calendar": "^5.0.0", 60 | "react-clock": "^5.0.0", 61 | "react-datetime-picker": "^6.0.1", 62 | "react-fit": "^2.0.0" 63 | }, 64 | "devDependencies": { 65 | "@biomejs/biome": "1.9.0", 66 | "@testing-library/dom": "^10.0.0", 67 | "@testing-library/jest-dom": "^6.0.0", 68 | "@testing-library/react": "^16.0.0", 69 | "@testing-library/user-event": "^14.5.0", 70 | "@types/node": "*", 71 | "@types/react": "*", 72 | "@types/react-dom": "*", 73 | "cpy-cli": "^5.0.0", 74 | "happy-dom": "^15.10.2", 75 | "react": "^18.2.0", 76 | "react-dom": "^18.2.0", 77 | "replace-in-files-cli": "^3.0.0", 78 | "typescript": "^5.5.2", 79 | "vitest": "^3.0.5", 80 | "vitest-canvas-mock": "^0.2.2" 81 | }, 82 | "peerDependencies": { 83 | "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 84 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 85 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 86 | }, 87 | "peerDependenciesMeta": { 88 | "@types/react": { 89 | "optional": true 90 | } 91 | }, 92 | "publishConfig": { 93 | "access": "public", 94 | "provenance": true 95 | }, 96 | "files": [ 97 | "dist", 98 | "src" 99 | ], 100 | "repository": { 101 | "type": "git", 102 | "url": "git+https://github.com/wojtekmaj/react-datetimerange-picker.git", 103 | "directory": "packages/react-datetimerange-picker" 104 | }, 105 | "funding": "https://github.com/wojtekmaj/react-datetimerange-picker?sponsor=1" 106 | } 107 | -------------------------------------------------------------------------------- /packages/react-datetimerange-picker/src/DateTimeRangePicker.css: -------------------------------------------------------------------------------- 1 | .react-datetimerange-picker { 2 | display: inline-flex; 3 | position: relative; 4 | } 5 | 6 | .react-datetimerange-picker, 7 | .react-datetimerange-picker *, 8 | .react-datetimerange-picker *:before, 9 | .react-datetimerange-picker *:after { 10 | -moz-box-sizing: border-box; 11 | -webkit-box-sizing: border-box; 12 | box-sizing: border-box; 13 | } 14 | 15 | .react-datetimerange-picker--disabled { 16 | background-color: #f0f0f0; 17 | color: #6d6d6d; 18 | } 19 | 20 | .react-datetimerange-picker__wrapper { 21 | display: flex; 22 | flex-grow: 1; 23 | flex-shrink: 0; 24 | align-items: center; 25 | border: thin solid gray; 26 | } 27 | 28 | .react-datetimerange-picker__inputGroup { 29 | min-width: calc(4px + (4px * 3) + 0.54em * 6 + 0.217em * 2); 30 | height: 100%; 31 | flex-grow: 1; 32 | padding: 0 2px; 33 | } 34 | 35 | .react-datetimerange-picker__inputGroup__divider { 36 | padding: 1px 0; 37 | } 38 | 39 | .react-datetimerange-picker__inputGroup__divider, 40 | .react-datetimerange-picker__inputGroup__leadingZero { 41 | display: inline-block; 42 | font: inherit; 43 | } 44 | 45 | .react-datetimerange-picker__inputGroup__input { 46 | min-width: 0.54em; 47 | height: calc(100% - 2px); 48 | position: relative; 49 | padding: 1px; 50 | border: 0; 51 | background: none; 52 | color: currentColor; 53 | font: inherit; 54 | box-sizing: content-box; 55 | -webkit-appearance: textfield; 56 | -moz-appearance: textfield; 57 | appearance: textfield; 58 | } 59 | 60 | .react-datetimerange-picker__inputGroup__input::-webkit-outer-spin-button, 61 | .react-datetimerange-picker__inputGroup__input::-webkit-inner-spin-button { 62 | -webkit-appearance: none; 63 | -moz-appearance: none; 64 | appearance: none; 65 | margin: 0; 66 | } 67 | 68 | .react-datetimerange-picker__inputGroup__input:invalid { 69 | background: rgba(255, 0, 0, 0.1); 70 | } 71 | 72 | .react-datetimerange-picker__inputGroup__input--hasLeadingZero { 73 | margin-left: -0.54em; 74 | padding-left: calc(1px + 0.54em); 75 | } 76 | 77 | .react-datetimerange-picker__inputGroup__amPm { 78 | font: inherit; 79 | -webkit-appearance: menulist; 80 | -moz-appearance: menulist; 81 | appearance: menulist; 82 | } 83 | 84 | .react-datetimerange-picker__button { 85 | border: 0; 86 | background: transparent; 87 | padding: 4px 6px; 88 | } 89 | 90 | .react-datetimerange-picker__button:enabled { 91 | cursor: pointer; 92 | } 93 | 94 | .react-datetimerange-picker__button:enabled:hover .react-datetimerange-picker__button__icon, 95 | .react-datetimerange-picker__button:enabled:focus .react-datetimerange-picker__button__icon { 96 | stroke: #0078d7; 97 | } 98 | 99 | .react-datetimerange-picker__button:disabled .react-datetimerange-picker__button__icon { 100 | stroke: #6d6d6d; 101 | } 102 | 103 | .react-datetimerange-picker__button svg { 104 | display: inherit; 105 | } 106 | 107 | .react-datetimerange-picker__calendar, 108 | .react-datetimerange-picker__clock { 109 | z-index: 1; 110 | } 111 | 112 | .react-datetimerange-picker__calendar--closed, 113 | .react-datetimerange-picker__clock--closed { 114 | display: none; 115 | } 116 | 117 | .react-datetimerange-picker__calendar { 118 | width: 350px; 119 | max-width: 100vw; 120 | } 121 | 122 | .react-datetimerange-picker__calendar .react-calendar { 123 | border-width: thin; 124 | } 125 | 126 | .react-datetimerange-picker__clock { 127 | width: 200px; 128 | height: 200px; 129 | max-width: 100vw; 130 | padding: 25px; 131 | background-color: white; 132 | border: thin solid #a0a096; 133 | } 134 | -------------------------------------------------------------------------------- /packages/react-datetimerange-picker/src/DateTimeRangePicker.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 DateTimeRangePicker from './DateTimeRangePicker.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('DateTimeRangePicker', () => { 22 | it('passes default name to DateTimeInput components', () => { 23 | const { container } = render(); 24 | 25 | const nativeInputs = container.querySelectorAll('input[type="datetime-local"]'); 26 | 27 | expect(nativeInputs[0]).toHaveAttribute('name', 'datetimerange_from'); 28 | expect(nativeInputs[1]).toHaveAttribute('name', 'datetimerange_to'); 29 | }); 30 | 31 | it('passes custom name to DateTimeInput components', () => { 32 | const name = 'testName'; 33 | 34 | const { container } = render(); 35 | 36 | const nativeInputs = container.querySelectorAll('input[type="datetime-local"]'); 37 | 38 | expect(nativeInputs[0]).toHaveAttribute('name', `${name}_from`); 39 | expect(nativeInputs[1]).toHaveAttribute('name', `${name}_to`); 40 | }); 41 | 42 | it('passes autoFocus flag to first DateTimeInput component', () => { 43 | const { container } = render(); 44 | 45 | const customInputs = container.querySelectorAll('input[data-input]'); 46 | 47 | expect(customInputs[0]).toHaveFocus(); 48 | }); 49 | 50 | it('passes disabled flag to DateTimeInput components', () => { 51 | const { container } = render(); 52 | 53 | const nativeInputs = container.querySelectorAll('input[type="datetime-local"]'); 54 | 55 | expect(nativeInputs[0]).toBeDisabled(); 56 | expect(nativeInputs[1]).toBeDisabled(); 57 | }); 58 | 59 | it('passes format to DateTimeInput components', () => { 60 | const { container } = render(); 61 | 62 | const customInputs = container.querySelectorAll('input[data-input]'); 63 | 64 | expect(customInputs).toHaveLength(2); 65 | expect(customInputs[0]).toHaveAttribute('name', 'second'); 66 | expect(customInputs[1]).toHaveAttribute('name', 'second'); 67 | }); 68 | 69 | it('passes aria-label props to DateTimeInput components', () => { 70 | const ariaLabelProps = { 71 | amPmAriaLabel: 'Select AM/PM', 72 | calendarAriaLabel: 'Toggle calendar', 73 | clearAriaLabel: 'Clear value', 74 | dayAriaLabel: 'Day', 75 | hourAriaLabel: 'Hour', 76 | minuteAriaLabel: 'Minute', 77 | monthAriaLabel: 'Month', 78 | nativeInputAriaLabel: 'Date and time', 79 | secondAriaLabel: 'Second', 80 | yearAriaLabel: 'Year', 81 | }; 82 | 83 | const { container } = render(); 84 | 85 | const calendarButton = container.querySelector( 86 | 'button.react-datetimerange-picker__calendar-button', 87 | ); 88 | const clearButton = container.querySelector('button.react-datetimerange-picker__clear-button'); 89 | const dateTimeInputs = container.querySelectorAll( 90 | '.react-datetimerange-picker__inputGroup', 91 | ) as unknown as [HTMLDivElement, HTMLDivElement]; 92 | 93 | const [dateTimeFromInput, dateTimeToInput] = dateTimeInputs; 94 | 95 | const nativeFromInput = dateTimeFromInput.querySelector('input[type="datetime-local"]'); 96 | const dayFromInput = dateTimeFromInput.querySelector('input[name="day"]'); 97 | const monthFromInput = dateTimeFromInput.querySelector('input[name="month"]'); 98 | const yearFromInput = dateTimeFromInput.querySelector('input[name="year"]'); 99 | const hourFromInput = dateTimeFromInput.querySelector('input[name="hour12"]'); 100 | const minuteFromInput = dateTimeFromInput.querySelector('input[name="minute"]'); 101 | const secondFromInput = dateTimeFromInput.querySelector('input[name="second"]'); 102 | 103 | const nativeToInput = dateTimeToInput.querySelector('input[type="datetime-local"]'); 104 | const dayToInput = dateTimeToInput.querySelector('input[name="day"]'); 105 | const monthToInput = dateTimeToInput.querySelector('input[name="month"]'); 106 | const yearToInput = dateTimeToInput.querySelector('input[name="year"]'); 107 | const hourToInput = dateTimeToInput.querySelector('input[name="hour12"]'); 108 | const minuteToInput = dateTimeToInput.querySelector('input[name="minute"]'); 109 | const secondToInput = dateTimeToInput.querySelector('input[name="second"]'); 110 | 111 | expect(calendarButton).toHaveAttribute('aria-label', ariaLabelProps.calendarAriaLabel); 112 | expect(clearButton).toHaveAttribute('aria-label', ariaLabelProps.clearAriaLabel); 113 | 114 | expect(nativeFromInput).toHaveAttribute('aria-label', ariaLabelProps.nativeInputAriaLabel); 115 | expect(dayFromInput).toHaveAttribute('aria-label', ariaLabelProps.dayAriaLabel); 116 | expect(monthFromInput).toHaveAttribute('aria-label', ariaLabelProps.monthAriaLabel); 117 | expect(yearFromInput).toHaveAttribute('aria-label', ariaLabelProps.yearAriaLabel); 118 | expect(hourFromInput).toHaveAttribute('aria-label', ariaLabelProps.hourAriaLabel); 119 | expect(minuteFromInput).toHaveAttribute('aria-label', ariaLabelProps.minuteAriaLabel); 120 | expect(secondFromInput).toHaveAttribute('aria-label', ariaLabelProps.secondAriaLabel); 121 | 122 | expect(nativeToInput).toHaveAttribute('aria-label', ariaLabelProps.nativeInputAriaLabel); 123 | expect(dayToInput).toHaveAttribute('aria-label', ariaLabelProps.dayAriaLabel); 124 | expect(monthToInput).toHaveAttribute('aria-label', ariaLabelProps.monthAriaLabel); 125 | expect(yearToInput).toHaveAttribute('aria-label', ariaLabelProps.yearAriaLabel); 126 | expect(hourToInput).toHaveAttribute('aria-label', ariaLabelProps.hourAriaLabel); 127 | expect(minuteToInput).toHaveAttribute('aria-label', ariaLabelProps.minuteAriaLabel); 128 | expect(secondToInput).toHaveAttribute('aria-label', ariaLabelProps.secondAriaLabel); 129 | }); 130 | 131 | it('passes placeholder props to DateTimeInput components', () => { 132 | const placeholderProps = { 133 | dayPlaceholder: 'dd', 134 | hourPlaceholder: 'hh', 135 | minutePlaceholder: 'mm', 136 | monthPlaceholder: 'mm', 137 | secondPlaceholder: 'ss', 138 | yearPlaceholder: 'yyyy', 139 | }; 140 | 141 | const { container } = render(); 142 | 143 | const dateTimeInputs = container.querySelectorAll( 144 | '.react-datetimerange-picker__inputGroup', 145 | ) as unknown as [HTMLDivElement, HTMLDivElement]; 146 | 147 | const [dateTimeFromInput, dateTimeToInput] = dateTimeInputs; 148 | 149 | const dayFromInput = dateTimeFromInput.querySelector('input[name="day"]'); 150 | const monthFromInput = dateTimeFromInput.querySelector('input[name="month"]'); 151 | const yearFromInput = dateTimeFromInput.querySelector('input[name="year"]'); 152 | const hourFromInput = dateTimeFromInput.querySelector('input[name="hour12"]'); 153 | const minuteFromInput = dateTimeFromInput.querySelector('input[name="minute"]'); 154 | const secondFromInput = dateTimeFromInput.querySelector('input[name="second"]'); 155 | 156 | const dayToInput = dateTimeToInput.querySelector('input[name="day"]'); 157 | const monthToInput = dateTimeToInput.querySelector('input[name="month"]'); 158 | const yearToInput = dateTimeToInput.querySelector('input[name="year"]'); 159 | const hourToInput = dateTimeToInput.querySelector('input[name="hour12"]'); 160 | const minuteToInput = dateTimeToInput.querySelector('input[name="minute"]'); 161 | const secondToInput = dateTimeToInput.querySelector('input[name="second"]'); 162 | 163 | expect(dayFromInput).toHaveAttribute('placeholder', placeholderProps.dayPlaceholder); 164 | expect(monthFromInput).toHaveAttribute('placeholder', placeholderProps.monthPlaceholder); 165 | expect(yearFromInput).toHaveAttribute('placeholder', placeholderProps.yearPlaceholder); 166 | expect(hourFromInput).toHaveAttribute('placeholder', placeholderProps.hourPlaceholder); 167 | expect(minuteFromInput).toHaveAttribute('placeholder', placeholderProps.minutePlaceholder); 168 | expect(secondFromInput).toHaveAttribute('placeholder', placeholderProps.secondPlaceholder); 169 | 170 | expect(dayToInput).toHaveAttribute('placeholder', placeholderProps.dayPlaceholder); 171 | expect(monthToInput).toHaveAttribute('placeholder', placeholderProps.monthPlaceholder); 172 | expect(yearToInput).toHaveAttribute('placeholder', placeholderProps.yearPlaceholder); 173 | expect(hourToInput).toHaveAttribute('placeholder', placeholderProps.hourPlaceholder); 174 | expect(minuteToInput).toHaveAttribute('placeholder', placeholderProps.minutePlaceholder); 175 | expect(secondToInput).toHaveAttribute('placeholder', placeholderProps.secondPlaceholder); 176 | }); 177 | 178 | describe('passes value to DateTimeInput components', () => { 179 | it('passes single value to DateTimeInput components', () => { 180 | const value = new Date(2019, 0, 1); 181 | 182 | const { container } = render(); 183 | 184 | const nativeInputs = container.querySelectorAll('input[type="datetime-local"]'); 185 | 186 | expect(nativeInputs[0]).toHaveValue('2019-01-01T00:00'); 187 | expect(nativeInputs[1]).toHaveValue(''); 188 | }); 189 | 190 | it('passes the first item of an array of values to DateTimeInput components', () => { 191 | const value1 = new Date(2019, 0, 1); 192 | const value2 = new Date(2019, 6, 1, 12, 30); 193 | 194 | const { container } = render(); 195 | 196 | const nativeInputs = container.querySelectorAll('input[type="datetime-local"]'); 197 | 198 | expect(nativeInputs[0]).toHaveValue('2019-01-01T00:00'); 199 | expect(nativeInputs[1]).toHaveValue('2019-07-01T12:30'); 200 | }); 201 | }); 202 | 203 | it('applies className to its wrapper when given a string', () => { 204 | const className = 'testClassName'; 205 | 206 | const { container } = render(); 207 | 208 | const wrapper = container.firstElementChild; 209 | 210 | expect(wrapper).toHaveClass(className); 211 | }); 212 | 213 | it('applies "--open" className to its wrapper when given isCalendarOpen flag', () => { 214 | const { container } = render(); 215 | 216 | const wrapper = container.firstElementChild; 217 | 218 | expect(wrapper).toHaveClass('react-datetimerange-picker--open'); 219 | }); 220 | 221 | it('applies "--open" className to its wrapper when given isClockOpen flag', () => { 222 | const { container } = render(); 223 | 224 | const wrapper = container.firstElementChild; 225 | 226 | expect(wrapper).toHaveClass('react-datetimerange-picker--open'); 227 | }); 228 | 229 | it('applies calendar className to the calendar when given a string', () => { 230 | const calendarClassName = 'testClassName'; 231 | 232 | const { container } = render( 233 | , 234 | ); 235 | 236 | const calendar = container.querySelector('.react-calendar'); 237 | 238 | expect(calendar).toHaveClass(calendarClassName); 239 | }); 240 | 241 | it('applies clock className to the clock when given a string', () => { 242 | const clockClassName = 'testClassName'; 243 | 244 | const { container } = render( 245 | , 246 | ); 247 | 248 | const clock = container.querySelector('.react-clock'); 249 | 250 | expect(clock).toHaveClass(clockClassName); 251 | }); 252 | 253 | it('renders DateTimeInput components', () => { 254 | const { container } = render(); 255 | 256 | const nativeInputs = container.querySelectorAll('input[type="datetime-local"]'); 257 | 258 | expect(nativeInputs.length).toBe(2); 259 | }); 260 | 261 | it('renders range divider with default divider', () => { 262 | const { container } = render(); 263 | 264 | const rangeDivider = container.querySelector('.react-datetimerange-picker__range-divider'); 265 | 266 | expect(rangeDivider).toBeInTheDocument(); 267 | expect(rangeDivider).toHaveTextContent('–'); 268 | }); 269 | 270 | it('renders range divider with custom divider', () => { 271 | const { container } = render(); 272 | 273 | const rangeDivider = container.querySelector('.react-datetimerange-picker__range-divider'); 274 | 275 | expect(rangeDivider).toBeInTheDocument(); 276 | expect(rangeDivider).toHaveTextContent('to'); 277 | }); 278 | 279 | describe('renders clear button properly', () => { 280 | it('renders clear button', () => { 281 | const { container } = render(); 282 | 283 | const clearButton = container.querySelector( 284 | 'button.react-datetimerange-picker__clear-button', 285 | ); 286 | 287 | expect(clearButton).toBeInTheDocument(); 288 | }); 289 | 290 | it('renders clear icon by default when clearIcon is not given', () => { 291 | const { container } = render(); 292 | 293 | const clearButton = container.querySelector( 294 | 'button.react-datetimerange-picker__clear-button', 295 | ) as HTMLButtonElement; 296 | 297 | const clearIcon = clearButton.querySelector('svg'); 298 | 299 | expect(clearIcon).toBeInTheDocument(); 300 | }); 301 | 302 | it('renders clear icon when given clearIcon as a string', () => { 303 | const { container } = render(); 304 | 305 | const clearButton = container.querySelector( 306 | 'button.react-datetimerange-picker__clear-button', 307 | ); 308 | 309 | expect(clearButton).toHaveTextContent('❌'); 310 | }); 311 | 312 | it('renders clear icon when given clearIcon as a React element', () => { 313 | function ClearIcon() { 314 | return <>❌; 315 | } 316 | 317 | const { container } = render(} />); 318 | 319 | const clearButton = container.querySelector( 320 | 'button.react-datetimerange-picker__clear-button', 321 | ); 322 | 323 | expect(clearButton).toHaveTextContent('❌'); 324 | }); 325 | 326 | it('renders clear icon when given clearIcon as a function', () => { 327 | function ClearIcon() { 328 | return <>❌; 329 | } 330 | 331 | const { container } = render(); 332 | 333 | const clearButton = container.querySelector( 334 | 'button.react-datetimerange-picker__clear-button', 335 | ); 336 | 337 | expect(clearButton).toHaveTextContent('❌'); 338 | }); 339 | }); 340 | 341 | describe('renders calendar button properly', () => { 342 | it('renders calendar button', () => { 343 | const { container } = render(); 344 | 345 | const calendarButton = container.querySelector( 346 | 'button.react-datetimerange-picker__calendar-button', 347 | ); 348 | 349 | expect(calendarButton).toBeInTheDocument(); 350 | }); 351 | 352 | it('renders calendar icon by default when calendarIcon is not given', () => { 353 | const { container } = render(); 354 | 355 | const calendarButton = container.querySelector( 356 | 'button.react-datetimerange-picker__calendar-button', 357 | ) as HTMLButtonElement; 358 | 359 | const calendarIcon = calendarButton.querySelector('svg'); 360 | 361 | expect(calendarIcon).toBeInTheDocument(); 362 | }); 363 | 364 | it('renders calendar icon when given calendarIcon as a string', () => { 365 | const { container } = render(); 366 | 367 | const calendarButton = container.querySelector( 368 | 'button.react-datetimerange-picker__calendar-button', 369 | ); 370 | 371 | expect(calendarButton).toHaveTextContent('📅'); 372 | }); 373 | 374 | it('renders calendar icon when given calendarIcon as a React element', () => { 375 | function CalendarIcon() { 376 | return <>📅; 377 | } 378 | 379 | const { container } = render(} />); 380 | 381 | const calendarButton = container.querySelector( 382 | 'button.react-datetimerange-picker__calendar-button', 383 | ); 384 | 385 | expect(calendarButton).toHaveTextContent('📅'); 386 | }); 387 | 388 | it('renders calendar icon when given calendarIcon as a function', () => { 389 | function CalendarIcon() { 390 | return <>📅; 391 | } 392 | 393 | const { container } = render(); 394 | 395 | const calendarButton = container.querySelector( 396 | 'button.react-datetimerange-picker__calendar-button', 397 | ); 398 | 399 | expect(calendarButton).toHaveTextContent('📅'); 400 | }); 401 | }); 402 | 403 | it('renders DateTimeInput and Calendar components when given isCalendarOpen flag', () => { 404 | const { container } = render(); 405 | 406 | const calendar = container.querySelector('.react-calendar'); 407 | 408 | expect(calendar).toBeInTheDocument(); 409 | }); 410 | 411 | it('renders Clock component when given isClockOpen flag', () => { 412 | const { container } = render(); 413 | 414 | const clock = container.querySelector('.react-clock'); 415 | 416 | expect(clock).toBeInTheDocument(); 417 | }); 418 | 419 | it('does not render Calendar component when given disableCalendar & isCalendarOpen flags', () => { 420 | const { container } = render(); 421 | 422 | const calendar = container.querySelector('.react-calendar'); 423 | 424 | expect(calendar).toBeFalsy(); 425 | }); 426 | 427 | it('does not render Clock component when given disableClock & isClockOpen flags', () => { 428 | const { container } = render(); 429 | 430 | const clock = container.querySelector('.react-clock'); 431 | 432 | expect(clock).toBeFalsy(); 433 | }); 434 | 435 | it('opens Calendar component when given isCalendarOpen flag by changing props', () => { 436 | const { container, rerender } = render(); 437 | 438 | const calendar = container.querySelector('.react-calendar'); 439 | 440 | expect(calendar).toBeFalsy(); 441 | 442 | rerender(); 443 | 444 | const calendar2 = container.querySelector('.react-calendar'); 445 | 446 | expect(calendar2).toBeInTheDocument(); 447 | }); 448 | 449 | it('opens Clock component when given isClockOpen flag by changing props', () => { 450 | const { container, rerender } = render(); 451 | 452 | const clock = container.querySelector('.react-clock'); 453 | 454 | expect(clock).toBeFalsy(); 455 | 456 | rerender(); 457 | 458 | const clock2 = container.querySelector('.react-clock'); 459 | 460 | expect(clock2).toBeInTheDocument(); 461 | }); 462 | 463 | it('opens Calendar component when clicking on a button', () => { 464 | const { container } = render(); 465 | 466 | const calendar = container.querySelector('.react-calendar'); 467 | const button = container.querySelector( 468 | 'button.react-datetimerange-picker__calendar-button', 469 | ) as HTMLButtonElement; 470 | 471 | expect(calendar).toBeFalsy(); 472 | 473 | fireEvent.click(button); 474 | 475 | const calendar2 = container.querySelector('.react-calendar'); 476 | 477 | expect(calendar2).toBeInTheDocument(); 478 | }); 479 | 480 | describe('handles opening Calendar component when focusing on an input inside properly', () => { 481 | it('opens Calendar component when focusing on an input inside by default', () => { 482 | const { container } = render(); 483 | 484 | const calendar = container.querySelector('.react-calendar'); 485 | const input = container.querySelector('input[name="day"]') as HTMLInputElement; 486 | 487 | expect(calendar).toBeFalsy(); 488 | 489 | fireEvent.focus(input); 490 | 491 | const calendar2 = container.querySelector('.react-calendar'); 492 | 493 | expect(calendar2).toBeInTheDocument(); 494 | }); 495 | 496 | it('opens Calendar component when focusing on an input inside given openWidgetsOnFocus = true', () => { 497 | const { container } = render(); 498 | 499 | const calendar = container.querySelector('.react-calendar'); 500 | const input = container.querySelector('input[name="day"]') as HTMLInputElement; 501 | 502 | expect(calendar).toBeFalsy(); 503 | 504 | fireEvent.focus(input); 505 | 506 | const calendar2 = container.querySelector('.react-calendar'); 507 | 508 | expect(calendar2).toBeInTheDocument(); 509 | }); 510 | 511 | it('does not open Calendar component when focusing on an input inside given openWidgetsOnFocus = false', () => { 512 | const { container } = render(); 513 | 514 | const calendar = container.querySelector('.react-calendar'); 515 | const input = container.querySelector('input[name="day"]') as HTMLInputElement; 516 | 517 | expect(calendar).toBeFalsy(); 518 | 519 | fireEvent.focus(input); 520 | 521 | const calendar2 = container.querySelector('.react-calendar'); 522 | 523 | expect(calendar2).toBeFalsy(); 524 | }); 525 | 526 | it('does not open Calendar component when focusing on an input inside given shouldOpenWidgets function returning false', () => { 527 | const shouldOpenWidgets = () => false; 528 | 529 | const { container } = render(); 530 | 531 | const calendar = container.querySelector('.react-calendar'); 532 | const input = container.querySelector('input[name="day"]') as HTMLInputElement; 533 | 534 | expect(calendar).toBeFalsy(); 535 | 536 | fireEvent.focus(input); 537 | 538 | const calendar2 = container.querySelector('.react-calendar'); 539 | 540 | expect(calendar2).toBeFalsy(); 541 | }); 542 | 543 | it('does not open Calendar component when focusing on a select element', () => { 544 | const { container } = render(); 545 | 546 | const calendar = container.querySelector('.react-calendar'); 547 | const select = container.querySelector('select[name="month"]') as HTMLSelectElement; 548 | 549 | expect(calendar).toBeFalsy(); 550 | 551 | fireEvent.focus(select); 552 | 553 | const calendar2 = container.querySelector('.react-calendar'); 554 | 555 | expect(calendar2).toBeFalsy(); 556 | }); 557 | }); 558 | 559 | describe('handles opening Clock component when focusing on an input inside properly', () => { 560 | it('opens Clock component when focusing on an input inside by default', () => { 561 | const { container } = render(); 562 | 563 | const clock = container.querySelector('.react-clock'); 564 | const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; 565 | 566 | expect(clock).toBeFalsy(); 567 | 568 | fireEvent.focus(input); 569 | 570 | const clock2 = container.querySelector('.react-clock'); 571 | 572 | expect(clock2).toBeInTheDocument(); 573 | }); 574 | 575 | it('opens Clock component when focusing on an input inside given openWidgetsOnFocus = true', () => { 576 | const { container } = render(); 577 | 578 | const clock = container.querySelector('.react-clock'); 579 | const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; 580 | 581 | expect(clock).toBeFalsy(); 582 | 583 | fireEvent.focus(input); 584 | 585 | const clock2 = container.querySelector('.react-clock'); 586 | 587 | expect(clock2).toBeInTheDocument(); 588 | }); 589 | 590 | it('does not open Clock component when focusing on an input inside given openWidgetsOnFocus = false', () => { 591 | const { container } = render(); 592 | 593 | const clock = container.querySelector('.react-clock'); 594 | const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; 595 | 596 | expect(clock).toBeFalsy(); 597 | 598 | fireEvent.focus(input); 599 | 600 | const clock2 = container.querySelector('.react-clock'); 601 | 602 | expect(clock2).toBeFalsy(); 603 | }); 604 | 605 | it('does not open Clock component when focusing on an input inside given shouldOpenWidgets function returning false', () => { 606 | const shouldOpenWidgets = () => false; 607 | 608 | const { container } = render(); 609 | 610 | const clock = container.querySelector('.react-clock'); 611 | const input = container.querySelector('input[name^="hour"]') as HTMLInputElement; 612 | 613 | expect(clock).toBeFalsy(); 614 | 615 | fireEvent.focus(input); 616 | 617 | const clock2 = container.querySelector('.react-clock'); 618 | 619 | expect(clock2).toBeFalsy(); 620 | }); 621 | 622 | it('does not open Clock component when focusing on a select element', () => { 623 | const { container } = render(); 624 | 625 | const clock = container.querySelector('.react-clock'); 626 | const select = container.querySelector('select[name="amPm"]') as HTMLSelectElement; 627 | 628 | expect(clock).toBeFalsy(); 629 | 630 | fireEvent.focus(select); 631 | 632 | const clock2 = container.querySelector('.react-clock'); 633 | 634 | expect(clock2).toBeFalsy(); 635 | }); 636 | }); 637 | 638 | it('closes Calendar component when clicked outside', async () => { 639 | const { container } = render(); 640 | 641 | userEvent.click(document.body); 642 | 643 | await waitForElementToBeRemovedOrHidden(() => 644 | container.querySelector('.react-datetimerange-picker__calendar'), 645 | ); 646 | }); 647 | 648 | it('closes Calendar component when focused outside', async () => { 649 | const { container } = render(); 650 | 651 | fireEvent.focusIn(document.body); 652 | 653 | await waitForElementToBeRemovedOrHidden(() => 654 | container.querySelector('.react-datetimerange-picker__calendar'), 655 | ); 656 | }); 657 | 658 | it('closes Calendar component when tapped outside', async () => { 659 | const { container } = render(); 660 | 661 | fireEvent.touchStart(document.body); 662 | 663 | await waitForElementToBeRemovedOrHidden(() => 664 | container.querySelector('.react-datetimerange-picker__calendar'), 665 | ); 666 | }); 667 | 668 | it('closes Clock component when clicked outside', async () => { 669 | const { container } = render(); 670 | 671 | userEvent.click(document.body); 672 | 673 | await waitForElementToBeRemovedOrHidden(() => 674 | container.querySelector('.react-datetimerange-picker__clock'), 675 | ); 676 | }); 677 | 678 | it('closes Clock component when focused outside', async () => { 679 | const { container } = render(); 680 | 681 | fireEvent.focusIn(document.body); 682 | 683 | await waitForElementToBeRemovedOrHidden(() => 684 | container.querySelector('.react-datetimerange-picker__clock'), 685 | ); 686 | }); 687 | 688 | it('closes Clock component when tapped outside', async () => { 689 | const { container } = render(); 690 | 691 | fireEvent.touchStart(document.body); 692 | 693 | await waitForElementToBeRemovedOrHidden(() => 694 | container.querySelector('.react-datetimerange-picker__clock'), 695 | ); 696 | }); 697 | 698 | it('does not close Calendar component when focused within date inputs', () => { 699 | const { container } = render(); 700 | 701 | const customInputs = container.querySelectorAll('input[data-input]'); 702 | const monthInput = customInputs[0] as HTMLInputElement; 703 | const dayInput = customInputs[1] as HTMLInputElement; 704 | 705 | fireEvent.blur(monthInput); 706 | fireEvent.focus(dayInput); 707 | 708 | const calendar = container.querySelector('.react-calendar'); 709 | 710 | expect(calendar).toBeInTheDocument(); 711 | }); 712 | 713 | it('does not close Clock component when focused within time inputs', async () => { 714 | const { container } = render(); 715 | 716 | const customInputs = container.querySelectorAll('input[data-input]'); 717 | const hourInput = customInputs[3] as HTMLInputElement; 718 | const minuteInput = customInputs[4] as HTMLInputElement; 719 | 720 | fireEvent.blur(hourInput); 721 | fireEvent.focus(minuteInput); 722 | 723 | await waitForElementToBeRemovedOrHidden(() => 724 | container.querySelector('.react-datetimerange-picker__calendar'), 725 | ); 726 | 727 | const clock = container.querySelector('.react-clock'); 728 | 729 | expect(clock).toBeInTheDocument(); 730 | }); 731 | 732 | it('closes Clock when Calendar is opened by a click on the calendar icon', async () => { 733 | const { container } = render(); 734 | 735 | const clock = container.querySelector('.react-clock'); 736 | const button = container.querySelector( 737 | 'button.react-datetimerange-picker__calendar-button', 738 | ) as HTMLButtonElement; 739 | 740 | expect(clock).toBeInTheDocument(); 741 | 742 | fireEvent.click(button); 743 | 744 | await waitForElementToBeRemovedOrHidden(() => 745 | container.querySelector('.react-datetimerange-picker__clock'), 746 | ); 747 | }); 748 | 749 | it('opens Calendar component, followed by Clock component, when focusing on inputs inside', () => { 750 | const { container } = render(); 751 | 752 | const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; 753 | 754 | fireEvent.focus(dayInput); 755 | 756 | const calendar = container.querySelector('.react-calendar'); 757 | 758 | expect(calendar).toBeInTheDocument(); 759 | 760 | const minuteInput = container.querySelector('input[name="minute"]') as HTMLInputElement; 761 | 762 | fireEvent.focus(minuteInput); 763 | 764 | const clock = container.querySelector('.react-clock'); 765 | 766 | expect(clock).toBeInTheDocument(); 767 | }); 768 | 769 | it('closes Calendar when changing value by default', async () => { 770 | const { container } = render(); 771 | 772 | const [firstTile, secondTile] = container.querySelectorAll( 773 | '.react-calendar__tile', 774 | ) as unknown as [HTMLButtonElement, HTMLButtonElement]; 775 | 776 | act(() => { 777 | fireEvent.click(firstTile); 778 | }); 779 | 780 | act(() => { 781 | fireEvent.click(secondTile); 782 | }); 783 | 784 | await waitForElementToBeRemovedOrHidden(() => 785 | container.querySelector('.react-datetimerange-picker__calendar'), 786 | ); 787 | }); 788 | 789 | it('closes Calendar when changing value with prop closeWidgets = true', async () => { 790 | const { container } = render(); 791 | 792 | const [firstTile, secondTile] = container.querySelectorAll( 793 | '.react-calendar__tile', 794 | ) as unknown as [HTMLButtonElement, HTMLButtonElement]; 795 | 796 | act(() => { 797 | fireEvent.click(firstTile); 798 | }); 799 | 800 | act(() => { 801 | fireEvent.click(secondTile); 802 | }); 803 | 804 | await waitForElementToBeRemovedOrHidden(() => 805 | container.querySelector('.react-datetimerange-picker__calendar'), 806 | ); 807 | }); 808 | 809 | it('does not close Calendar when changing value with prop closeWidgets = false', () => { 810 | const { container } = render(); 811 | 812 | const [firstTile, secondTile] = container.querySelectorAll( 813 | '.react-calendar__tile', 814 | ) as unknown as [HTMLButtonElement, HTMLButtonElement]; 815 | 816 | act(() => { 817 | fireEvent.click(firstTile); 818 | }); 819 | 820 | act(() => { 821 | fireEvent.click(secondTile); 822 | }); 823 | 824 | const calendar = container.querySelector('.react-calendar'); 825 | 826 | expect(calendar).toBeInTheDocument(); 827 | }); 828 | 829 | it('does not close Calendar when changing value with shouldCloseWidgets function returning false', () => { 830 | const shouldCloseWidgets = () => false; 831 | 832 | const { container } = render( 833 | , 834 | ); 835 | 836 | const [firstTile, secondTile] = container.querySelectorAll( 837 | '.react-calendar__tile', 838 | ) as unknown as [HTMLButtonElement, HTMLButtonElement]; 839 | 840 | act(() => { 841 | fireEvent.click(firstTile); 842 | }); 843 | 844 | act(() => { 845 | fireEvent.click(secondTile); 846 | }); 847 | 848 | const calendar = container.querySelector('.react-calendar'); 849 | 850 | expect(calendar).toBeInTheDocument(); 851 | }); 852 | 853 | it('does not close Calendar when changing value using inputs', () => { 854 | const { container } = render(); 855 | 856 | const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; 857 | 858 | act(() => { 859 | fireEvent.change(dayInput, { target: { value: '1' } }); 860 | }); 861 | 862 | const calendar = container.querySelector('.react-calendar'); 863 | 864 | expect(calendar).toBeInTheDocument(); 865 | }); 866 | 867 | it('does not close Clock when changing value using inputs', () => { 868 | const { container } = render(); 869 | 870 | const hourInput = container.querySelector('input[name="hour12"]') as HTMLInputElement; 871 | 872 | act(() => { 873 | fireEvent.change(hourInput, { target: { value: '9' } }); 874 | }); 875 | 876 | const clock = container.querySelector('.react-clock'); 877 | 878 | expect(clock).toBeInTheDocument(); 879 | }); 880 | 881 | it('calls onChange callback when changing value', () => { 882 | const value = new Date(2023, 0, 31, 21, 40, 11); 883 | const onChange = vi.fn(); 884 | 885 | const { container } = render( 886 | , 887 | ); 888 | 889 | const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; 890 | 891 | act(() => { 892 | fireEvent.change(dayInput, { target: { value: '1' } }); 893 | }); 894 | 895 | expect(onChange).toHaveBeenCalledWith([new Date(2023, 0, 1, 21, 40, 11), null]); 896 | }); 897 | 898 | it('calls onChange callback with merged new date & old time when calling internal onDateChange given Date', () => { 899 | const hours = 21; 900 | const minutes = 40; 901 | const seconds = 11; 902 | const ms = 458; 903 | 904 | const onChange = vi.fn(); 905 | const valueFrom = new Date(2018, 6, 17, hours, minutes, seconds, ms); 906 | const nextValueFrom = new Date(2019, 0, 1, hours, minutes, seconds, ms); 907 | const valueTo = new Date(2019, 6, 17); 908 | 909 | const { container, getByRole } = render( 910 | , 911 | ); 912 | 913 | // Navigate up the calendar 914 | const drillUpButton = container.querySelector( 915 | '.react-calendar__navigation__label', 916 | ) as HTMLButtonElement; 917 | fireEvent.click(drillUpButton); // To year 2018 918 | fireEvent.click(drillUpButton); // To 2011 – 2020 decade 919 | 920 | // Click year 2019 921 | const twentyNineteenButton = getByRole('button', { name: '2019' }); 922 | fireEvent.click(twentyNineteenButton); 923 | 924 | // Click January 925 | const januaryButton = getByRole('button', { name: 'January 2019' }); 926 | fireEvent.click(januaryButton); 927 | 928 | // Click 1st 929 | const firstButton = getByRole('button', { name: 'January 1, 2019' }); 930 | fireEvent.click(firstButton); 931 | 932 | // Navigate up the calendar 933 | fireEvent.click(drillUpButton); // To year 2019 934 | 935 | // Click July 936 | const julyButton = getByRole('button', { name: 'July 2019' }); 937 | fireEvent.click(julyButton); 938 | 939 | // Click 17th 940 | const seventeenthButton = getByRole('button', { name: 'July 17, 2019' }); 941 | fireEvent.click(seventeenthButton); 942 | 943 | expect(onChange).toHaveBeenCalledWith([nextValueFrom, valueTo]); 944 | }); 945 | 946 | it('calls onChange callback with merged new date & old time when calling internal onDateChange given ISO string', () => { 947 | const hours = 21; 948 | const minutes = 40; 949 | const seconds = 11; 950 | const ms = 458; 951 | 952 | const onChange = vi.fn(); 953 | const valueFrom = new Date(2018, 6, 17, hours, minutes, seconds, ms); 954 | const nextValueFrom = new Date(2019, 0, 1, hours, minutes, seconds, ms); 955 | const valueTo = new Date(2019, 6, 17); 956 | 957 | const { container, getByRole } = render( 958 | , 959 | ); 960 | 961 | // Navigate up the calendar 962 | const drillUpButton = container.querySelector( 963 | '.react-calendar__navigation__label', 964 | ) as HTMLButtonElement; 965 | fireEvent.click(drillUpButton); // To year 2018 966 | fireEvent.click(drillUpButton); // To 2011 – 2020 decade 967 | 968 | // Click year 2019 969 | const twentyNineteenButton = getByRole('button', { name: '2019' }); 970 | fireEvent.click(twentyNineteenButton); 971 | 972 | // Click January 973 | const januaryButton = getByRole('button', { name: 'January 2019' }); 974 | fireEvent.click(januaryButton); 975 | 976 | // Click 1st 977 | const firstButton = getByRole('button', { name: 'January 1, 2019' }); 978 | fireEvent.click(firstButton); 979 | 980 | // Navigate up the calendar 981 | fireEvent.click(drillUpButton); // To year 2019 982 | 983 | // Click July 984 | const julyButton = getByRole('button', { name: 'July 2019' }); 985 | fireEvent.click(julyButton); 986 | 987 | // Click 17th 988 | const seventeenthButton = getByRole('button', { name: 'July 17, 2019' }); 989 | fireEvent.click(seventeenthButton); 990 | 991 | expect(onChange).toHaveBeenCalledWith([nextValueFrom, valueTo]); 992 | }); 993 | 994 | it('calls onChange callback with merged new date & old time when calling internal onDateChange given Date', () => { 995 | const hours = 21; 996 | const minutes = 40; 997 | const seconds = 11; 998 | const ms = 458; 999 | 1000 | const onChange = vi.fn(); 1001 | const valueFrom = new Date(2018, 6, 17); 1002 | const valueTo = new Date(2019, 6, 17, hours, minutes, seconds, ms); 1003 | const nextValueTo = new Date(2019, 0, 1, hours, minutes, seconds, ms); 1004 | 1005 | const { container, getByRole } = render( 1006 | , 1007 | ); 1008 | 1009 | // Click 17th 1010 | const seventeenthButton = getByRole('button', { name: 'July 17, 2018' }); 1011 | fireEvent.click(seventeenthButton); 1012 | 1013 | // Navigate up the calendar 1014 | const drillUpButton = container.querySelector( 1015 | '.react-calendar__navigation__label', 1016 | ) as HTMLButtonElement; 1017 | fireEvent.click(drillUpButton); // To year 2018 1018 | fireEvent.click(drillUpButton); // To 2011 – 2020 decade 1019 | 1020 | // Click year 2019 1021 | const twentyNineteenButton = getByRole('button', { name: '2019' }); 1022 | fireEvent.click(twentyNineteenButton); 1023 | 1024 | // Click January 1025 | const januaryButton = getByRole('button', { name: 'January 2019' }); 1026 | fireEvent.click(januaryButton); 1027 | 1028 | // Click 1st 1029 | const firstButton = getByRole('button', { name: 'January 1, 2019' }); 1030 | fireEvent.click(firstButton); 1031 | 1032 | expect(onChange).toHaveBeenCalledWith([valueFrom, nextValueTo]); 1033 | }); 1034 | 1035 | it('calls onChange callback with merged new date & old time when calling internal onDateChange given ISO string', () => { 1036 | const hours = 21; 1037 | const minutes = 40; 1038 | const seconds = 11; 1039 | const ms = 458; 1040 | 1041 | const onChange = vi.fn(); 1042 | const valueFrom = new Date(2018, 6, 17); 1043 | const valueTo = new Date(2019, 6, 17, hours, minutes, seconds, ms); 1044 | const nextValueTo = new Date(2019, 0, 1, hours, minutes, seconds, ms); 1045 | 1046 | const { container, getByRole } = render( 1047 | , 1048 | ); 1049 | 1050 | // Click 17th 1051 | const seventeenthButton = getByRole('button', { name: 'July 17, 2018' }); 1052 | fireEvent.click(seventeenthButton); 1053 | 1054 | // Navigate up the calendar 1055 | const drillUpButton = container.querySelector( 1056 | '.react-calendar__navigation__label', 1057 | ) as HTMLButtonElement; 1058 | fireEvent.click(drillUpButton); // To year 2018 1059 | fireEvent.click(drillUpButton); // To 2011 – 2020 decade 1060 | 1061 | // Click year 2019 1062 | const twentyNineteenButton = getByRole('button', { name: '2019' }); 1063 | fireEvent.click(twentyNineteenButton); 1064 | 1065 | // Click January 1066 | const januaryButton = getByRole('button', { name: 'January 2019' }); 1067 | fireEvent.click(januaryButton); 1068 | 1069 | // Click 1st 1070 | const firstButton = getByRole('button', { name: 'January 1, 2019' }); 1071 | fireEvent.click(firstButton); 1072 | 1073 | expect(onChange).toHaveBeenCalledWith([valueFrom, nextValueTo]); 1074 | }); 1075 | 1076 | it('calls onInvalidChange callback when changing value to an invalid one', () => { 1077 | const value = new Date(2023, 0, 31, 21, 40, 11); 1078 | const onInvalidChange = vi.fn(); 1079 | 1080 | const { container } = render( 1081 | , 1082 | ); 1083 | 1084 | const dayInput = container.querySelector('input[name="day"]') as HTMLInputElement; 1085 | 1086 | act(() => { 1087 | fireEvent.change(dayInput, { target: { value: '32' } }); 1088 | }); 1089 | 1090 | expect(onInvalidChange).toHaveBeenCalled(); 1091 | }); 1092 | 1093 | it('clears the value when clicking on a button', () => { 1094 | const onChange = vi.fn(); 1095 | 1096 | const { container } = render(); 1097 | 1098 | const calendar = container.querySelector('.react-calendar'); 1099 | const button = container.querySelector( 1100 | 'button.react-datetimerange-picker__clear-button', 1101 | ) as HTMLButtonElement; 1102 | 1103 | expect(calendar).toBeFalsy(); 1104 | 1105 | fireEvent.click(button); 1106 | 1107 | expect(onChange).toHaveBeenCalledWith(null); 1108 | }); 1109 | 1110 | describe('onChangeFrom', () => { 1111 | it('calls onChange properly given no initial value', () => { 1112 | const onChange = vi.fn(); 1113 | 1114 | const { container } = render( 1115 | , 1116 | ); 1117 | 1118 | const nextValueFrom = new Date(2018, 1, 15, 12, 30, 45); 1119 | 1120 | const customInputs = container.querySelectorAll('input[data-input]'); 1121 | const monthInput = customInputs[0] as HTMLInputElement; 1122 | const dayInput = customInputs[1] as HTMLInputElement; 1123 | const yearInput = customInputs[2] as HTMLInputElement; 1124 | const hourInput = customInputs[3] as HTMLInputElement; 1125 | const minuteInput = customInputs[4] as HTMLInputElement; 1126 | const secondInput = customInputs[5] as HTMLInputElement; 1127 | 1128 | act(() => { 1129 | fireEvent.change(monthInput, { target: { value: '2' } }); 1130 | }); 1131 | 1132 | act(() => { 1133 | fireEvent.change(dayInput, { target: { value: '15' } }); 1134 | }); 1135 | 1136 | act(() => { 1137 | fireEvent.change(yearInput, { target: { value: '2018' } }); 1138 | }); 1139 | 1140 | act(() => { 1141 | fireEvent.change(hourInput, { target: { value: '12' } }); 1142 | }); 1143 | 1144 | act(() => { 1145 | fireEvent.change(minuteInput, { target: { value: '30' } }); 1146 | }); 1147 | 1148 | act(() => { 1149 | fireEvent.change(secondInput, { target: { value: '45' } }); 1150 | }); 1151 | 1152 | expect(onChange).toHaveBeenCalled(); 1153 | expect(onChange).toHaveBeenCalledWith([nextValueFrom, null]); 1154 | }); 1155 | 1156 | it('calls onChange properly given single initial value', () => { 1157 | const onChange = vi.fn(); 1158 | const value = new Date(2018, 0, 1); 1159 | 1160 | const { container } = render( 1161 | , 1167 | ); 1168 | 1169 | const nextValueFrom = new Date(2018, 1, 15, 12, 30, 45); 1170 | 1171 | const customInputs = container.querySelectorAll('input[data-input]'); 1172 | const monthInput = customInputs[0] as HTMLInputElement; 1173 | const dayInput = customInputs[1] as HTMLInputElement; 1174 | const yearInput = customInputs[2] as HTMLInputElement; 1175 | const hourInput = customInputs[3] as HTMLInputElement; 1176 | const minuteInput = customInputs[4] as HTMLInputElement; 1177 | const secondInput = customInputs[5] as HTMLInputElement; 1178 | 1179 | act(() => { 1180 | fireEvent.change(monthInput, { target: { value: '2' } }); 1181 | }); 1182 | 1183 | act(() => { 1184 | fireEvent.change(dayInput, { target: { value: '15' } }); 1185 | }); 1186 | 1187 | act(() => { 1188 | fireEvent.change(yearInput, { target: { value: '2018' } }); 1189 | }); 1190 | 1191 | act(() => { 1192 | fireEvent.change(hourInput, { target: { value: '12' } }); 1193 | }); 1194 | 1195 | act(() => { 1196 | fireEvent.change(minuteInput, { target: { value: '30' } }); 1197 | }); 1198 | 1199 | act(() => { 1200 | fireEvent.change(secondInput, { target: { value: '45' } }); 1201 | }); 1202 | 1203 | expect(onChange).toHaveBeenCalled(); 1204 | expect(onChange).toHaveBeenCalledWith([nextValueFrom, null]); 1205 | }); 1206 | 1207 | it('calls onChange properly given initial value as an array', () => { 1208 | const onChange = vi.fn(); 1209 | const valueFrom = new Date(2018, 0, 1); 1210 | const valueTo = new Date(2018, 6, 1); 1211 | const value = [valueFrom, valueTo] as [Date, Date]; 1212 | 1213 | const { container } = render( 1214 | , 1220 | ); 1221 | 1222 | const nextValueFrom = new Date(2018, 1, 15, 12, 30, 45); 1223 | 1224 | const customInputs = container.querySelectorAll('input[data-input]'); 1225 | const monthInput = customInputs[0] as HTMLInputElement; 1226 | const dayInput = customInputs[1] as HTMLInputElement; 1227 | const yearInput = customInputs[2] as HTMLInputElement; 1228 | const hourInput = customInputs[3] as HTMLInputElement; 1229 | const minuteInput = customInputs[4] as HTMLInputElement; 1230 | const secondInput = customInputs[5] as HTMLInputElement; 1231 | 1232 | act(() => { 1233 | fireEvent.change(monthInput, { target: { value: '2' } }); 1234 | }); 1235 | 1236 | act(() => { 1237 | fireEvent.change(dayInput, { target: { value: '15' } }); 1238 | }); 1239 | 1240 | act(() => { 1241 | fireEvent.change(yearInput, { target: { value: '2018' } }); 1242 | }); 1243 | 1244 | act(() => { 1245 | fireEvent.change(hourInput, { target: { value: '12' } }); 1246 | }); 1247 | 1248 | act(() => { 1249 | fireEvent.change(minuteInput, { target: { value: '30' } }); 1250 | }); 1251 | 1252 | act(() => { 1253 | fireEvent.change(secondInput, { target: { value: '45' } }); 1254 | }); 1255 | 1256 | expect(onChange).toHaveBeenCalled(); 1257 | expect(onChange).toHaveBeenCalledWith([nextValueFrom, valueTo]); 1258 | }); 1259 | }); 1260 | 1261 | describe('onChangeTo', () => { 1262 | it('calls onChange properly given no initial value', () => { 1263 | const onChange = vi.fn(); 1264 | 1265 | const { container } = render( 1266 | , 1267 | ); 1268 | 1269 | const nextValueTo = new Date(2018, 1, 15, 12, 30, 45); 1270 | 1271 | const customInputs = container.querySelectorAll('input[data-input]'); 1272 | const monthInput = customInputs[6] as HTMLInputElement; 1273 | const dayInput = customInputs[7] as HTMLInputElement; 1274 | const yearInput = customInputs[8] as HTMLInputElement; 1275 | const hourInput = customInputs[9] as HTMLInputElement; 1276 | const minuteInput = customInputs[10] as HTMLInputElement; 1277 | const secondInput = customInputs[11] as HTMLInputElement; 1278 | act(() => { 1279 | fireEvent.change(dayInput, { target: { value: '15' } }); 1280 | }); 1281 | 1282 | act(() => { 1283 | fireEvent.change(monthInput, { target: { value: '2' } }); 1284 | }); 1285 | 1286 | act(() => { 1287 | fireEvent.change(yearInput, { target: { value: '2018' } }); 1288 | }); 1289 | 1290 | act(() => { 1291 | fireEvent.change(hourInput, { target: { value: '12' } }); 1292 | }); 1293 | 1294 | act(() => { 1295 | fireEvent.change(minuteInput, { target: { value: '30' } }); 1296 | }); 1297 | 1298 | act(() => { 1299 | fireEvent.change(secondInput, { target: { value: '45' } }); 1300 | }); 1301 | 1302 | expect(onChange).toHaveBeenCalled(); 1303 | expect(onChange).toHaveBeenCalledWith([null, nextValueTo]); 1304 | }); 1305 | 1306 | it('calls onChange properly given single initial value', () => { 1307 | const onChange = vi.fn(); 1308 | const value = new Date(2018, 0, 1); 1309 | 1310 | const { container } = render( 1311 | , 1317 | ); 1318 | 1319 | const nextValueTo = new Date(2018, 1, 15, 12, 30, 45); 1320 | 1321 | const customInputs = container.querySelectorAll('input[data-input]'); 1322 | const monthInput = customInputs[6] as HTMLInputElement; 1323 | const dayInput = customInputs[7] as HTMLInputElement; 1324 | const yearInput = customInputs[8] as HTMLInputElement; 1325 | const hourInput = customInputs[9] as HTMLInputElement; 1326 | const minuteInput = customInputs[10] as HTMLInputElement; 1327 | const secondInput = customInputs[11] as HTMLInputElement; 1328 | 1329 | act(() => { 1330 | fireEvent.change(dayInput, { target: { value: '15' } }); 1331 | }); 1332 | 1333 | act(() => { 1334 | fireEvent.change(monthInput, { target: { value: '2' } }); 1335 | }); 1336 | 1337 | act(() => { 1338 | fireEvent.change(yearInput, { target: { value: '2018' } }); 1339 | }); 1340 | 1341 | act(() => { 1342 | fireEvent.change(hourInput, { target: { value: '12' } }); 1343 | }); 1344 | 1345 | act(() => { 1346 | fireEvent.change(minuteInput, { target: { value: '30' } }); 1347 | }); 1348 | 1349 | act(() => { 1350 | fireEvent.change(secondInput, { target: { value: '45' } }); 1351 | }); 1352 | 1353 | expect(onChange).toHaveBeenCalled(); 1354 | expect(onChange).toHaveBeenCalledWith([value, nextValueTo]); 1355 | }); 1356 | 1357 | it('calls onChange properly given initial value as an array', () => { 1358 | const onChange = vi.fn(); 1359 | const valueFrom = new Date(2018, 0, 1); 1360 | const valueTo = new Date(2018, 6, 1); 1361 | const value = [valueFrom, valueTo] as [Date, Date]; 1362 | 1363 | const { container } = render( 1364 | , 1370 | ); 1371 | 1372 | const nextValueTo = new Date(2018, 1, 15, 12, 30, 45); 1373 | 1374 | const customInputs = container.querySelectorAll('input[data-input]'); 1375 | const monthInput = customInputs[6] as HTMLInputElement; 1376 | const dayInput = customInputs[7] as HTMLInputElement; 1377 | const yearInput = customInputs[8] as HTMLInputElement; 1378 | const hourInput = customInputs[9] as HTMLInputElement; 1379 | const minuteInput = customInputs[10] as HTMLInputElement; 1380 | const secondInput = customInputs[11] as HTMLInputElement; 1381 | 1382 | act(() => { 1383 | fireEvent.change(dayInput, { target: { value: '15' } }); 1384 | }); 1385 | 1386 | act(() => { 1387 | fireEvent.change(monthInput, { target: { value: '2' } }); 1388 | }); 1389 | 1390 | act(() => { 1391 | fireEvent.change(yearInput, { target: { value: '2018' } }); 1392 | }); 1393 | 1394 | act(() => { 1395 | fireEvent.change(hourInput, { target: { value: '12' } }); 1396 | }); 1397 | 1398 | act(() => { 1399 | fireEvent.change(minuteInput, { target: { value: '30' } }); 1400 | }); 1401 | 1402 | act(() => { 1403 | fireEvent.change(secondInput, { target: { value: '45' } }); 1404 | }); 1405 | 1406 | expect(onChange).toHaveBeenCalled(); 1407 | expect(onChange).toHaveBeenCalledWith([valueFrom, nextValueTo]); 1408 | }); 1409 | }); 1410 | it('calls onClick callback when clicked a page (sample of mouse events family)', () => { 1411 | const onClick = vi.fn(); 1412 | 1413 | const { container } = render(); 1414 | 1415 | const wrapper = container.firstElementChild as HTMLDivElement; 1416 | fireEvent.click(wrapper); 1417 | 1418 | expect(onClick).toHaveBeenCalled(); 1419 | }); 1420 | 1421 | it('calls onTouchStart callback when touched a page (sample of touch events family)', () => { 1422 | const onTouchStart = vi.fn(); 1423 | 1424 | const { container } = render(); 1425 | 1426 | const wrapper = container.firstElementChild as HTMLDivElement; 1427 | fireEvent.touchStart(wrapper); 1428 | 1429 | expect(onTouchStart).toHaveBeenCalled(); 1430 | }); 1431 | }); 1432 | -------------------------------------------------------------------------------- /packages/react-datetimerange-picker/src/DateTimeRangePicker.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 'react-datetime-picker/dist/esm/DateTimeInput'; 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-datetimerange-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 DateTimeRangePickerProps = { 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-datetimerange-picker"` to the main React-DateTimeRange-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-DateTimeRange-Picker `
` element. 138 | * 139 | * @example 'datetimerange-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 range 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-DateTimeRange-Picker `
` element. 199 | * 200 | * @example 'datetimerange-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-DateRange-Picker](https://github.com/wojtekmaj/react-daterange-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 'datetimerange' 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 | * Divider between datetime inputs. 343 | * 344 | * @default '–' 345 | * @example ' to ' 346 | */ 347 | rangeDivider?: React.ReactNode; 348 | /** 349 | * Whether datetime input should be required. 350 | * 351 | * @default false 352 | * @example true 353 | */ 354 | required?: boolean; 355 | /** 356 | * `aria-label` for the second input. 357 | * 358 | * @example 'Second' 359 | */ 360 | secondAriaLabel?: string; 361 | /** 362 | * `placeholder` for the second input. 363 | * 364 | * @default '--' 365 | * @example 'ss' 366 | */ 367 | secondPlaceholder?: string; 368 | /** 369 | * 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. 370 | * 371 | * @example ({ reason, widget }) => reason !== 'outsideAction' && widget === 'calendar'` 372 | */ 373 | shouldCloseWidgets?: (props: { reason: CloseReason; widget: 'calendar' | 'clock' }) => boolean; 374 | /** 375 | * 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. 376 | * 377 | * @example ({ reason, widget }) => reason !== 'focus' && widget === 'calendar'` 378 | */ 379 | shouldOpenWidgets?: (props: { reason: OpenReason; widget: 'calendar' | 'clock' }) => boolean; 380 | /** 381 | * Whether leading zeros should be rendered in datetime inputs. 382 | * 383 | * @default false 384 | * @example true 385 | */ 386 | showLeadingZeros?: boolean; 387 | /** 388 | * Input value. 389 | * 390 | * @example new Date(2017, 0, 1, 22, 15) 391 | * @example [new Date(2017, 0, 1, 22, 15), new Date(2017, 0, 1, 23, 45)] 392 | * @example ["2017-01-01T22:15:00", "2017-01-01T23:45:00"] 393 | */ 394 | value?: LooseValue; 395 | /** 396 | * `aria-label` for the year input. 397 | * 398 | * @example 'Year' 399 | */ 400 | yearAriaLabel?: string; 401 | /** 402 | * `placeholder` for the year input. 403 | * 404 | * @default '----' 405 | * @example 'yyyy' 406 | */ 407 | yearPlaceholder?: string; 408 | } & Omit; 409 | 410 | export default function DateTimeRangePicker(props: DateTimeRangePickerProps): React.ReactElement { 411 | const { 412 | amPmAriaLabel, 413 | autoFocus, 414 | calendarAriaLabel, 415 | calendarIcon = CalendarIcon, 416 | className, 417 | clearAriaLabel, 418 | clearIcon = ClearIcon, 419 | closeWidgets: shouldCloseWidgetsOnSelect = true, 420 | 'data-testid': dataTestid, 421 | dayAriaLabel, 422 | dayPlaceholder, 423 | disableCalendar, 424 | disableClock, 425 | disabled, 426 | format, 427 | hourAriaLabel, 428 | hourPlaceholder, 429 | id, 430 | isCalendarOpen: isCalendarOpenProps = null, 431 | isClockOpen: isClockOpenProps = null, 432 | locale, 433 | maxDate, 434 | maxDetail = 'minute', 435 | minDate, 436 | minuteAriaLabel, 437 | minutePlaceholder, 438 | monthAriaLabel, 439 | monthPlaceholder, 440 | name = 'datetimerange', 441 | nativeInputAriaLabel, 442 | onCalendarClose, 443 | onCalendarOpen, 444 | onChange: onChangeProps, 445 | onClockClose, 446 | onClockOpen, 447 | onFocus: onFocusProps, 448 | onInvalidChange, 449 | openWidgetsOnFocus = true, 450 | rangeDivider = '–', 451 | required, 452 | secondAriaLabel, 453 | secondPlaceholder, 454 | shouldCloseWidgets, 455 | shouldOpenWidgets, 456 | showLeadingZeros, 457 | value, 458 | yearAriaLabel, 459 | yearPlaceholder, 460 | ...otherProps 461 | } = props; 462 | 463 | const [isCalendarOpen, setIsCalendarOpen] = useState(isCalendarOpenProps); 464 | const [isClockOpen, setIsClockOpen] = useState(isClockOpenProps); 465 | const wrapper = useRef(null); 466 | const calendarWrapper = useRef(null); 467 | const clockWrapper = useRef(null); 468 | 469 | useEffect(() => { 470 | setIsCalendarOpen(isCalendarOpenProps); 471 | }, [isCalendarOpenProps]); 472 | 473 | useEffect(() => { 474 | setIsClockOpen(isClockOpenProps); 475 | }, [isClockOpenProps]); 476 | 477 | function openCalendar({ reason }: { reason: OpenReason }) { 478 | if (shouldOpenWidgets) { 479 | if (!shouldOpenWidgets({ reason, widget: 'calendar' })) { 480 | return; 481 | } 482 | } 483 | 484 | setIsClockOpen(isClockOpen ? false : isCalendarOpen); 485 | setIsCalendarOpen(true); 486 | 487 | if (onCalendarOpen) { 488 | onCalendarOpen(); 489 | } 490 | } 491 | 492 | const closeCalendar = useCallback( 493 | ({ reason }: { reason: CloseReason }) => { 494 | if (shouldCloseWidgets) { 495 | if (!shouldCloseWidgets({ reason, widget: 'calendar' })) { 496 | return; 497 | } 498 | } 499 | 500 | setIsCalendarOpen(false); 501 | 502 | if (onCalendarClose) { 503 | onCalendarClose(); 504 | } 505 | }, 506 | [onCalendarClose, shouldCloseWidgets], 507 | ); 508 | 509 | function toggleCalendar() { 510 | if (isCalendarOpen) { 511 | closeCalendar({ reason: 'buttonClick' }); 512 | } else { 513 | openCalendar({ reason: 'buttonClick' }); 514 | } 515 | } 516 | 517 | function openClock({ reason }: { reason: OpenReason }) { 518 | if (shouldOpenWidgets) { 519 | if (!shouldOpenWidgets({ reason, widget: 'clock' })) { 520 | return; 521 | } 522 | } 523 | 524 | setIsCalendarOpen(isCalendarOpen ? false : isCalendarOpen); 525 | setIsClockOpen(true); 526 | 527 | if (onClockOpen) { 528 | onClockOpen(); 529 | } 530 | } 531 | 532 | const closeClock = useCallback( 533 | ({ reason }: { reason: CloseReason }) => { 534 | if (shouldCloseWidgets) { 535 | if (!shouldCloseWidgets({ reason, widget: 'clock' })) { 536 | return; 537 | } 538 | } 539 | 540 | setIsClockOpen(false); 541 | 542 | if (onClockClose) { 543 | onClockClose(); 544 | } 545 | }, 546 | [onClockClose, shouldCloseWidgets], 547 | ); 548 | 549 | const closeWidgets = useCallback( 550 | ({ reason }: { reason: CloseReason }) => { 551 | closeCalendar({ reason }); 552 | closeClock({ reason }); 553 | }, 554 | [closeCalendar, closeClock], 555 | ); 556 | 557 | function onChange(value: Value, shouldCloseWidgets = shouldCloseWidgetsOnSelect) { 558 | if (shouldCloseWidgets) { 559 | closeWidgets({ reason: 'select' }); 560 | } 561 | 562 | if (onChangeProps) { 563 | onChangeProps(value); 564 | } 565 | } 566 | 567 | function onChangeFrom(valueFrom: Date | null, closeCalendar: boolean) { 568 | const [, valueTo] = Array.isArray(value) ? value : [value]; 569 | 570 | const valueToDate = valueTo ? new Date(valueTo) : null; 571 | 572 | onChange([valueFrom, valueToDate], closeCalendar); 573 | } 574 | 575 | function onChangeTo(valueTo: Date | null, closeCalendar: boolean) { 576 | const [valueFrom] = Array.isArray(value) ? value : [value]; 577 | 578 | const valueFromDate = valueFrom ? new Date(valueFrom) : null; 579 | 580 | onChange([valueFromDate, valueTo], closeCalendar); 581 | } 582 | 583 | type DatePiece = Date | null; 584 | 585 | function onDateChange( 586 | nextValue: DatePiece | [DatePiece, DatePiece], 587 | shouldCloseWidgets?: boolean, 588 | ) { 589 | // React-Calendar passes an array of values when selectRange is enabled 590 | const [rawNextValueFrom, rawNextValueTo] = Array.isArray(nextValue) ? nextValue : [nextValue]; 591 | const [valueFrom, valueTo] = Array.isArray(value) ? value : [value]; 592 | 593 | const nextValueFrom = (() => { 594 | if (!valueFrom || !rawNextValueFrom) { 595 | return rawNextValueFrom; 596 | } 597 | 598 | const valueFromDate = new Date(valueFrom); 599 | const nextValueFromWithHour = new Date(rawNextValueFrom); 600 | nextValueFromWithHour.setHours( 601 | valueFromDate.getHours(), 602 | valueFromDate.getMinutes(), 603 | valueFromDate.getSeconds(), 604 | valueFromDate.getMilliseconds(), 605 | ); 606 | 607 | return nextValueFromWithHour; 608 | })(); 609 | 610 | const nextValueTo = (() => { 611 | if (!valueTo || !rawNextValueTo) { 612 | return rawNextValueTo; 613 | } 614 | 615 | const valueToDate = new Date(valueTo); 616 | const nextValueToWithHour = new Date(rawNextValueTo); 617 | nextValueToWithHour.setHours( 618 | valueToDate.getHours(), 619 | valueToDate.getMinutes(), 620 | valueToDate.getSeconds(), 621 | valueToDate.getMilliseconds(), 622 | ); 623 | 624 | return nextValueToWithHour; 625 | })(); 626 | 627 | onChange([nextValueFrom || null, nextValueTo || null], shouldCloseWidgets); 628 | } 629 | 630 | function onFocus(event: React.FocusEvent) { 631 | if (onFocusProps) { 632 | onFocusProps(event); 633 | } 634 | 635 | if ( 636 | // Internet Explorer still fires onFocus on disabled elements 637 | disabled || 638 | !openWidgetsOnFocus || 639 | event.target.dataset.select === 'true' 640 | ) { 641 | return; 642 | } 643 | 644 | switch (event.target.name) { 645 | case 'day': 646 | case 'month': 647 | case 'year': { 648 | if (isCalendarOpen) { 649 | return; 650 | } 651 | 652 | openCalendar({ reason: 'focus' }); 653 | break; 654 | } 655 | case 'hour12': 656 | case 'hour24': 657 | case 'minute': 658 | case 'second': { 659 | if (isClockOpen) { 660 | return; 661 | } 662 | 663 | openClock({ reason: 'focus' }); 664 | break; 665 | } 666 | default: 667 | } 668 | } 669 | 670 | const onKeyDown = useCallback( 671 | (event: KeyboardEvent) => { 672 | if (event.key === 'Escape') { 673 | closeWidgets({ reason: 'escape' }); 674 | } 675 | }, 676 | [closeWidgets], 677 | ); 678 | 679 | function clear() { 680 | onChange(null); 681 | } 682 | 683 | function stopPropagation(event: React.FocusEvent) { 684 | event.stopPropagation(); 685 | } 686 | 687 | const onOutsideAction = useCallback( 688 | (event: Event) => { 689 | const { current: wrapperEl } = wrapper; 690 | const { current: calendarWrapperEl } = calendarWrapper; 691 | const { current: clockWrapperEl } = clockWrapper; 692 | 693 | // Try event.composedPath first to handle clicks inside a Shadow DOM. 694 | const target = ( 695 | 'composedPath' in event ? event.composedPath()[0] : (event as Event).target 696 | ) as HTMLElement; 697 | 698 | if ( 699 | target && 700 | wrapperEl && 701 | !wrapperEl.contains(target) && 702 | (!calendarWrapperEl || !calendarWrapperEl.contains(target)) && 703 | (!clockWrapperEl || !clockWrapperEl.contains(target)) 704 | ) { 705 | closeWidgets({ reason: 'outsideAction' }); 706 | } 707 | }, 708 | [closeWidgets], 709 | ); 710 | 711 | const handleOutsideActionListeners = useCallback( 712 | (shouldListen = isCalendarOpen || isClockOpen) => { 713 | for (const event of outsideActionEvents) { 714 | if (shouldListen) { 715 | document.addEventListener(event, onOutsideAction); 716 | } else { 717 | document.removeEventListener(event, onOutsideAction); 718 | } 719 | } 720 | 721 | if (shouldListen) { 722 | document.addEventListener('keydown', onKeyDown); 723 | } else { 724 | document.removeEventListener('keydown', onKeyDown); 725 | } 726 | }, 727 | [isCalendarOpen, isClockOpen, onOutsideAction, onKeyDown], 728 | ); 729 | 730 | useEffect(() => { 731 | handleOutsideActionListeners(); 732 | 733 | return () => { 734 | handleOutsideActionListeners(false); 735 | }; 736 | }, [handleOutsideActionListeners]); 737 | 738 | function renderInputs() { 739 | const [valueFrom, valueTo] = Array.isArray(value) ? value : [value]; 740 | 741 | const ariaLabelProps = { 742 | amPmAriaLabel, 743 | dayAriaLabel, 744 | hourAriaLabel, 745 | minuteAriaLabel, 746 | monthAriaLabel, 747 | nativeInputAriaLabel, 748 | secondAriaLabel, 749 | yearAriaLabel, 750 | }; 751 | 752 | const placeholderProps = { 753 | dayPlaceholder, 754 | hourPlaceholder, 755 | minutePlaceholder, 756 | monthPlaceholder, 757 | secondPlaceholder, 758 | yearPlaceholder, 759 | }; 760 | 761 | const commonProps = { 762 | ...ariaLabelProps, 763 | ...placeholderProps, 764 | className: `${baseClassName}__inputGroup`, 765 | disabled, 766 | format, 767 | isWidgetOpen: isCalendarOpen || isClockOpen, 768 | locale, 769 | maxDate, 770 | maxDetail, 771 | minDate, 772 | onInvalidChange, 773 | required, 774 | showLeadingZeros, 775 | }; 776 | 777 | return ( 778 |
779 | 786 | {rangeDivider} 787 | 788 | {clearIcon !== null && ( 789 | 799 | )} 800 | {calendarIcon !== null && !disableCalendar && ( 801 | 812 | )} 813 |
814 | ); 815 | } 816 | 817 | function renderCalendar() { 818 | if (isCalendarOpen === null || disableCalendar) { 819 | return null; 820 | } 821 | 822 | const { calendarProps, portalContainer, value } = props; 823 | 824 | const className = `${baseClassName}__calendar`; 825 | const classNames = clsx(className, `${className}--${isCalendarOpen ? 'open' : 'closed'}`); 826 | 827 | const calendar = ( 828 | onDateChange(value)} 833 | selectRange 834 | value={value} 835 | {...calendarProps} 836 | /> 837 | ); 838 | 839 | return portalContainer ? ( 840 | createPortal( 841 |
842 | {calendar} 843 |
, 844 | portalContainer, 845 | ) 846 | ) : ( 847 | 848 |
{ 850 | if (ref && !isCalendarOpen) { 851 | ref.removeAttribute('style'); 852 | } 853 | }} 854 | className={classNames} 855 | > 856 | {calendar} 857 |
858 |
859 | ); 860 | } 861 | 862 | function renderClock() { 863 | if (isClockOpen === null || disableClock) { 864 | return null; 865 | } 866 | 867 | const { clockProps, maxDetail = 'minute', portalContainer, value } = props; 868 | 869 | const className = `${baseClassName}__clock`; 870 | const classNames = clsx(className, `${className}--${isClockOpen ? 'open' : 'closed'}`); 871 | 872 | const [valueFrom] = Array.isArray(value) ? value : [value]; 873 | 874 | const maxDetailIndex = allViews.indexOf(maxDetail); 875 | 876 | const clock = ( 877 | 0} 880 | renderSecondHand={maxDetailIndex > 1} 881 | value={valueFrom} 882 | {...clockProps} 883 | /> 884 | ); 885 | 886 | return portalContainer ? ( 887 | createPortal( 888 |
889 | {clock} 890 |
, 891 | portalContainer, 892 | ) 893 | ) : ( 894 | 895 |
{ 897 | if (ref && !isClockOpen) { 898 | ref.removeAttribute('style'); 899 | } 900 | }} 901 | className={classNames} 902 | > 903 | {clock} 904 |
905 |
906 | ); 907 | } 908 | 909 | const eventProps = useMemo( 910 | () => makeEventProps(otherProps), 911 | // biome-ignore lint/correctness/useExhaustiveDependencies: FIXME 912 | [otherProps], 913 | ); 914 | 915 | return ( 916 |
929 | {renderInputs()} 930 | {renderCalendar()} 931 | {renderClock()} 932 |
933 | ); 934 | } 935 | -------------------------------------------------------------------------------- /packages/react-datetimerange-picker/src/index.ts: -------------------------------------------------------------------------------- 1 | import DateTimeRangePicker from './DateTimeRangePicker.js'; 2 | 3 | export type { DateTimeRangePickerProps } from './DateTimeRangePicker.js'; 4 | 5 | export { DateTimeRangePicker }; 6 | 7 | export default DateTimeRangePicker; 8 | -------------------------------------------------------------------------------- /packages/react-datetimerange-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 | type LooseValuePiece = string | Date | null; 12 | 13 | export type LooseValue = LooseValuePiece | Range; 14 | 15 | export type OpenReason = 'buttonClick' | 'focus'; 16 | 17 | type ValuePiece = Date | null; 18 | 19 | export type Value = ValuePiece | Range; 20 | -------------------------------------------------------------------------------- /packages/react-datetimerange-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-datetimerange-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-datetimerange-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-datetimerange-picker/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { afterEach } from 'vitest'; 2 | import { cleanup } from '@testing-library/react'; 3 | import '@testing-library/jest-dom/vitest'; 4 | import 'vitest-canvas-mock'; 5 | 6 | afterEach(() => { 7 | cleanup(); 8 | }); 9 | -------------------------------------------------------------------------------- /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 DateTimeRangePicker from '@wojtekmaj/react-datetimerange-picker'; 3 | 4 | import './Sample.css'; 5 | 6 | type ValuePiece = Date | null; 7 | 8 | type Value = ValuePiece | [ValuePiece, ValuePiece]; 9 | 10 | const now = new Date(); 11 | const yesterdayBegin = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); 12 | const todayNoon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12); 13 | 14 | export default function Sample() { 15 | const [value, onChange] = useState([yesterdayBegin, todayNoon]); 16 | 17 | return ( 18 |
19 |
20 |

react-datetimerange-picker sample page

21 |
22 |
23 |
24 | 25 |
26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /sample/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | react-datetimerange-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-datetimerange-picker-sample-page", 3 | "version": "3.0.0", 4 | "description": "A sample page for React-DateTimeRange-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 | "@wojtekmaj/react-datetimerange-picker": "latest", 19 | "react": "^18.2.0", 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/.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 DateTimeRangePicker from '@wojtekmaj/react-datetimerange-picker'; 3 | import '@wojtekmaj/react-datetimerange-picker/dist/DateTimeRangePicker.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 nineteenNinetyFive = new Date(1995, now.getUTCMonth() + 1, 15, 12); 20 | const fifteenthOfNextMonth = new Date(now.getUTCFullYear(), now.getUTCMonth() + 1, 15, 12); 21 | 22 | export default function Test() { 23 | const portalContainer = useRef(null); 24 | const [disabled, setDisabled] = useState(false); 25 | const [locale, setLocale] = useState(); 26 | const [maxDate, setMaxDate] = useState(fifteenthOfNextMonth); 27 | const [maxDetail, setMaxDetail] = useState('minute'); 28 | const [minDate, setMinDate] = useState(nineteenNinetyFive); 29 | const [renderInPortal, setRenderInPortal] = useState(false); 30 | const [required, setRequired] = useState(true); 31 | const [showLeadingZeros, setShowLeadingZeros] = useState(true); 32 | const [showNeighboringMonth, setShowNeighboringMonth] = useState(false); 33 | const [showWeekNumbers, setShowWeekNumbers] = useState(false); 34 | const [value, setValue] = useState(now); 35 | 36 | return ( 37 |
38 |
39 |

react-datetimerange-picker test page

40 |
41 |
42 | 67 |
68 |
{ 70 | event.preventDefault(); 71 | 72 | console.warn('Calendar triggered submitting the form.'); 73 | console.log(event); 74 | }} 75 | > 76 | console.log('Calendar closed')} 91 | onCalendarOpen={() => console.log('Calendar opened')} 92 | onChange={setValue} 93 | onClockClose={() => console.log('Clock closed')} 94 | onClockOpen={() => console.log('Clock opened')} 95 | portalContainer={renderInPortal ? portalContainer.current : undefined} 96 | required={required} 97 | showLeadingZeros={showLeadingZeros} 98 | value={value} 99 | /> 100 |
101 |
102 |
103 | 106 | 107 |
108 |
109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /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 [startDate, endDate] = Array.isArray(value) ? value : [value, null]; 12 | 13 | function setStartValue(nextStartDate: string | Date | null) { 14 | if (!nextStartDate) { 15 | setValue(endDate); 16 | return; 17 | } 18 | 19 | if (Array.isArray(value)) { 20 | setValue([nextStartDate, endDate]); 21 | } else { 22 | setValue(nextStartDate); 23 | } 24 | } 25 | 26 | function setEndValue(nextEndDate: string | Date | null) { 27 | if (!nextEndDate) { 28 | setValue(startDate || null); 29 | return; 30 | } 31 | 32 | setValue([startDate || null, nextEndDate]); 33 | } 34 | 35 | function onStartChange(event: React.ChangeEvent) { 36 | const { value: nextValue } = event.target; 37 | setStartValue(nextValue ? new Date(nextValue) : null); 38 | } 39 | 40 | function onEndChange(event: React.ChangeEvent) { 41 | const { value: nextValue } = event.target; 42 | 43 | setEndValue(nextValue ? new Date(nextValue) : null); 44 | } 45 | 46 | return ( 47 |
48 | Value options 49 | 50 |
51 | 52 | 62 |   63 | 66 | 69 |
70 | 71 |
72 | 73 | 81 |   82 | 85 | 88 |
89 |
90 | ); 91 | } 92 | -------------------------------------------------------------------------------- /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-datetimerange-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.0.0", 4 | "description": "A test page for React-DateTimeRange-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/react-datetimerange-picker": "workspace:packages/react-datetimerange-picker", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0" 25 | }, 26 | "devDependencies": { 27 | "@biomejs/biome": "1.9.0", 28 | "@types/react": "*", 29 | "@vitejs/plugin-react": "^4.3.4", 30 | "@wojtekmaj/date-utils": "^1.0.0", 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 | --------------------------------------------------------------------------------