├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .npmignore ├── .npmrc ├── LICENSE ├── README.md ├── config ├── cssTransform.js ├── enzymeConfig.js ├── fileTransform.js ├── setupTests.js └── webpack.js ├── package.json ├── public └── index.html ├── src ├── dev │ └── index.tsx ├── lib │ ├── assets │ │ └── svg │ │ │ ├── back.svg │ │ │ ├── calendar.svg │ │ │ ├── next.svg │ │ │ └── prev.svg │ ├── components │ │ └── DatePicker │ │ │ ├── BaseDatePicker.tsx │ │ │ ├── ClientOnly.tsx │ │ │ ├── DateInput.tsx │ │ │ ├── DateInputGroup.tsx │ │ │ ├── DatePicker.test.js │ │ │ ├── DatePickerProvider.tsx │ │ │ ├── Day.tsx │ │ │ ├── Dialog.tsx │ │ │ ├── DialogContentDesktop.tsx │ │ │ ├── DialogContentMobile.tsx │ │ │ ├── DialogWrapper.tsx │ │ │ ├── MonthCalendar.tsx │ │ │ ├── RangeDatePicker.examples.md │ │ │ ├── RangeDatePicker.tsx │ │ │ ├── SingleDatePicker.examples.md │ │ │ ├── SingleDatePicker.tsx │ │ │ ├── Week.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ ├── custom.d.ts │ ├── helpers │ │ └── index.ts │ ├── hooks │ │ └── useClientSide.ts │ ├── index.ts │ └── styles.scss └── types │ └── index.d.ts ├── styleguide └── index.html ├── tsconfig.json ├── tsup.config.ts ├── webpack.dev.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | .git 2 | .npmrc 3 | .yarnrc 4 | build 5 | dist 6 | node_modules 7 | coverage 8 | config 9 | styleguide 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "airbnb", 5 | "plugin:react/recommended" 6 | ], 7 | "settings": { 8 | "react": { 9 | "createClass": "createReactClass", 10 | "pragma": "React", 11 | "version": "16.6.3" 12 | }, 13 | "propWrapperFunctions": [ 14 | "forbidExtraProps" 15 | ] 16 | }, 17 | "plugins": [ 18 | "jest", 19 | "react", 20 | "flowtype", 21 | "react-hooks" 22 | ], 23 | "parserOptions": { 24 | "sourceType": "module", 25 | "ecmaVersion": 7, 26 | "ecmaFeatures": { 27 | "jsx": true 28 | } 29 | }, 30 | "env": { 31 | "jasmine": true, 32 | "browser": true, 33 | "node": true, 34 | "es6": true, 35 | "jest/globals": true 36 | }, 37 | "rules": { 38 | "semi": [ 39 | 2 40 | ], 41 | "arrow-parens": [ 42 | "error", 43 | "as-needed" 44 | ], 45 | "radix": 0, 46 | "max-len": 0, 47 | "no-shadow": 0, 48 | "global-require": 0, 49 | "no-return-assign": [ 50 | "error", 51 | "always" 52 | ], 53 | "react/jsx-props-no-spreading": 0, 54 | "react-hooks/rules-of-hooks": "error", 55 | "newline-before-return": "error", 56 | "react/no-danger": 0, 57 | "import/extensions": 0, 58 | "no-nested-ternary": 0, 59 | "import/no-unresolved": 0, 60 | "react/forbid-prop-types": 0, 61 | "no-template-curly-in-string": 0, 62 | "react/jsx-filename-extension": 0, 63 | "jest/no-disabled-tests": "warn", 64 | "jest/no-focused-tests": "error", 65 | "jest/no-identical-title": "error", 66 | "import/no-dynamic-require": 0, 67 | "import/no-extraneous-dependencies": 0, 68 | "consistent-return": 0, 69 | "no-underscore-dangle": 0, 70 | "import/prefer-default-export": 0, 71 | "react/destructuring-assignment": 0, 72 | "linebreak-style": 0, 73 | "jsx-a11y/click-events-have-key-events": 0, 74 | "react/no-array-index-key": 0, 75 | "react/jsx-no-bind": 0, 76 | "react/display-name": 0, 77 | "jsx-a11y/no-static-element-interactions": 0, 78 | "no-multiple-empty-lines": [ 79 | "error", 80 | { 81 | "max": 1, 82 | "maxBOF": 1 83 | } 84 | ], 85 | "jsx-a11y/anchor-is-valid": [ 86 | "error", 87 | { 88 | "components": [ 89 | "Link" 90 | ], 91 | "specialLink": [ 92 | "to", 93 | "hash" 94 | ], 95 | "aspects": [ 96 | "noHref" 97 | ] 98 | } 99 | ] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | coverage 10 | dist 11 | .rpt2_cache 12 | 13 | # misc 14 | .DS_Store 15 | .env 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | coverage 25 | package-lock.json 26 | .vscode/settings.json 27 | statistics.html -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | ._* 3 | .DS_Store 4 | .git 5 | .gitlab-ci* 6 | .hg 7 | .npmrc 8 | .lock-wscript 9 | .svn 10 | .wafpickle-* 11 | .travis.yml 12 | .editorconfig 13 | .eslint* 14 | .yarnrc 15 | 16 | config.gypi 17 | CVS 18 | npm-debug.log 19 | 20 | yarn-debug.log* 21 | yarn-error.log* 22 | default.config 23 | rollup.config.js 24 | 25 | 26 | docker* 27 | Docker* 28 | 29 | coverage 30 | config 31 | demo 32 | public 33 | src 34 | scripts 35 | styleguide* 36 | 37 | public 38 | 39 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @Telsho:registry=https://npm.pkg.github.com -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 JSLancer 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 | # react-google-flight-datepicker 2 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 3 | [![Downloads][downloads-image]][downloads-url] 4 | 5 | Google flight date picker implemented in ReactJS 6 | 7 | ### Demo 8 | - Live demo: https://codesandbox.io/s/react-google-flight-datepicker-zultp 9 | - To run demo on your computer: 10 | - Clone this repository 11 | - `yarn install` 12 | - `yarn run dev` 13 | 14 | ### Screenshot 15 | 16 | 17 | #### Desktop 18 | 19 | ---- 20 | #### Mobile 21 | 22 | 23 | ### Usage 24 | 25 | ##### RangeDatePicker 26 | ```jsx 27 | import { RangeDatePicker } from 'react-google-flight-datepicker'; 28 | import 'react-google-flight-datepicker/dist/main.css'; 29 | 30 | onDateChange(startDate, endDate)} 34 | minDate={new Date(1900, 0, 1)} 35 | maxDate={new Date(2100, 0, 1)} 36 | dateFormat="D" 37 | monthFormat="MMM YYYY" 38 | startDatePlaceholder="Start Date" 39 | endDatePlaceholder="End Date" 40 | disabled={false} 41 | className="my-own-class-name" 42 | startWeekDay="monday" 43 | /> 44 | ``` 45 | 46 | ##### SingleDatePicker 47 | ```jsx 48 | import { SingleDatePicker } from 'react-google-flight-datepicker'; 49 | import 'react-google-flight-datepicker/dist/main.css'; 50 | 51 | onDateChange(startDate)} 54 | minDate={new Date(1900, 0, 1)} 55 | maxDate={new Date(2100, 0, 1)} 56 | dateFormat="D" 57 | monthFormat="MMM YYYY" 58 | startDatePlaceholder="Date" 59 | disabled={false} 60 | className="my-own-class-name" 61 | startWeekDay="monday" 62 | /> 63 | ``` 64 | ##### Props 65 | |Prop name |Prop type|Default value|Description| 66 | |---------|---------|-------------|-----------| 67 | startDate | Date | null | Selected start date | 68 | endDate | Date | null | Selected end date | 69 | dateFormat | String | D | Display format for date. Check momentjs doc for information (https://momentjs.com/docs/#/displaying/) | 70 | monthFormat | String | MMM YYYY | Display format for month. Check momentjs doc for information (https://momentjs.com/docs/#/displaying/) | 71 | onChange | Function | null | Event handler that is called when startDate and endDate are changed | 72 | onFocus | Function | null | Return a string (START_DATE, END_DATE) which indicate which text input is focused | 73 | minDate | Date | 1900 Jan 01 | Minimum date that user can select | 74 | maxDate | Date | 2100 Jan 01 | Maximum date that user can select | 75 | className | String | | Custom CSS className for datepicker | 76 | disabled | String | false | Disable the datepicker | 77 | startDatePlaceholder | String | Start Date | Placeholder text for startDate text input | 78 | endDatePlaceholder | String | End Date | Placeholder text for endDate text input | 79 | startWeekDay | String (monday or sunday) | monday | Determine the start day for a week (monday or sunday) | 80 | highlightToday | Bool | false | Hightlight "today" date 81 | singleCalendar | Bool | false | Only applicable on SingleDatePicker. When this prop is actived, the datepicker will display 1 calendar instead of 2 calendar in the the container 82 | tooltip | String, React Component, Function | | Display the tooltip when hovering on day element, you can pass string, component, or a function. The function will receive a Date object, so you can generate the content of tooltip. 83 | subTextDict | Dict | null | Each key of the dict is a date in format YYYY-MM-DD, and the value is the text you want to display. You can see an example in dev/index.js. The text shouldn't be too big 84 | expandDirection | String | "right" | if "right" the calendar will expand from the top left to the right if "left" it will expand from the top right to the left 85 | locale | string | "fr", "it" .. | You can specify the locale, it should follow the locale formats from dayjs. 86 | 87 | ### Author 88 | - David Tran - david@jslancer.com 89 | - Elias Thouant 90 | 91 | ### License 92 | MIT 93 | 94 | [package-url]: https://npmjs.org/package/react-google-flight-datepicker 95 | [npm-version-svg]: http://versionbadg.es/jslancerteam/react-google-flight-datepicker.svg 96 | [deps-svg]: https://david-dm.org/jslancerteam/react-google-flight-datepicker.svg 97 | [deps-url]: https://david-dm.org/jslancerteam/react-google-flight-datepicker 98 | [dev-deps-svg]: https://david-dm.org/jslancerteam/react-google-flight-datepicker/dev-status.svg 99 | [dev-deps-url]: https://david-dm.org/jslancerteam/react-google-flight-datepicker#info=devDependencies 100 | [downloads-image]: http://img.shields.io/npm/dm/react-google-flight-datepicker.svg 101 | [downloads-url]: http://npm-stat.com/charts.html?package=react-google-flight-datepicker 102 | -------------------------------------------------------------------------------- /config/cssTransform.js: -------------------------------------------------------------------------------- 1 | 2 | // This is a custom Jest transformer turning style imports into empty objects. 3 | // http://facebook.github.io/jest/docs/en/webpack.html 4 | 5 | module.exports = { 6 | process() { 7 | return 'module.exports = {};' 8 | }, 9 | getCacheKey() { 10 | // The output is always the same. 11 | return 'cssTransform' 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /config/enzymeConfig.js: -------------------------------------------------------------------------------- 1 | // Enzyme setup 2 | const Enzyme = require('enzyme') 3 | const Adapter = require('enzyme-adapter-react-16') 4 | 5 | Enzyme.configure({ adapter: new Adapter() }) 6 | -------------------------------------------------------------------------------- /config/fileTransform.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | // This is a custom Jest transformer turning file imports into filenames. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process(src, filename) { 8 | const assetFilename = JSON.stringify(path.basename(filename)) 9 | 10 | if (filename.match(/\.svg$/)) { 11 | return `module.exports = { 12 | __esModule: true, 13 | default: ${assetFilename}, 14 | ReactComponent: (props) => ({ 15 | $$typeof: Symbol.for('react.element'), 16 | type: 'svg', 17 | ref: null, 18 | key: null, 19 | props: Object.assign({}, props, { 20 | children: ${assetFilename} 21 | }) 22 | }), 23 | };` 24 | } 25 | 26 | return `module.exports = ${assetFilename};` 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /config/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect' 6 | -------------------------------------------------------------------------------- /config/webpack.js: -------------------------------------------------------------------------------- 1 | // Webpack configuration 2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 3 | 4 | module.exports = { 5 | plugins: [new MiniCssExtractPlugin()], 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.(js|jsx)$/, 10 | exclude: /node_modules/, 11 | use: [ 12 | 'babel-loader', 13 | ], 14 | }, 15 | { 16 | test: /\.(s?)css$/, 17 | use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'], 18 | }, 19 | { 20 | test: /\.svg$/, 21 | use: [ 22 | { 23 | loader: "babel-loader" 24 | }, 25 | { 26 | loader: "react-svg-loader", 27 | options: { 28 | jsx: true // true outputs JSX tags 29 | } 30 | } 31 | ] 32 | }, 33 | { 34 | test: /\.(png|jpg|jpeg|webp|gif)$/, 35 | use: [ 36 | 'file-loader', 37 | ], 38 | }, 39 | ], 40 | }, 41 | resolve: { 42 | extensions: ['.js', '.jsx'], 43 | }, 44 | devServer: { 45 | compress: true, 46 | disableHostCheck: true, // That solved it 47 | 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@telsho/react-google-flight-datepicker", 3 | "repository": { 4 | "type": "git", 5 | "url": "git+https://github.com/Telsho/react-google-flight-datepicker.git" 6 | }, 7 | "version": "1.1.5 ", 8 | "main": "dist/index.js", 9 | "module": "dist/index.mjs", 10 | "types": "dist/index.d.ts", 11 | "exports": { 12 | ".": { 13 | "import": "./dist/index.mjs", 14 | "require": "./dist/index.js" 15 | }, 16 | "./styles.scss": "./dist/styles.scss" 17 | }, 18 | "dependencies": { 19 | "@telsho/react-google-flight-datepicker": "file:", 20 | "classnames": "^2.5.1", 21 | "compression-webpack-plugin": "^11.1.0", 22 | "copy-webpack-plugin": "^12.0.2", 23 | "dayjs": "^1.11.11", 24 | "esbuild-sass-plugin": "^3.3.1", 25 | "react-virtualized-auto-sizer": "^1.0.20", 26 | "react-window": "^1.8.10" 27 | }, 28 | "peerDependencies": { 29 | "react": "^19.0.0", 30 | "react-dom": "^19.0.0" 31 | }, 32 | "devDependencies": { 33 | "@svgr/webpack": "^8.1.0", 34 | "@testing-library/jest-dom": "^6.4.2", 35 | "@testing-library/react": "^16.0.2", 36 | "@testing-library/user-event": "^14.5.2", 37 | "@types/react": "^19.0.0", 38 | "@types/react-dom": "^19.0.0", 39 | "@types/react-window": "^1.8.8", 40 | "@typescript-eslint/eslint-plugin": "^7.8.0", 41 | "cross-env": "^7.0.3", 42 | "css-loader": "^7.1.2", 43 | "esbuild-css-modules-plugin": "^3.1.4", 44 | "esbuild-plugin-copy": "^2.1.1", 45 | "esbuild-plugin-svgr": "^3.1.0", 46 | "eslint": "^8.56.0", 47 | "eslint-config-airbnb": "^19.0.4", 48 | "eslint-plugin-flowtype": "^8.0.3", 49 | "eslint-plugin-import": "^2.29.1", 50 | "eslint-plugin-jest": "^27.6.0", 51 | "eslint-plugin-jsx-a11y": "^6.8.0", 52 | "eslint-plugin-react": "^7.33.2", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "file-loader": "^6.2.0", 55 | "html-webpack-plugin": "^5.6.0", 56 | "jest": "^29.7.0", 57 | "jest-environment-jsdom": "^29.7.0", 58 | "mini-css-extract-plugin": "^2.9.2", 59 | "react": "^19.0.0", 60 | "react-dom": "^19.0.0", 61 | "rimraf": "^6.0.1", 62 | "sass": "^1.76.0", 63 | "sass-loader": "^14.0.0", 64 | "style-loader": "^3.3.4", 65 | "terser-webpack-plugin": "^5.3.10", 66 | "ts-jest": "^29.1.2", 67 | "ts-loader": "^9.5.1", 68 | "tsup": "^8.3.5", 69 | "typescript": "^5.4.5", 70 | "webpack": "^5.91.0", 71 | "webpack-bundle-analyzer": "^4.10.2", 72 | "webpack-cli": "^5.1.4", 73 | "webpack-dev-server": "^4.15.1" 74 | }, 75 | "scripts": { 76 | "clean": "rimraf dist", 77 | "build": "npx tsup", 78 | "dev": "concurrently \"npm run build:watch\" \"npm run start:dev\"", 79 | "build:watch": "tsup --watch", 80 | "start:dev": "webpack serve --config webpack.dev.js", 81 | "prepack": "npm run clean && npm run build" 82 | }, 83 | "files": [ 84 | "dist", 85 | "dist/styles.scss" 86 | ], 87 | "sideEffects": [ 88 | "**/*.scss", 89 | "dist/**/*.scss" 90 | ], 91 | "jest": { 92 | "roots": [ 93 | "/src" 94 | ], 95 | "collectCoverageFrom": [ 96 | "src/**/*.{js,jsx,ts,tsx}", 97 | "!src/**/*.d.ts", 98 | "!src/**/index.ts" 99 | ], 100 | "resolver": "jest-pnp-resolver", 101 | "setupFilesAfterEnv": [ 102 | "/config/setupTests.js" 103 | ], 104 | "testMatch": [ 105 | "/src/**/__tests__/**/*.{js,jsx,ts,tsx}", 106 | "/src/**/*.{spec,test}.{js,jsx,ts,tsx}" 107 | ], 108 | "testEnvironment": "jest-environment-jsdom", 109 | "transform": { 110 | "^.+\\.(js|jsx|ts|tsx)$": "ts-jest", 111 | "^.+\\.css$": "/config/cssTransform.js", 112 | "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "/config/fileTransform.js" 113 | }, 114 | "transformIgnorePatterns": [ 115 | "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$", 116 | "^.+\\.module\\.(css|sass|scss)$" 117 | ], 118 | "moduleDirectories": [ 119 | "node_modules", 120 | "src" 121 | ], 122 | "moduleNameMapper": { 123 | "^react-native$": "react-native-web", 124 | "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy" 125 | }, 126 | "moduleFileExtensions": [ 127 | "web.js", 128 | "js", 129 | "web.ts", 130 | "ts", 131 | "web.tsx", 132 | "tsx", 133 | "json", 134 | "web.jsx", 135 | "jsx", 136 | "node" 137 | ], 138 | "watchPlugins": [ 139 | "jest-watch-typeahead/filename", 140 | "jest-watch-typeahead/testname" 141 | ] 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Google Flight Datepicker Demo 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/dev/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import '../lib/components/DatePicker/styles.scss'; 4 | import dayjs from 'dayjs'; 5 | import { SingleDatePicker } from '../lib/components/DatePicker/SingleDatePicker'; 6 | import { RangeDatePicker } from '../lib/components/DatePicker/RangeDatePicker'; 7 | 8 | interface SubTextDict { 9 | [key: string]: string; 10 | } 11 | 12 | const App: React.FC = () => { 13 | const subTextDict: SubTextDict = { 14 | [dayjs().format('YYYY-MM-DD')]: "500$", 15 | [dayjs().add(1, 'day').format('YYYY-MM-DD')]: "543$", 16 | [dayjs().add(2, 'day').format('YYYY-MM-DD')]: "94$", 17 | [dayjs().add(3, 'day').format('YYYY-MM-DD')]: "94$", 18 | [dayjs().add(4, 'day').format('YYYY-MM-DD')]: "94$", 19 | [dayjs().add(5, 'day').format('YYYY-MM-DD')]: "94$", 20 | [dayjs().add(6, 'day').format('YYYY-MM-DD')]: "94$", 21 | [dayjs().add(7, 'day').format('YYYY-MM-DD')]: "94$", 22 | [dayjs().add(8, 'day').format('YYYY-MM-DD')]: "94$", 23 | [dayjs().add(9, 'day').format('YYYY-MM-DD')]: "94$", 24 | [dayjs().add(10, 'day').format('YYYY-MM-DD')]: "94$", 25 | [dayjs().add(11, 'day').format('YYYY-MM-DD')]: "940$", 26 | }; 27 | 28 | return ( 29 |
30 |

react-google-flight-datepicker

31 |

Install

32 |
 33 |         npm install @telsho/react-google-flight-datepicker
 34 |         
35 |
36 | yarn add @telsho/react-google-flight-datepicker 37 |
38 |

RangeDatePicker

39 | 43 |
44 |

SingleDatePicker

45 | 46 |

RangeDatePicker with startDate and endDate

47 | 51 |
52 |

RangeDatePicker with minDate and maxDate

53 | 59 |
60 |

RangeDatePicker with custom date format

61 | 66 |

RangeDatePicker with custom month format

67 | 72 |
73 | 74 |

Disabled RangeDatePicker

75 | 80 |
81 | 82 |

Custom placeholder

83 | 89 |
90 | 91 |

Highlight today

92 | 99 |
100 | 101 |

Subtext

102 | 109 |
110 | 111 |

Left Expanding

112 | 119 |
120 |
121 | ); 122 | }; 123 | 124 | const container = document.getElementById('root'); 125 | if (!container) { 126 | throw new Error('Root element not found'); 127 | } 128 | const root = createRoot(container); 129 | root.render(); 130 | 131 | export default App; -------------------------------------------------------------------------------- /src/lib/assets/svg/back.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/lib/assets/svg/calendar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/assets/svg/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/assets/svg/prev.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/components/DatePicker/BaseDatePicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useRef, 4 | useEffect, 5 | useLayoutEffect, 6 | useCallback, 7 | } from "react"; 8 | import dayjs, { Dayjs } from "dayjs"; 9 | import cx from "classnames"; 10 | import localeData from "dayjs/plugin/localeData"; 11 | import { debounce } from "../../helpers"; 12 | 13 | import { DateInputGroup } from "./DateInputGroup"; 14 | import DialogWrapper from "./DialogWrapper"; 15 | import { Dialog } from "./Dialog"; 16 | 17 | import { 18 | DatePickerConfig, 19 | DatePickerProvider, 20 | DateState, 21 | DisplayCustomization, 22 | UIState, 23 | } from "./DatePickerProvider"; 24 | import { useClientSide } from "@lib/hooks/useClientSide"; 25 | 26 | dayjs.extend(localeData); 27 | 28 | export interface SubTextDict { 29 | [key: string]: string; 30 | } 31 | 32 | // Base shared props 33 | export interface BaseDatePickerProps { 34 | className?: string; 35 | disabled?: boolean; 36 | startWeekDay?: "monday" | "sunday"; 37 | minDate?: Date | null; 38 | maxDate?: Date | null; 39 | weekDayFormat?: string; 40 | dateFormat?: string; 41 | monthFormat?: string; 42 | highlightToday?: boolean; 43 | isOpen?: boolean; 44 | tooltip?: string | React.ReactNode | ((date: Date) => React.ReactNode); 45 | subTextDict?: SubTextDict | null; 46 | expandDirection?: string; 47 | locale?: string; 48 | onFocus?: (input: string) => void; 49 | } 50 | 51 | // Internal props for the base component 52 | interface BaseDatePickerInternalProps extends BaseDatePickerProps { 53 | isSingle: boolean; 54 | startDate: Date | null; 55 | endDate: Date | null; 56 | startDatePlaceholder: string; 57 | endDatePlaceholder?: string; 58 | onChange: (startDate: Date | null, endDate: Date | null) => void; 59 | onCloseCalendar: (startDate: Date | null, endDate: Date | null) => void; 60 | dateInputSeperator?: React.ReactNode; 61 | hideDialogHeader?: boolean; 62 | hideDialogFooter?: boolean; 63 | hideDialogAfterSelectEndDate?: boolean; 64 | singleCalendar?: boolean; 65 | } 66 | 67 | const BaseDatePicker: React.FC = ({ 68 | startDate = null, 69 | endDate = null, 70 | className = "", 71 | disabled = false, 72 | startDatePlaceholder, 73 | endDatePlaceholder, 74 | onChange, 75 | onFocus = () => {}, 76 | startWeekDay = "monday", 77 | minDate = null, 78 | maxDate = null, 79 | weekDayFormat = "dd", 80 | dateFormat = "", 81 | monthFormat = "", 82 | highlightToday = false, 83 | dateInputSeperator = null, 84 | hideDialogHeader = false, 85 | hideDialogFooter = false, 86 | hideDialogAfterSelectEndDate = false, 87 | isOpen = false, 88 | onCloseCalendar, 89 | tooltip = "", 90 | subTextDict = null, 91 | expandDirection = "right", 92 | locale = "en", 93 | isSingle = false, 94 | singleCalendar = false, 95 | }) => { 96 | // State 97 | const [complsOpen, setComplsOpen] = useState(isOpen); 98 | const [inputFocus, setInputFocus] = useState<"from" | "to" | null>( 99 | isSingle ? "from" : null 100 | ); 101 | const [fromDate, setFromDate] = useState( 102 | startDate ? dayjs(startDate) : undefined 103 | ); 104 | const [toDate, setToDate] = useState( 105 | endDate ? dayjs(endDate) : undefined 106 | ); 107 | const [hoverDate, setHoverDate] = useState(); 108 | const [isMobile, setIsMobile] = useState(false); 109 | const [isFirstTime, setIsFirstTime] = useState(false); 110 | 111 | 112 | const isClient = useClientSide(); 113 | 114 | // Refs 115 | const containerRef = useRef(null); 116 | const fromDateRef = useRef(null); 117 | const toDateRef = useRef(null); 118 | 119 | // Handle resize for mobile detection 120 | const handleResize = useCallback((): void => { 121 | if (!isClient) return; 122 | setIsMobile(window.innerWidth < 768); 123 | }, [isClient]); 124 | 125 | // Notify change handlers 126 | const notifyChange = (): void => { 127 | const _startDate = fromDateRef.current 128 | ? fromDateRef.current.toDate() 129 | : null; 130 | const _endDate = 131 | !isSingle && toDateRef.current ? toDateRef.current.toDate() : null; 132 | 133 | if (isSingle) { 134 | onChange(_startDate, null); 135 | } else { 136 | onChange(_startDate, _endDate); 137 | } 138 | }; 139 | 140 | const debounceNotifyChange = debounce(notifyChange, 20); 141 | 142 | // Update date handlers 143 | const updateFromDate = ( 144 | dateValue: Dayjs | null | undefined, 145 | shouldNotifyChange = false 146 | ): void => { 147 | setFromDate(dateValue || undefined); 148 | fromDateRef.current = dateValue || null; 149 | if (shouldNotifyChange) { 150 | debounceNotifyChange(); 151 | } 152 | }; 153 | 154 | const updateToDate = ( 155 | dateValue: Dayjs | null | undefined, 156 | shouldNotifyChange = false 157 | ): void => { 158 | if (!isSingle) { 159 | setToDate(dateValue || undefined); 160 | toDateRef.current = dateValue || null; 161 | if (shouldNotifyChange) { 162 | debounceNotifyChange(); 163 | } 164 | } 165 | }; 166 | 167 | useLayoutEffect(() => { 168 | if (!isClient) return; 169 | handleResize(); 170 | window.addEventListener("resize", handleResize); 171 | return () => window.removeEventListener("resize", handleResize); 172 | }, [isClient, handleResize]); 173 | 174 | useEffect(() => { 175 | if (!isClient) return; 176 | 177 | setIsFirstTime(true); 178 | const handleDocumentClick = (e: MouseEvent): void => { 179 | if ( 180 | containerRef.current && 181 | e.target instanceof Node && 182 | !containerRef.current.contains(e.target) && 183 | window.innerWidth >= 768 184 | ) { 185 | setComplsOpen(false); 186 | } 187 | }; 188 | 189 | document.addEventListener("click", handleDocumentClick); 190 | return () => document.removeEventListener("click", handleDocumentClick); 191 | }, [isClient]); // Add isClient to dependencies 192 | 193 | useEffect(() => { 194 | const _startDateJs = startDate ? dayjs(startDate) : null; 195 | fromDateRef.current = _startDateJs; 196 | updateFromDate(_startDateJs, false); 197 | }, [startDate]); 198 | 199 | useEffect(() => { 200 | if (!isSingle) { 201 | const _endDateJs = endDate ? dayjs(endDate) : null; 202 | toDateRef.current = _endDateJs; 203 | updateToDate(_endDateJs, false); 204 | } 205 | }, [endDate, isSingle]); 206 | 207 | useEffect(() => { 208 | if (!complsOpen && isFirstTime) { 209 | const _startDate = fromDateRef.current?.toDate() || null; 210 | const _endDate = toDateRef.current?.toDate() || null; 211 | if (isSingle) { 212 | onCloseCalendar(_startDate, null); 213 | } else { 214 | onCloseCalendar(_startDate, _endDate); 215 | } 216 | } 217 | }, [complsOpen, isFirstTime, isSingle, onCloseCalendar]); 218 | 219 | useEffect(() => { 220 | setComplsOpen(isOpen); 221 | }, [isOpen]); 222 | 223 | useEffect(() => { 224 | if (isFirstTime) { 225 | const input = 226 | inputFocus === "from" 227 | ? "Start Date" 228 | : inputFocus === "to" 229 | ? "End Date" 230 | : ""; 231 | onFocus(input); 232 | } 233 | }, [inputFocus, isFirstTime, onFocus]); 234 | 235 | // Event handlers 236 | const toggleDialog = (): void => { 237 | setComplsOpen(!complsOpen); 238 | }; 239 | 240 | const handleClickDateInput = (focusInput: "from" | "to"): void => { 241 | if (disabled || (!isSingle && focusInput === "to" && !fromDate)) { 242 | return; 243 | } 244 | 245 | if (!complsOpen) { 246 | setComplsOpen(true); 247 | } 248 | 249 | setInputFocus(focusInput); 250 | }; 251 | 252 | const onSelectDate = useCallback( 253 | (date: Dayjs): void => { 254 | const minDayjs = minDate ? dayjs(minDate) : null; 255 | const maxDayjs = maxDate ? dayjs(maxDate) : null; 256 | 257 | if ( 258 | (minDayjs && minDayjs.isAfter(date, "date")) || 259 | (maxDayjs && maxDayjs.isBefore(date, "date")) 260 | ) { 261 | return; 262 | } 263 | 264 | if (isSingle) { 265 | updateFromDate(date, true); 266 | if (hideDialogAfterSelectEndDate) { 267 | setTimeout(() => setComplsOpen(false), 50); 268 | } 269 | } else if ( 270 | inputFocus === "from" || 271 | (fromDate && date.isBefore(fromDate, "date")) 272 | ) { 273 | updateFromDate(date, true); 274 | if (toDate && date.isAfter(toDate, "date")) { 275 | updateToDate(null, true); 276 | } 277 | setInputFocus("to"); 278 | } else { 279 | updateToDate(date, true); 280 | setInputFocus(null); 281 | if (hideDialogAfterSelectEndDate) { 282 | setTimeout(() => setComplsOpen(false), 50); 283 | } 284 | } 285 | }, 286 | [ 287 | minDate, 288 | maxDate, 289 | isSingle, 290 | hideDialogAfterSelectEndDate, 291 | inputFocus, 292 | fromDate, 293 | toDate, 294 | ] 295 | ); 296 | 297 | const onHoverDate = (date: Dayjs): void => { 298 | setHoverDate(date); 299 | }; 300 | 301 | const handleReset = (): void => { 302 | setHoverDate(undefined); 303 | updateFromDate(null, true); 304 | if (!isSingle) { 305 | updateToDate(null, true); 306 | } 307 | setInputFocus("from"); 308 | }; 309 | 310 | const handleChangeDate = useCallback( 311 | (date: Dayjs, type: "from" | "to"): void => { 312 | const minDayjs = minDate ? dayjs(minDate) : null; 313 | const maxDayjs = maxDate ? dayjs(maxDate) : null; 314 | 315 | if ( 316 | (minDayjs && minDayjs.isAfter(date, "date")) || 317 | (maxDayjs && maxDayjs.isBefore(date, "date")) 318 | ) { 319 | return; 320 | } 321 | 322 | if (type === "from" || isSingle) { 323 | setInputFocus("from"); 324 | updateFromDate(date, true); 325 | if (!isSingle && toDate && date.isAfter(toDate, "date")) { 326 | updateToDate(null, true); 327 | } 328 | } else { 329 | setInputFocus("to"); 330 | updateToDate(date, true); 331 | } 332 | }, 333 | [minDate, maxDate, isSingle, toDate, inputFocus] 334 | ); 335 | 336 | // Create context values 337 | const dateState: DateState = { 338 | fromDate, 339 | toDate, 340 | hoverDate, 341 | inputFocus, 342 | onSelectDate, 343 | onHoverDate, 344 | handleChangeDate, 345 | handleReset, 346 | handleClickDateInput, 347 | }; 348 | 349 | const config: DatePickerConfig = { 350 | isSingle, 351 | startWeekDay, 352 | minDate: minDate ? dayjs(minDate).toDate() : null, 353 | maxDate: maxDate ? dayjs(maxDate).toDate() : null, 354 | weekDayFormat, 355 | dateFormat, 356 | monthFormat, 357 | highlightToday, 358 | singleCalendar, 359 | expandDirection, 360 | locale, 361 | }; 362 | 363 | const uiState: UIState = { 364 | complsOpen, 365 | isMobile, 366 | disabled, 367 | toggleDialog, 368 | }; 369 | 370 | const display: DisplayCustomization = { 371 | startDatePlaceholder, 372 | endDatePlaceholder, 373 | dateInputSeperator, 374 | hideDialogHeader, 375 | hideDialogFooter, 376 | hideDialogAfterSelectEndDate, 377 | tooltip, 378 | subTextDict, 379 | }; 380 | 381 | return ( 382 |
383 |
389 | 396 | 397 | 398 | 399 | 400 | 401 |
402 |
403 | ); 404 | }; 405 | 406 | export default BaseDatePicker; 407 | -------------------------------------------------------------------------------- /src/lib/components/DatePicker/ClientOnly.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export const ClientOnly = ({ children }: { children: React.ReactNode }) => { 4 | const [hasMounted, setHasMounted] = useState(false); 5 | 6 | useEffect(() => { 7 | setHasMounted(true); 8 | }, []); 9 | 10 | if (!hasMounted) return null; 11 | 12 | return <>{children}; 13 | }; -------------------------------------------------------------------------------- /src/lib/components/DatePicker/DateInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import cx from 'classnames'; 3 | import dayjs from 'dayjs'; 4 | import { 5 | useDateState, 6 | useDatePickerConfig, 7 | useDisplayCustomization 8 | } from './DatePickerProvider'; 9 | import CalendarIcon from '../../assets/svg/calendar.svg'; 10 | import PrevIcon from '../../assets/svg/prev.svg'; 11 | import NextIcon from '../../assets/svg/next.svg'; 12 | 13 | interface DateInputProps { 14 | type: 'from' | 'to'; 15 | showIcon?: boolean; 16 | tabIndex?: number; 17 | nonFocusable?: boolean; 18 | } 19 | 20 | export const DateInput: React.FC = ({ 21 | type, 22 | showIcon = false, 23 | tabIndex = 0, 24 | nonFocusable = false, 25 | }) => { 26 | const [formattedDate, setFormattedDate] = useState(null); 27 | const [disablePrev, setDisablePrev] = useState(false); 28 | const [disableNext, setDisableNext] = useState(false); 29 | 30 | const { 31 | fromDate, 32 | toDate, 33 | inputFocus, 34 | handleClickDateInput, 35 | handleChangeDate 36 | } = useDateState(); 37 | 38 | const { 39 | isSingle, 40 | minDate, 41 | maxDate, 42 | dateFormat 43 | } = useDatePickerConfig(); 44 | 45 | const { 46 | startDatePlaceholder, 47 | endDatePlaceholder 48 | } = useDisplayCustomization(); 49 | 50 | const value = type === 'from' ? fromDate : toDate; 51 | const placeholder = type === 'from' ? startDatePlaceholder : endDatePlaceholder; 52 | 53 | useEffect(() => { 54 | if (value) { 55 | let formattedValue = value.clone().locale(dayjs.locale()); 56 | let text = formattedValue.format('ddd, DD MMM'); 57 | if (dateFormat) { 58 | text = value.format(dateFormat); 59 | } 60 | setFormattedDate(text); 61 | 62 | const minDateDayjs = minDate ? dayjs(minDate) : null; 63 | const maxDateDayjs = maxDate ? dayjs(maxDate) : null; 64 | 65 | if ((minDateDayjs?.add(1, 'day').isAfter(value, 'date')) 66 | || (type === 'to' && fromDate && value.isBefore(fromDate.add(1, 'day'), 'date')) 67 | ) { 68 | setDisablePrev(true); 69 | } else { 70 | setDisablePrev(false); 71 | } 72 | 73 | if (maxDateDayjs?.subtract(1, 'day').isBefore(value, 'date')) { 74 | setDisableNext(true); 75 | } else { 76 | setDisableNext(false); 77 | } 78 | } else { 79 | setFormattedDate(null); 80 | } 81 | }, [value, fromDate, minDate, maxDate, dateFormat, type]); 82 | 83 | const prevDate = (e: React.MouseEvent) => { 84 | e.stopPropagation(); 85 | if (value) { 86 | handleChangeDate(value.subtract(1, 'day'), type); 87 | } 88 | }; 89 | 90 | const nextDate = (e: React.MouseEvent) => { 91 | e.stopPropagation(); 92 | if (value) { 93 | handleChangeDate(value.add(1, 'day'), type); 94 | } 95 | }; 96 | 97 | const handleClick = () => { 98 | handleClickDateInput(type); 99 | }; 100 | 101 | return ( 102 |
112 | {showIcon && ( 113 | 114 | )} 115 | 116 |
117 | {formattedDate ??
{placeholder}
} 118 |
119 | 120 | {formattedDate && ( 121 |
122 | 131 | 140 |
141 | )} 142 |
143 | ); 144 | }; -------------------------------------------------------------------------------- /src/lib/components/DatePicker/DateInputGroup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | useDatePickerConfig, 4 | useDisplayCustomization, 5 | } from "./DatePickerProvider"; 6 | import CalendarIcon from "../../assets/svg/calendar.svg"; 7 | import { DateInput } from "./DateInput"; 8 | 9 | interface DateInputGroupProps { 10 | showIcon?: boolean; 11 | nonFocusable?: boolean; 12 | } 13 | 14 | export const DateInputGroup: React.FC = ({ 15 | showIcon = false, 16 | nonFocusable = false, 17 | }) => { 18 | const { isSingle } = useDatePickerConfig(); 19 | const { dateInputSeperator } = useDisplayCustomization(); 20 | 21 | return ( 22 |
23 | {showIcon && ( 24 | 25 | )} 26 |
27 | 33 | {!isSingle && dateInputSeperator && ( 34 |
{dateInputSeperator}
35 | )} 36 | {!isSingle && ( 37 | 43 | )} 44 |
45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/lib/components/DatePicker/DatePicker.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import DatePicker from './DatePicker'; 5 | 6 | describe('', () => { 7 | it('should render', () => { 8 | const wrapper = shallow(); 9 | 10 | expect(wrapper).toBeDefined(); 11 | }); 12 | 13 | it('should call onClick', () => { 14 | const props = { 15 | onClick: jest.fn(), 16 | }; 17 | const wrapper = shallow(); 18 | wrapper.simulate('click'); 19 | 20 | expect(props.onClick).toHaveBeenCalled(); 21 | }); 22 | 23 | it('should be disableable', () => { 24 | const wrapper = shallow(); 25 | 26 | expect(wrapper.prop('disabled')).toBe(true); 27 | }); 28 | 29 | it('should allow custom className', () => { 30 | const props = { 31 | className: 'Custom', 32 | }; 33 | const wrapper = shallow(); 34 | 35 | expect(wrapper.hasClass(props.className)).toBe(true); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/lib/components/DatePicker/DatePickerProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; 2 | import dayjs, { Dayjs } from 'dayjs'; 3 | import { SubTextDict } from './BaseDatePicker'; 4 | 5 | // Core date state and handlers 6 | export interface DateState { 7 | fromDate?: Dayjs; 8 | toDate?: Dayjs; 9 | hoverDate?: Dayjs; 10 | inputFocus: 'from' | 'to' | null; 11 | onSelectDate: (date: Dayjs) => void; 12 | onHoverDate: (date: Dayjs) => void; 13 | handleChangeDate: (date: Dayjs, type: 'from' | 'to') => void; 14 | handleReset: () => void; 15 | handleClickDateInput: (type: 'from' | 'to') => void; 16 | } 17 | 18 | // Configuration that rarely changes 19 | export interface DatePickerConfig { 20 | isSingle: boolean; 21 | startWeekDay: 'monday' | 'sunday'; 22 | minDate: Date | null; 23 | maxDate: Date | null; 24 | weekDayFormat: string; 25 | dateFormat: string; 26 | monthFormat: string; 27 | highlightToday: boolean; 28 | singleCalendar?: boolean; 29 | expandDirection: string; 30 | locale: string; 31 | } 32 | 33 | // UI-specific state 34 | export interface UIState { 35 | complsOpen: boolean; 36 | isMobile: boolean; 37 | disabled: boolean; 38 | toggleDialog: () => void; 39 | } 40 | 41 | // Display customization 42 | export interface DisplayCustomization { 43 | startDatePlaceholder: string; 44 | endDatePlaceholder?: string; 45 | dateInputSeperator?: React.ReactNode; 46 | hideDialogHeader: boolean; 47 | hideDialogFooter: boolean; 48 | hideDialogAfterSelectEndDate: boolean; 49 | tooltip?: string | React.ReactNode | ((date: Date) => React.ReactNode); 50 | subTextDict?: SubTextDict | null; 51 | } 52 | 53 | // Locale state interface 54 | export interface LocaleState { 55 | currentLocale: string; 56 | isLocaleReady: boolean; 57 | } 58 | 59 | // Create contexts 60 | const DateStateContext = createContext(null); 61 | const DatePickerConfigContext = createContext(null); 62 | const UIStateContext = createContext(null); 63 | const DisplayContext = createContext(null); 64 | const LocaleContext = createContext(null); 65 | 66 | // Custom hooks 67 | export const useDateState = () => { 68 | const context = useContext(DateStateContext); 69 | if (!context) throw new Error('useDateState must be used within DatePickerProvider'); 70 | return context; 71 | }; 72 | 73 | export const useDatePickerConfig = () => { 74 | const context = useContext(DatePickerConfigContext); 75 | if (!context) throw new Error('useDatePickerConfig must be used within DatePickerProvider'); 76 | return context; 77 | }; 78 | 79 | export const useUIState = () => { 80 | const context = useContext(UIStateContext); 81 | if (!context) throw new Error('useUIState must be used within DatePickerProvider'); 82 | return context; 83 | }; 84 | 85 | export const useDisplayCustomization = () => { 86 | const context = useContext(DisplayContext); 87 | if (!context) throw new Error('useDisplayCustomization must be used within DatePickerProvider'); 88 | return context; 89 | }; 90 | 91 | export const useLocale = () => { 92 | const context = useContext(LocaleContext); 93 | if (!context) throw new Error('useLocale must be used within DatePickerProvider'); 94 | return context; 95 | }; 96 | 97 | // Locale loader utility 98 | const loadLocale = async (locale: string): Promise => { 99 | if (locale === 'en') return true; 100 | try { 101 | await import(`dayjs/locale/${locale}.js`); 102 | return true; 103 | } catch (error) { 104 | console.error(`Failed to load locale ${locale}:`, error); 105 | return false; 106 | } 107 | }; 108 | 109 | // Provider Props interface 110 | export interface DatePickerProviderProps { 111 | children: React.ReactNode; 112 | dateState: DateState; 113 | config: DatePickerConfig; 114 | uiState: UIState; 115 | display: DisplayCustomization; 116 | locale?: string; 117 | } 118 | 119 | // Provider component 120 | export const DatePickerProvider: React.FC = ({ 121 | children, 122 | dateState, 123 | config, 124 | uiState, 125 | display, 126 | locale = "en" 127 | }) => { 128 | // Locale state 129 | const [localeState, setLocaleState] = useState({ 130 | currentLocale: "en", 131 | isLocaleReady: locale === "en" 132 | }); 133 | 134 | // Load and set locale 135 | useEffect(() => { 136 | if (locale !== "en") { 137 | setLocaleState(prev => ({ ...prev, isLocaleReady: false })); 138 | loadLocale(locale) 139 | .then(success => { 140 | if (success) { 141 | dayjs.locale(locale); 142 | setLocaleState({ 143 | currentLocale: locale, 144 | isLocaleReady: true 145 | }); 146 | } else { 147 | dayjs.locale("en"); 148 | setLocaleState({ 149 | currentLocale: "en", 150 | isLocaleReady: true 151 | }); 152 | } 153 | }); 154 | } else { 155 | dayjs.locale("en"); 156 | setLocaleState({ 157 | currentLocale: "en", 158 | isLocaleReady: true 159 | }); 160 | } 161 | }, [locale]); 162 | 163 | // Don't render until locale is ready 164 | if (!localeState.isLocaleReady) { 165 | return null; // Or a loading component 166 | } 167 | 168 | // Provide all contexts 169 | return ( 170 | 171 | 172 | 173 | 174 | 175 | {children} 176 | 177 | 178 | 179 | 180 | 181 | ); 182 | }; 183 | 184 | export default DatePickerProvider; -------------------------------------------------------------------------------- /src/lib/components/DatePicker/Day.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useCallback, useEffect, useRef } from 'react'; 2 | import cx from 'classnames'; 3 | import { Dayjs } from 'dayjs'; 4 | import { useDateState } from './DatePickerProvider'; 5 | 6 | interface DayProps { 7 | dateIndex: number; 8 | dateValue: Dayjs; 9 | isEndDay?: boolean; 10 | selected?: boolean; 11 | hovered: boolean; 12 | disabled: boolean | null; 13 | totalDay: number; 14 | highlight: boolean; 15 | handleHoverDay: (date: Dayjs) => void; 16 | subText: string; 17 | } 18 | 19 | export const Day = forwardRef(({ 20 | dateIndex, 21 | dateValue, 22 | isEndDay, 23 | selected, 24 | hovered, 25 | disabled, 26 | totalDay, 27 | highlight, 28 | handleHoverDay, 29 | subText, 30 | }, ref) => { 31 | const dayRef = useRef(null); 32 | const { onSelectDate, onHoverDate } = useDateState(); 33 | 34 | const selectDate = (e: React.MouseEvent) => { 35 | e.stopPropagation(); 36 | e.preventDefault(); 37 | if (disabled) return; 38 | onSelectDate(dateValue); 39 | }; 40 | 41 | const handleHoverDate = () => { 42 | if (disabled) return; 43 | onHoverDate(dateValue); 44 | handleHoverDay(dateValue); 45 | }; 46 | 47 | const handleTooltipPosition = useCallback(() => { 48 | // Check if ref exists and is a RefObject 49 | if (!ref || typeof ref === 'function') return; 50 | const element = ref.current; 51 | if (element && dayRef.current) { 52 | element.style.left = `${ 53 | dayRef.current.offsetLeft - element.offsetWidth + 135 54 | }px`; 55 | element.style.top = `${ 56 | dayRef.current.offsetTop - element.offsetHeight - 15 57 | }px`; 58 | element.style.visibility = 'visible'; 59 | } 60 | }, [ref]); 61 | 62 | const handleTooltipHidden = useCallback(() => { 63 | if (!ref || typeof ref === 'function') return; 64 | const element = ref.current; 65 | if (element) { 66 | element.style.visibility = 'hidden'; 67 | } 68 | }, [ref]); 69 | 70 | useEffect(() => { 71 | const currentRef = dayRef.current; 72 | if (currentRef) { 73 | currentRef.addEventListener('mouseover', handleTooltipPosition); 74 | currentRef.addEventListener('mouseleave', handleTooltipHidden); 75 | } 76 | return () => { 77 | if (currentRef) { 78 | currentRef.removeEventListener('mouseover', handleTooltipPosition); 79 | currentRef.removeEventListener('mouseleave', handleTooltipHidden); 80 | } 81 | }; 82 | }, [handleTooltipPosition, handleTooltipHidden]); 83 | 84 | return ( 85 |
102 | {hovered && 103 | !(isEndDay && dateIndex === totalDay) && 104 | !(dateIndex === 1 && selected && !isEndDay) && ( 105 |
111 | )} 112 |
113 |
{dateIndex}
114 | {subText && ( 115 |
123 | {subText} 124 |
125 | )} 126 |
127 |
128 | ); 129 | }); 130 | 131 | Day.displayName = 'Day'; -------------------------------------------------------------------------------- /src/lib/components/DatePicker/Dialog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import cx from 'classnames'; 3 | import { 4 | useDateState, 5 | useDatePickerConfig, 6 | useUIState, 7 | useDisplayCustomization 8 | } from './DatePickerProvider'; 9 | import BackIcon from '../../assets/svg/back.svg'; 10 | import {DateInputGroup} from './DateInputGroup'; 11 | import {DialogContentMobile} from './DialogContentMobile'; 12 | import {DialogContentDesktop} from './DialogContentDesktop'; 13 | import { Dayjs } from 'dayjs'; 14 | 15 | interface DialogContainerProps { 16 | containerRef?: React.RefObject; 17 | } 18 | 19 | export const Dialog: React.FC = ({ 20 | containerRef: externalRef 21 | }) => { 22 | const [hideAnimation, setHideAnimation] = useState(false); 23 | const [dateChanged, setDateChanged] = useState(null); 24 | const defaultRef = useRef(null); 25 | const containerRef = externalRef || defaultRef; 26 | 27 | const { 28 | handleChangeDate, 29 | handleReset, 30 | } = useDateState(); 31 | 32 | const { 33 | singleCalendar, 34 | expandDirection 35 | } = useDatePickerConfig(); 36 | 37 | const { 38 | complsOpen, 39 | isMobile, 40 | toggleDialog 41 | } = useUIState(); 42 | 43 | const { 44 | hideDialogHeader, 45 | hideDialogFooter, 46 | } = useDisplayCustomization(); 47 | 48 | const onChangeDate = (date: Dayjs, type: 'from' | 'to') => { 49 | setDateChanged(date); 50 | handleChangeDate(date, type); 51 | }; 52 | 53 | useEffect(() => { 54 | if (complsOpen && !hideAnimation) { 55 | setHideAnimation(true); 56 | } 57 | if (complsOpen) { 58 | setTimeout(() => { 59 | const startDateInput = containerRef.current?.querySelector( 60 | '#start-date-input-button' 61 | ) as HTMLElement; 62 | if (startDateInput) { 63 | startDateInput.focus(); 64 | } 65 | }, 50); 66 | } 67 | }, [complsOpen, containerRef, hideAnimation]); 68 | 69 | return ( 70 |
80 | {!hideDialogHeader && ( 81 |
82 | 89 | 90 | 94 | 95 | 102 |
103 | )} 104 | 105 |
106 | {isMobile ? ( 107 | 108 | ) : ( 109 | 112 | )} 113 |
114 | 115 | {!hideDialogFooter && ( 116 |
117 | 125 | 132 |
133 | )} 134 |
135 | ); 136 | }; -------------------------------------------------------------------------------- /src/lib/components/DatePicker/DialogContentDesktop.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useEffect, 3 | useState, 4 | useRef, 5 | useCallback, 6 | useMemo, 7 | } from "react"; 8 | import cx from "classnames"; 9 | import dayjs, { Dayjs } from "dayjs"; 10 | import { 11 | useDateState, 12 | useDatePickerConfig, 13 | useDisplayCustomization, 14 | useUIState, 15 | } from "./DatePickerProvider"; 16 | import PrevIcon from "../../assets/svg/prev.svg"; 17 | import NextIcon from "../../assets/svg/next.svg"; 18 | import { MonthCalendar } from "./MonthCalendar"; 19 | 20 | interface DialogContentDesktopProps { 21 | dateChanged?: Dayjs | null; 22 | } 23 | 24 | // Client-side check hook 25 | const useClientSide = () => { 26 | const [isClient, setIsClient] = useState(false); 27 | useEffect(() => { setIsClient(true); }, []); 28 | return isClient; 29 | }; 30 | 31 | export const DialogContentDesktop: React.FC = ({ 32 | dateChanged = null, 33 | }) => { 34 | const isClient = useClientSide(); 35 | const containerRef = useRef(null); 36 | const tooltipRef = useRef(null); 37 | const [translateAmount, setTranslateAmount] = useState(0); 38 | const [monthArray, setMonthArray] = useState([]); 39 | const [focusDate, setFocusDate] = useState(null); 40 | const [disablePrev, setDisablePrev] = useState(false); 41 | const [disableNext, setDisableNext] = useState(false); 42 | const [wrapperWidth, setWrapperWidth] = useState(0); 43 | const [dayValue, setDayValue] = useState(null); 44 | const [isAnimating, setIsAnimating] = useState(false); 45 | 46 | const { fromDate } = useDateState(); 47 | const { minDate, maxDate, singleCalendar } = useDatePickerConfig(); 48 | const { complsOpen } = useUIState(); 49 | const { tooltip } = useDisplayCustomization(); 50 | 51 | const getArrayMonth = useCallback((date: Dayjs): Dayjs[] => [ 52 | date.subtract(1, "month"), 53 | date, 54 | date.add(1, "month"), 55 | date.add(2, "month"), 56 | ], []); 57 | 58 | // Width calculation with resize observer 59 | useEffect(() => { 60 | if (!isClient || !containerRef.current) return; 61 | 62 | const updateDimensions = () => { 63 | const width = containerRef.current!.offsetWidth; 64 | const style = window.getComputedStyle(containerRef.current!); 65 | const translateValue = singleCalendar 66 | ? width + parseInt(style.marginLeft) - 8 67 | : width / 2; 68 | setWrapperWidth(translateValue); 69 | }; 70 | 71 | updateDimensions(); 72 | const resizeObserver = new ResizeObserver(updateDimensions); 73 | resizeObserver.observe(containerRef.current); 74 | 75 | return () => resizeObserver.disconnect(); 76 | }, [isClient, singleCalendar]); 77 | 78 | // Focus date initialization 79 | useEffect(() => { 80 | setFocusDate(fromDate ?? dayjs()); 81 | }, [complsOpen, fromDate]); 82 | 83 | // Month array and navigation controls 84 | useEffect(() => { 85 | if (!focusDate) return; 86 | 87 | // Convert Date to Dayjs before using date math 88 | const minDayjs = minDate ? dayjs(minDate) : null; 89 | const maxDayjs = maxDate ? dayjs(maxDate) : null; 90 | 91 | setDisablePrev( 92 | Boolean( 93 | minDayjs && 94 | focusDate.isBefore(minDayjs.add(1, "month"), "month") 95 | ) 96 | ); 97 | 98 | setDisableNext( 99 | Boolean( 100 | maxDayjs && 101 | focusDate.isAfter(maxDayjs.subtract(2, "month"), "month") 102 | ) 103 | ); 104 | 105 | setMonthArray(getArrayMonth(focusDate)); 106 | }, [focusDate, minDate, maxDate, getArrayMonth]); 107 | 108 | // Date change handler 109 | useEffect(() => { 110 | if (!dateChanged || !focusDate) return; 111 | 112 | const monthDiff = dateChanged.diff(focusDate, "month"); 113 | if (monthDiff < -1) decreaseCurrentMonth(); 114 | if (monthDiff > 1) increaseCurrentMonth(); 115 | }, [dateChanged, focusDate]); 116 | 117 | // Animation handlers 118 | const handleMonthChange = useCallback((direction: "next" | "prev") => () => { 119 | if ((direction === "next" && disableNext) || 120 | (direction === "prev" && disablePrev) || 121 | isAnimating) return; 122 | 123 | setIsAnimating(true); 124 | setTranslateAmount(direction === "next" ? -wrapperWidth : wrapperWidth); 125 | 126 | const timer = setTimeout(() => { 127 | setFocusDate(prev => { 128 | const newDate = direction === "next" 129 | ? prev!.add(1, "month") 130 | : prev!.subtract(1, "month"); 131 | setMonthArray(getArrayMonth(newDate)); 132 | return newDate; 133 | }); 134 | setTranslateAmount(0); 135 | setIsAnimating(false); 136 | }, 200); 137 | 138 | return () => clearTimeout(timer); 139 | }, [disableNext, disablePrev, isAnimating, wrapperWidth, getArrayMonth]); 140 | 141 | const [increaseCurrentMonth, decreaseCurrentMonth] = useMemo( 142 | () => [handleMonthChange("next"), handleMonthChange("prev")], 143 | [handleMonthChange] 144 | ); 145 | 146 | // Focus management 147 | const focusOnCalendar = useCallback(() => { 148 | if (!isClient || !containerRef.current) return; 149 | 150 | const selector = ".day.selected, .month-calendar:not(.hidden) .day:not(.disabled)"; 151 | const focusTarget = containerRef.current.querySelector(selector); 152 | focusTarget?.focus(); 153 | }, [isClient]); 154 | 155 | // Keyboard navigation 156 | const handleKeyDown = useCallback((e: React.KeyboardEvent) => { 157 | const target = e.target as HTMLElement; 158 | const dayIndex = target.getAttribute("data-day-index"); 159 | if (!dayIndex) return; 160 | 161 | e.preventDefault(); 162 | 163 | const calendarContainer = target.closest(".calendar-wrapper"); 164 | const dateValue = parseInt(target.dataset.dateValue ?? "0"); 165 | const date = dayjs(dateValue); 166 | const lastDate = date.endOf("month").date(); 167 | 168 | let newIndex = parseInt(dayIndex); 169 | switch (e.key) { 170 | case "ArrowLeft": newIndex--; break; 171 | case "ArrowUp": newIndex -= 7; break; 172 | case "ArrowRight": newIndex++; break; 173 | case "ArrowDown": newIndex += 7; break; 174 | case " ": target.click(); return; 175 | default: return; 176 | } 177 | 178 | if (newIndex > 0 && newIndex <= lastDate) { 179 | calendarContainer?.querySelector(`[data-day-index="${newIndex}"]`)?.focus(); 180 | } else { 181 | const newDate = date.add(newIndex - parseInt(dayIndex), "day"); 182 | const monthDiff = newDate.diff(focusDate, "month"); 183 | 184 | if (monthDiff > 1 && !disableNext) increaseCurrentMonth(); 185 | if (monthDiff < 0 && !disablePrev) decreaseCurrentMonth(); 186 | 187 | setTimeout(() => { 188 | calendarContainer?.querySelector( 189 | `[data-month-index="${newDate.month() + 1}"] [data-day-index="${newDate.date()}"]` 190 | )?.focus(); 191 | }, 200); 192 | } 193 | }, [focusDate, disableNext, disablePrev, increaseCurrentMonth, decreaseCurrentMonth]); 194 | 195 | // Tooltip rendering 196 | const renderTooltip = useMemo(() => { 197 | if (!tooltip || !isClient) return null; 198 | 199 | const content = typeof tooltip === "function" 200 | ? tooltip(dayValue?.toDate() ?? new Date()) 201 | : tooltip; 202 | 203 | return ( 204 |
205 | {content} 206 |
207 | ); 208 | }, [tooltip, dayValue, isClient]); 209 | 210 | // Calendar rendering 211 | const calendarMonths = useMemo(() => 212 | monthArray.map((date, index) => { 213 | const isVisible = index === 1 || index === 2; 214 | const isSlidingNext = isAnimating && translateAmount < 0 && index === 3; 215 | const isSlidingPrev = isAnimating && translateAmount > 0 && index === 0; 216 | 217 | return ( 218 |