├── .all-contributorsrc ├── .babelrc ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .jest ├── setup.ts └── with-theme.tsx ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.js └── preview.js ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── datepicker-custom.png ├── jest.config.js ├── package.json ├── src ├── calendar.stories.tsx ├── calendar.test.tsx ├── calendar.tsx ├── context.tsx ├── control-next-button.tsx ├── control-prev-button.tsx ├── control.tsx ├── day.tsx ├── index.ts ├── month-days.tsx ├── month-name.tsx ├── month-week.tsx ├── month.tsx ├── months.tsx ├── theme │ ├── calendar.ts │ ├── controls.ts │ ├── day.ts │ ├── index.ts │ └── month.ts ├── types.ts ├── useCalendar.ts └── useCalendarDay.ts ├── tsconfig.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "datepicker", 3 | "projectOwner": "uselessdev", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "uselessdev", 15 | "name": "Wallace Batista", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/6943919?v=4", 17 | "profile": "https://iamwallace.dev", 18 | "contributions": [ 19 | "code", 20 | "ideas" 21 | ] 22 | }, 23 | { 24 | "login": "leonardoelias", 25 | "name": "Leonardo Elias", 26 | "avatar_url": "https://avatars.githubusercontent.com/u/1995213?v=4", 27 | "profile": "http://htttp://www.leonardoelias.me", 28 | "contributions": [ 29 | "code" 30 | ] 31 | }, 32 | { 33 | "login": "kivi", 34 | "name": "kivi", 35 | "avatar_url": "https://avatars.githubusercontent.com/u/366163?v=4", 36 | "profile": "https://github.com/kivi", 37 | "contributions": [ 38 | "code" 39 | ] 40 | }, 41 | { 42 | "login": "ggteixeira", 43 | "name": "Guilherme Teixeira ", 44 | "avatar_url": "https://avatars.githubusercontent.com/u/24235344?v=4", 45 | "profile": "http://guiteixeira.dev", 46 | "contributions": [ 47 | "code" 48 | ] 49 | }, 50 | { 51 | "login": "branislaav", 52 | "name": "Brano Zavracky", 53 | "avatar_url": "https://avatars.githubusercontent.com/u/10597602?v=4", 54 | "profile": "https://github.com/branislaav", 55 | "contributions": [ 56 | "code" 57 | ] 58 | }, 59 | { 60 | "login": "BasicPixel", 61 | "name": "O. Qudah", 62 | "avatar_url": "https://avatars.githubusercontent.com/u/69857856?v=4", 63 | "profile": "https://pixel.is-a.dev", 64 | "contributions": [ 65 | "doc" 66 | ] 67 | }, 68 | { 69 | "login": "tomchentw", 70 | "name": "Tom Chen", 71 | "avatar_url": "https://avatars.githubusercontent.com/u/922234?v=4", 72 | "profile": "https://medium.com/@tomchentw", 73 | "contributions": [ 74 | "doc", 75 | "code" 76 | ] 77 | }, 78 | { 79 | "login": "astahmer", 80 | "name": "Alexandre Stahmer", 81 | "avatar_url": "https://avatars.githubusercontent.com/u/47224540?v=4", 82 | "profile": "https://github.com/astahmer", 83 | "contributions": [ 84 | "code" 85 | ] 86 | }, 87 | { 88 | "login": "raphaelrochap", 89 | "name": "Raphael da Rocha Pinto Barboza", 90 | "avatar_url": "https://avatars.githubusercontent.com/u/21209032?v=4", 91 | "profile": "https://github.com/raphaelrochap", 92 | "contributions": [ 93 | "code" 94 | ] 95 | }, 96 | { 97 | "login": "gleuch", 98 | "name": "Greg Leuch", 99 | "avatar_url": "https://avatars.githubusercontent.com/u/9039?v=4", 100 | "profile": "https://gleu.ch", 101 | "contributions": [ 102 | "code" 103 | ] 104 | }, 105 | { 106 | "login": "philzen", 107 | "name": "Philzen", 108 | "avatar_url": "https://avatars.githubusercontent.com/u/1634615", 109 | "profile": "https://github.com/Philzen", 110 | "contributions": [ 111 | "code", 112 | "bug" 113 | ] 114 | } 115 | ], 116 | "contributorsPerLine": 7, 117 | "skipCi": true 118 | } 119 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-typescript", 4 | "@babel/preset-react", 5 | "@babel/preset-env" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "env": { 4 | "browser": true, 5 | "es2020": true, 6 | "jest": true, 7 | "node": true 8 | }, 9 | "settings": { 10 | "react": { 11 | "version": "detect" 12 | } 13 | }, 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:react/recommended", 17 | "plugin:@typescript-eslint/recommended", 18 | "plugin:prettier/recommended" 19 | ], 20 | "parser": "@typescript-eslint/parser", 21 | "parserOptions": { 22 | "ecmaFeatures": { 23 | "jsx": true 24 | }, 25 | "ecmaVersion": 11, 26 | "sourceType": "module" 27 | }, 28 | "plugins": [ 29 | "react", 30 | "@typescript-eslint", 31 | "prettier", 32 | "react-hooks" 33 | ], 34 | "rules": { 35 | "react-hooks/rules-of-hooks": "error", 36 | "react-hooks/exhaustive-deps": "warn", 37 | "react/prop-types": "off", 38 | "react/react-in-jsx-scope": "off", 39 | "@typescript-eslint/explicit-module-boundary-types": "off" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [pull_request] 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout Repositiry 9 | uses: actions/checkout@v2 10 | 11 | - name: Setup Node 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 14.x 15 | 16 | - name: Install dependencies 17 | run: yarn 18 | 19 | - name: Run lint tests 20 | run: yarn lint 21 | 22 | - name: Run tests 23 | run: yarn test --collect-coverage 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | .env.production 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | out 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # Stores VSCode versions used for testing VSCode extensions 111 | .vscode-test 112 | 113 | # yarn v2 114 | .yarn/cache 115 | .yarn/unplugged 116 | .yarn/build-state.yml 117 | .yarn/install-state.gz 118 | .pnp.* 119 | -------------------------------------------------------------------------------- /.jest/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | -------------------------------------------------------------------------------- /.jest/with-theme.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render, RenderOptions } from '@testing-library/react' 3 | import { ChakraProvider } from '@chakra-ui/react' 4 | import { theme } from '../src/theme' 5 | 6 | const Providers = ({ children }: React.PropsWithChildren) => {children} 7 | 8 | const renderer = (ui: React.ReactElement, options?: RenderOptions) => { 9 | return render(ui, { wrapper: Providers, ...options }) 10 | } 11 | 12 | export * from '@testing-library/react' 13 | export { renderer as render } 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "printWidth": 80, 7 | "trailingComma": "es5", 8 | "quoteProps": "consistent", 9 | "bracketSpacing": true, 10 | "jsxBracketSameLine": false, 11 | "arrowParens": "avoid" 12 | } 13 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: [ 3 | "../src/**/*.stories.@(ts|tsx)" 4 | ], 5 | addons: [ 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials", 8 | "@chakra-ui/storybook-addon", 9 | ], 10 | webpackFinal: async config => { 11 | config.module.rules.push({ 12 | test: /\.mjs$/, 13 | include: /node_modules/, 14 | type: 'javascript/auto', 15 | }) 16 | 17 | config.resolve.alias = { 18 | ...config.resolve.alias, 19 | "@emotion/core": "@emotion/react", 20 | "emotion-theming": "@emotion/react", 21 | } 22 | 23 | return config 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { theme } from '../src/theme' 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: "^on[A-Z].*" }, 5 | chakra: { 6 | theme, 7 | }, 8 | controls: { 9 | matchers: { 10 | color: /(background|color)$/i, 11 | date: /Date$/, 12 | }, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": false, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 4 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | 6 | ## [2.0.0] - 2022-01-10 7 | ### Changed 8 | - Refactor the library to use compound components. 9 | 10 | ## [1.0.0] - 2021-12-13 11 | ### Added 12 | - Reset to current date if not selected on close 13 | 14 | ### Changed 15 | - Remove unnecessary `Box` component in `Calendar` 16 | 17 | ## [1.0.0-rc] - 2021-11-18 18 | ### Added 19 | - Preset of dates: `7 days`, `14 days`, `1 month` 20 | - Input event change example 21 | 22 | ### Changed 23 | - Rename prop `onlyOneMonth` to `singleMonth` 24 | - Rename `values` to `value` in README.md 25 | 26 | ## [0.4.0] - 2021-11-18 27 | ### Added 28 | - Allow disable previous dates 29 | - Allow disable future dates 30 | - Allow disable weekends 31 | - Allow disable some dates 32 | - Enable outside days in single month 33 | 34 | ## [0.3.0] - 2021-11-18 35 | ### Added 36 | - Support locales with date-fns 37 | - Month name and year format 38 | - Month missing `styles.month` prop 39 | 40 | ### Changed 41 | - Move `Buttons`, `NextButton` and `PrevButton` type to type file 42 | 43 | ## [0.2.0] - 2021-11-14 44 | ### Added 45 | - Show only one month 46 | - Select only one date 47 | - Example to close on select dates 48 | - Chevron icons in next/prev buttons 49 | - Custom buttons control 50 | 51 | ### Changed 52 | - Rename `Values` to `CalendarValues` 53 | - Prefix theme keys with `Calendar` 54 | - Use `onSelectDate` instead of `onSelectStartDate` and `onSelectEndDate` 55 | 56 | ## [0.1.1] - 2021-11-12 57 | ### Added 58 | - Select end/start dates 59 | - Default theme 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Wallace Oliveira 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Datepicker 2 | 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors-) 4 | 5 | 6 | A simple datepicker component build with [date-fns][1] and [Chakra-UI][2]. 7 | 8 | ## Table of Contents 9 | 10 | - [Requisites](#requisites) 11 | - [Installation and Usage](#installation-and-usage) 12 | - [Customizing](#customizing) 13 | - [License](#license) 14 | 15 | ## Requisites 16 | You need to install [date-fns][1] and [chakra-ui][2] in order to use this library. 17 | 18 | ```bash 19 | yarn add date-fns 20 | ``` 21 | 22 | To install chakra-ui follow their [guide here](https://chakra-ui.com/guides/first-steps#framework-guide). 23 | 24 | ## Installation and Usage 25 | After install these dependencies you can now install the library and use this as below: 26 | 27 | ```bash 28 | yarn add @uselessdev/datepicker 29 | ``` 30 | 31 | Before to use this you can create your own theme or use the default one. 32 | 33 | ```tsx 34 | import { ChakraProvider } from '@chakra-ui/react' 35 | import { 36 | Calendar, 37 | CalendarDefaultTheme, 38 | CalendarControls, 39 | CalendarPrevButton, 40 | CalendarNextButton, 41 | CalendarMonths, 42 | CalendarMonth, 43 | CalendarMonthName, 44 | CalendarWeek, 45 | CalendarDays, 46 | } from '@uselessdev/datepicker' 47 | 48 | export function App() { 49 | const [dates, setDates] = useState() 50 | 51 | const handleSelectDate = (values) => setDates(values) 52 | 53 | return ( 54 | return ( 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | ) 72 | ) 73 | } 74 | ``` 75 | 76 | **note that the example above doens't render an input but only the calendar** 77 | 78 | If you want to use this with inputs and a popover [you can see this example](https://uselessdev-datepicker.netlify.app/?path=/story/calendar--with-input-popover-start-end-dates) 79 | 80 | ### Customizing 81 | You can fully customize the Calendar component using the `extendTheme` provided by chakra-ui, you can see an example below. 82 | 83 | In your theme you can overrides the default theme (you can see all available components keys for theme customization here) 84 | 85 | ```ts 86 | import { extendTheme } from '@chakra-ui/react' 87 | import { CalendarDefaultTheme } from '@uselessdev/datepicker' 88 | 89 | export const theme = extendTheme(CalendarDefaultTheme, { 90 | components: { 91 | Calendar: { 92 | parts: ['calendar'], 93 | 94 | baseStyle: { 95 | calendar: { 96 | borderWidth: '6px', 97 | borderColor: 'pink.400', 98 | rounded: 'none', 99 | shadow: 'none', 100 | boxShadow: '32px 16px 0 6px #3B4DCC' 101 | }, 102 | }, 103 | }, 104 | 105 | CalendarControl: { 106 | parts: ['button'], 107 | 108 | baseStyle: { 109 | button: { 110 | h: 6, 111 | px: 2, 112 | rounded: 'none', 113 | fontSize: 'sm', 114 | color: 'white', 115 | bgColor: 'pink.400', 116 | 117 | _hover: { 118 | bgColor: 'pink.200', 119 | }, 120 | 121 | _focus: { 122 | outline: 'none', 123 | }, 124 | }, 125 | }, 126 | } 127 | }, 128 | }) 129 | ``` 130 | 131 | Now you can use this theme in `ChakraProvider`: 132 | 133 | ```tsx 134 | import { ChakraProvider } from '@chakra-ui/react' 135 | import { theme } from './theme' 136 | 137 | function App() { 138 | return ( 139 | 140 | {/* children... */} 141 | 142 | ) 143 | } 144 | ``` 145 | 146 | Theses changes will produce the following results in Calendar: 147 | 148 | ![Customized calendar](docs/datepicker-custom.png) 149 | 150 | ## Available components theme keys 151 | 152 | | Key name | Description | Parts | 153 | |-----------------|---------------------------------------------------------------------------|------------------------------------------| 154 | | Calendar | A multipart component this is reponsible for the calendar it self. |`calendar`, `months` | 155 | | CalendarMonth | Responsible to style one month block. |`month`, `name`, `week`, `weekday`, `days`| 156 | | CalendarDay | Applies styles to individual day. This is the only single part component. | -- | 157 | | CalendarControl | Applies styles to prev and next months. |`controls`, `button` | 158 | 159 | ## License 160 | This code is under the [Apache-2.0](LICENSE) License 161 | 162 | [1]: https://date-fns.org/ 163 | [2]: https://chakra-ui.com/ 164 | 165 | ## Contributors ✨ 166 | 167 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 |
Wallace Batista
Wallace Batista

💻 🤔
Leonardo Elias
Leonardo Elias

💻
kivi
kivi

💻
Guilherme Teixeira
Guilherme Teixeira

💻
Brano Zavracky
Brano Zavracky

💻
O. Qudah
O. Qudah

📖
Tom Chen
Tom Chen

📖 💻
Alexandre Stahmer
Alexandre Stahmer

💻
Raphael da Rocha Pinto Barboza
Raphael da Rocha Pinto Barboza

💻
Greg Leuch
Greg Leuch

💻
190 | 191 | 192 | 193 | 194 | 195 | 196 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 197 | -------------------------------------------------------------------------------- /docs/datepicker-custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiwllc/datepicker/23ccd88a06f489c3f9aa925aafff011e4e4f2933/docs/datepicker-custom.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'jest-environment-jsdom', 4 | testPathIgnorePatterns: ['dist/'], 5 | collectCoverage: false, 6 | collectCoverageFrom: ['src/**/*.ts(x)', '!src/**/*.stories.ts(x)'], 7 | setupFilesAfterEnv: ['/.jest/setup.ts'], 8 | snapshotSerializers: ['@emotion/jest/serializer'], 9 | moduleNameMapper: { 10 | renderer: '/.jest/with-theme.tsx', 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@uselessdev/datepicker", 3 | "version": "2.8.1", 4 | "description": "A simple react datepicker build with chakra-ui and date-fns", 5 | "main": "dist/uselessdev-datepicker.cjs.js", 6 | "module": "dist/uselessdev-datepicker.esm.js", 7 | "types": "dist/uselessdev-datepicker.cjs.d.ts", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+ssh://git@github.com/uselessdev/datepicker.git" 11 | }, 12 | "author": "Wallace Oliveira ", 13 | "license": "Apache-2.0", 14 | "private": false, 15 | "files": [ 16 | "dist" 17 | ], 18 | "scripts": { 19 | "dev": "preconstruct dev", 20 | "build": "preconstruct build", 21 | "lint": "eslint ./src/**", 22 | "storybook": "start-storybook -p 6006", 23 | "build-storybook": "build-storybook", 24 | "test": "jest", 25 | "prepublish": "yarn build" 26 | }, 27 | "devDependencies": { 28 | "@babel/core": "^7.16.0", 29 | "@chakra-ui/icons": "^1.1.7", 30 | "@chakra-ui/react": "^1.8.6", 31 | "@chakra-ui/storybook-addon": "^1.0.3", 32 | "@emotion/jest": "^11.7.1", 33 | "@emotion/react": "11.6.0", 34 | "@emotion/styled": "11.6.0", 35 | "@preconstruct/cli": "^2.1.5", 36 | "@storybook/addon-actions": "^6.5.4", 37 | "@storybook/addon-essentials": "^6.5.4", 38 | "@storybook/addon-links": "^6.5.4", 39 | "@storybook/react": "^6.5.4", 40 | "@testing-library/jest-dom": "^5.16.1", 41 | "@testing-library/react": "^12.1.2", 42 | "@types/jest": "^27.4.0", 43 | "@types/react": "^17.0.34", 44 | "@typescript-eslint/eslint-plugin": "^5.3.1", 45 | "@typescript-eslint/parser": "^5.3.1", 46 | "all-contributors-cli": "^6.20.0", 47 | "date-fns": "^2.25.0", 48 | "eslint": "^8.2.0", 49 | "eslint-config-prettier": "^8.3.0", 50 | "eslint-plugin-prettier": "^4.0.0", 51 | "eslint-plugin-react": "^7.27.0", 52 | "eslint-plugin-react-hooks": "^4.3.0", 53 | "framer-motion": ">=6.5.2", 54 | "jest": "^27.4.7", 55 | "react": "^17.0.2", 56 | "react-dom": "^17.0.2", 57 | "ts-jest": "^27.1.2", 58 | "tslib": "^2.3.1", 59 | "typescript": "^4.4.4" 60 | }, 61 | "peerDependencies": { 62 | "@chakra-ui/react": "^1.8.6 || ^2", 63 | "date-fns": "^2.25.0", 64 | "framer-motion": ">=6.5.2", 65 | "react": "^17.0.2 || ^18", 66 | "react-dom": "^17.0.2 || ^18" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/calendar.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ComponentStory, ComponentMeta } from '@storybook/react' 3 | import { addDays, format, isAfter, isBefore, isValid, subDays } from 'date-fns' 4 | import { 5 | Box, 6 | Button, 7 | Circle, 8 | Flex, 9 | Input, 10 | Popover, 11 | PopoverBody, 12 | PopoverContent, 13 | PopoverTrigger, 14 | Text, 15 | useDisclosure, 16 | useOutsideClick, 17 | VStack, 18 | } from '@chakra-ui/react' 19 | import * as locales from 'date-fns/locale' 20 | 21 | import { Calendar } from './calendar' 22 | import { CalendarMonth } from './month' 23 | import { CalendarDays } from './month-days' 24 | import { CalendarMonthName } from './month-name' 25 | import { CalendarWeek } from './month-week' 26 | import { CalendarMonths } from './months' 27 | import { CalendarControls } from './control' 28 | import { CalendarNextButton } from './control-next-button' 29 | import { CalendarPrevButton } from './control-prev-button' 30 | import { CalendarDate, CalendarValues } from './types' 31 | import { useCalendarDay } from './useCalendarDay' 32 | 33 | export default { 34 | title: 'Calendar', 35 | component: Calendar, 36 | } as ComponentMeta 37 | 38 | export const Basic: ComponentStory = () => { 39 | const [dates, setDates] = React.useState({}) 40 | 41 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 42 | 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ) 59 | } 60 | 61 | export const CustomLocale: ComponentStory = ({ locale }) => { 62 | const [dates, setDates] = React.useState({}) 63 | 64 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 65 | 66 | return ( 67 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | ) 87 | } 88 | 89 | const mapping = Object.fromEntries(Object.entries(locales)) 90 | 91 | CustomLocale.argTypes = { 92 | locale: { 93 | options: Object.keys(mapping), 94 | mapping, 95 | }, 96 | } 97 | 98 | CustomLocale.args = { 99 | locale: locales.ptBR, 100 | } 101 | 102 | export const DisablePastDates: ComponentStory = () => { 103 | const [dates, setDates] = React.useState({}) 104 | 105 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 106 | 107 | return ( 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | ) 123 | } 124 | 125 | export const DisablePastDatesFrom: ComponentStory = () => { 126 | const [dates, setDates] = React.useState({}) 127 | 128 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 129 | 130 | return ( 131 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | ) 150 | } 151 | 152 | export const DisableFutureDates: ComponentStory = () => { 153 | const [dates, setDates] = React.useState({}) 154 | 155 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 156 | 157 | return ( 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | ) 173 | } 174 | 175 | export const DisableFutureDatesFrom: ComponentStory = () => { 176 | const [dates, setDates] = React.useState({}) 177 | 178 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 179 | 180 | return ( 181 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | ) 200 | } 201 | 202 | export const DisableDates: ComponentStory = () => { 203 | const [dates, setDates] = React.useState({}) 204 | 205 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 206 | 207 | const today = new Date() 208 | const disabledDates = [ 209 | subDays(today, 5), 210 | today, 211 | addDays(today, 1), 212 | addDays(today, 2), 213 | addDays(today, 40), 214 | ] 215 | 216 | return ( 217 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | ) 236 | } 237 | 238 | export const DisableWeekends: ComponentStory = () => { 239 | const [dates, setDates] = React.useState({}) 240 | 241 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 242 | 243 | return ( 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | ) 259 | } 260 | 261 | export const AllowOutsideDays: ComponentStory = () => { 262 | const [dates, setDates] = React.useState({}) 263 | 264 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 265 | 266 | return ( 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | ) 282 | } 283 | 284 | export const SingleDateSelection: ComponentStory = () => { 285 | const [date, setDate] = React.useState() 286 | 287 | const handleSelectDate = (date: CalendarDate) => setDate(date) 288 | 289 | return ( 290 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ) 309 | } 310 | 311 | export const CustomControlButtons: ComponentStory = () => { 312 | const [date, setDate] = React.useState() 313 | 314 | const handleSelectDate = (date: CalendarDate) => setDate(date) 315 | 316 | return ( 317 | 322 | 323 | ( 325 | 328 | )} 329 | /> 330 | ( 332 | 335 | )} 336 | /> 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | ) 348 | } 349 | 350 | export const WithMultipleMonths: ComponentStory = () => { 351 | const [dates, setDates] = React.useState({}) 352 | 353 | const MONTHS = 2 354 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 355 | 356 | return ( 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | {[...Array(MONTHS).keys()].map(month => ( 365 | 366 | 367 | 368 | 369 | 370 | ))} 371 | 372 | 373 | ) 374 | } 375 | 376 | export const WithInputPopover: ComponentStory = () => { 377 | const [date, setDate] = React.useState() 378 | const [value, setValue] = React.useState('') 379 | 380 | const { isOpen, onOpen, onClose } = useDisclosure() 381 | 382 | const initialRef = React.useRef(null) 383 | const calendarRef = React.useRef(null) 384 | 385 | const handleSelectDate = (date: CalendarDate) => { 386 | setDate(date) 387 | setValue(() => (isValid(date) ? format(date, 'MM/dd/yyyy') : '')) 388 | onClose() 389 | } 390 | 391 | const match = (value: string) => value.match(/(\d{2})\/(\d{2})\/(\d{4})/) 392 | 393 | const handleInputChange = ({ 394 | target, 395 | }: React.ChangeEvent) => { 396 | setValue(target.value) 397 | 398 | if (match(target.value)) { 399 | onClose() 400 | } 401 | } 402 | 403 | useOutsideClick({ 404 | ref: calendarRef, 405 | handler: onClose, 406 | enabled: isOpen, 407 | }) 408 | 409 | React.useEffect(() => { 410 | if (match(value)) { 411 | const date = new Date(value) 412 | 413 | return setDate(date) 414 | } 415 | }, [value]) 416 | 417 | return ( 418 | 419 | 426 | 427 | 428 | 433 | 434 | 435 | 436 | 444 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 458 | 459 | 460 | 461 | 462 | 463 | 464 | 465 | 466 | 467 | ) 468 | } 469 | 470 | export const WithInputPopoverStartEndDates: ComponentStory = 471 | () => { 472 | const [dates, setDates] = React.useState({}) 473 | const [values, setValues] = React.useState({ 474 | start: '', 475 | end: '', 476 | }) 477 | 478 | const { isOpen, onOpen, onClose } = useDisclosure() 479 | 480 | const initialRef = React.useRef(null) 481 | const calendarRef = React.useRef(null) 482 | const startInputRef = React.useRef(null) 483 | const endInputRef = React.useRef(null) 484 | 485 | const MONTHS = 2 486 | 487 | const handleSelectDate = (dates: CalendarValues) => { 488 | setDates(dates) 489 | 490 | setValues({ 491 | start: isValid(dates.start) 492 | ? format(dates.start as Date, 'MM/dd/yyyy') 493 | : '', 494 | end: isValid(dates.end) ? format(dates.end as Date, 'MM/dd/yyyy') : '', 495 | }) 496 | 497 | if (dates.end) { 498 | onClose() 499 | } 500 | } 501 | 502 | const match = (value: string) => value.match(/(\d{2})\/(\d{2})\/(\d{4})/) 503 | 504 | const handleInputChange = ({ 505 | target, 506 | }: React.ChangeEvent) => { 507 | setValues({ 508 | ...values, 509 | [target.name]: target.value, 510 | }) 511 | 512 | if ( 513 | target.name === 'start' && 514 | match(target.value) && 515 | endInputRef.current 516 | ) { 517 | endInputRef.current.focus() 518 | } 519 | } 520 | 521 | useOutsideClick({ 522 | ref: calendarRef, 523 | handler: onClose, 524 | enabled: isOpen, 525 | }) 526 | 527 | React.useEffect(() => { 528 | if (match(values.start)) { 529 | const startDate = new Date(values.start) 530 | const isValidStartDate = isValid(startDate) 531 | const isAfterEndDate = dates.end && isAfter(startDate, dates.end) 532 | 533 | if (isValidStartDate && isAfterEndDate) { 534 | setValues({ ...values, end: '' }) 535 | return setDates({ end: undefined, start: startDate }) 536 | } 537 | 538 | return setDates({ ...dates, start: startDate }) 539 | } 540 | }, [values.start]) 541 | 542 | React.useEffect(() => { 543 | if (match(values.end)) { 544 | const endDate = new Date(values.end) 545 | const isValidEndDate = isValid(endDate) 546 | const isBeforeStartDate = dates.start && isBefore(endDate, dates.start) 547 | 548 | if (isValidEndDate && isBeforeStartDate) { 549 | setValues({ ...values, start: '' }) 550 | 551 | startInputRef.current?.focus() 552 | 553 | return setDates({ start: undefined, end: endDate }) 554 | } 555 | 556 | onClose() 557 | return setDates({ ...dates, end: endDate }) 558 | } 559 | }, [values.end]) 560 | 561 | return ( 562 | 563 | 570 | 571 | 579 | 587 | 595 | 596 | 597 | 598 | 606 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | {[...Array(MONTHS).keys()].map(m => ( 619 | 620 | 621 | 622 | 623 | 624 | ))} 625 | 626 | 627 | 628 | 629 | 630 | 631 | ) 632 | } 633 | 634 | export const CustomContent: ComponentStory = () => { 635 | const [dates, setDates] = React.useState({}) 636 | 637 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 638 | 639 | const today = new Date() 640 | 641 | const addSevenDays = () => 642 | setDates({ 643 | start: today, 644 | end: addDays(today, 7), 645 | }) 646 | 647 | const subSevenDays = () => 648 | setDates({ 649 | start: subDays(today, 7), 650 | end: today, 651 | }) 652 | 653 | return ( 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | 662 | 663 | 664 | 665 | 666 | 667 | 668 | 669 | 670 | 678 | 681 | 682 | 685 | 686 | 687 | ) 688 | } 689 | 690 | export const StartWeekDay: ComponentStory = () => { 691 | const [dates, setDates] = React.useState({}) 692 | 693 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 694 | 695 | return ( 696 | 697 | 698 | 699 | 700 | 701 | 702 | 703 | 704 | 705 | 706 | 707 | 708 | 709 | 710 | ) 711 | } 712 | 713 | export const HighlightToday: ComponentStory = () => { 714 | const [dates, setDates] = React.useState({}) 715 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 716 | 717 | return ( 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | 726 | 727 | 728 | 729 | 730 | 731 | 732 | ) 733 | } 734 | 735 | export const WeekSelection: ComponentStory = () => { 736 | const [dates, setDates] = React.useState({}) 737 | 738 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 739 | 740 | return ( 741 | 747 | 748 | 749 | 750 | 751 | 752 | 753 | 754 | 755 | 756 | 757 | 758 | 759 | 760 | ) 761 | } 762 | 763 | function CustomDay() { 764 | const { day, onSelectDates, isSelected, isInRange } = useCalendarDay() 765 | 766 | const selected = isSelected 767 | ? { 768 | bgColor: 'teal.400', 769 | color: 'white', 770 | rounded: 0, 771 | _hover: { 772 | bgColor: 'teal.300', 773 | }, 774 | } 775 | : {} 776 | 777 | const range = isInRange 778 | ? { 779 | bgColor: 'teal.300', 780 | color: 'white', 781 | rounded: 'none', 782 | _hover: { 783 | bgColor: 'teal.200', 784 | }, 785 | } 786 | : {} 787 | 788 | return ( 789 | 803 | ) 804 | } 805 | 806 | export const WithCustomDay: ComponentStory = () => { 807 | const [dates, setDates] = React.useState({}) 808 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 809 | 810 | return ( 811 | 812 | 813 | 814 | 815 | 816 | 817 | 818 | 819 | 820 | 821 | 822 | 823 | 824 | 825 | 826 | 827 | ) 828 | } 829 | 830 | export const AllowSelectSameDay: ComponentStory = () => { 831 | const [dates, setDates] = React.useState({}) 832 | 833 | const handleSelectDate = (dates: CalendarValues) => setDates(dates) 834 | 835 | return ( 836 | 837 | 838 | 839 | 840 | 841 | 842 | 843 | 844 | 845 | 846 | 847 | 848 | 849 | 850 | ) 851 | } 852 | -------------------------------------------------------------------------------- /src/calendar.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | Box, 4 | Flex, 5 | Input, 6 | Popover, 7 | PopoverBody, 8 | PopoverContent, 9 | PopoverTrigger, 10 | useDisclosure, 11 | useOutsideClick, 12 | } from '@chakra-ui/react' 13 | import { addMonths, format, isAfter, isBefore, isValid } from 'date-fns' 14 | import { 15 | Calendar, 16 | CalendarControls, 17 | CalendarNextButton, 18 | CalendarPrevButton, 19 | CalendarMonths, 20 | CalendarMonth, 21 | CalendarMonthName, 22 | CalendarDays, 23 | CalendarWeek, 24 | CalendarDate, 25 | CalendarValues, 26 | } from './index' 27 | import { render, screen, fireEvent } from 'renderer' 28 | 29 | function CalendarBasic() { 30 | const [date, setDate] = React.useState() 31 | const [value, setValue] = React.useState('') 32 | 33 | const { isOpen, onOpen, onClose } = useDisclosure() 34 | 35 | const initialRef = React.useRef(null) 36 | const calendarRef = React.useRef(null) 37 | 38 | const handleSelectDate = (date: CalendarDate) => { 39 | setDate(date) 40 | setValue(() => (isValid(date) ? format(date, 'MM/dd/yyyy') : '')) 41 | onClose() 42 | } 43 | 44 | const match = (value: string) => value.match(/(\d{2})\/(\d{2})\/(\d{4})/) 45 | 46 | const handleInputChange = ({ 47 | target, 48 | }: React.ChangeEvent) => { 49 | setValue(target.value) 50 | 51 | if (match(target.value)) { 52 | onClose() 53 | } 54 | } 55 | 56 | useOutsideClick({ 57 | ref: calendarRef, 58 | handler: onClose, 59 | enabled: isOpen, 60 | }) 61 | 62 | React.useEffect(() => { 63 | if (match(value)) { 64 | const date = new Date(value) 65 | 66 | return setDate(date) 67 | } 68 | }, [value]) 69 | 70 | return ( 71 | 72 | 79 | 80 | 81 | 86 | 87 | 88 | 89 | 97 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | ) 121 | } 122 | 123 | function CalendarRange() { 124 | const [dates, setDates] = React.useState({}) 125 | const [values, setValues] = React.useState({ 126 | start: '', 127 | end: '', 128 | }) 129 | 130 | const { isOpen, onOpen, onClose } = useDisclosure() 131 | 132 | const initialRef = React.useRef(null) 133 | const calendarRef = React.useRef(null) 134 | const startInputRef = React.useRef(null) 135 | const endInputRef = React.useRef(null) 136 | 137 | const MONTHS = 2 138 | 139 | const handleSelectDate = (dates: CalendarValues) => { 140 | setDates(dates) 141 | 142 | setValues({ 143 | start: isValid(dates.start) 144 | ? format(dates.start as Date, 'MM/dd/yyyy') 145 | : '', 146 | end: isValid(dates.end) ? format(dates.end as Date, 'MM/dd/yyyy') : '', 147 | }) 148 | 149 | if (dates.end) { 150 | onClose() 151 | } 152 | } 153 | 154 | const match = (value: string) => value.match(/(\d{2})\/(\d{2})\/(\d{4})/) 155 | 156 | const handleInputChange = ({ 157 | target, 158 | }: React.ChangeEvent) => { 159 | setValues({ 160 | ...values, 161 | [target.name]: target.value, 162 | }) 163 | 164 | if (target.name === 'start' && match(target.value) && endInputRef.current) { 165 | endInputRef.current.focus() 166 | } 167 | } 168 | 169 | useOutsideClick({ 170 | ref: calendarRef, 171 | handler: onClose, 172 | enabled: isOpen, 173 | }) 174 | 175 | React.useEffect(() => { 176 | if (match(values.start)) { 177 | const startDate = new Date(values.start) 178 | const isValidStartDate = isValid(startDate) 179 | const isAfterEndDate = dates.end && isAfter(startDate, dates.end) 180 | 181 | if (isValidStartDate && isAfterEndDate) { 182 | setValues({ ...values, end: '' }) 183 | return setDates({ end: undefined, start: startDate }) 184 | } 185 | 186 | return setDates({ ...dates, start: startDate }) 187 | } 188 | }, [values.start]) 189 | 190 | React.useEffect(() => { 191 | if (match(values.end)) { 192 | const endDate = new Date(values.end) 193 | const isValidEndDate = isValid(endDate) 194 | const isBeforeStartDate = dates.start && isBefore(endDate, dates.start) 195 | 196 | if (isValidEndDate && isBeforeStartDate) { 197 | setValues({ ...values, start: '' }) 198 | 199 | startInputRef.current?.focus() 200 | 201 | return setDates({ start: undefined, end: endDate }) 202 | } 203 | 204 | onClose() 205 | return setDates({ ...dates, end: endDate }) 206 | } 207 | }, [values.end]) 208 | 209 | return ( 210 | 211 | 218 | 219 | 227 | 235 | 243 | 244 | 245 | 246 | 254 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | {[...Array(MONTHS).keys()].map(m => ( 267 | 268 | 269 | 270 | 271 | 272 | ))} 273 | 274 | 275 | 276 | 277 | 278 | 279 | ) 280 | } 281 | 282 | const TODAY = new Date() 283 | const CURRENT_MONTH_NUMBER = format(TODAY, 'MM') 284 | const CURRENT_YEAR = format(TODAY, 'yyyy') 285 | const CURRENT_CALENDAR_NAME = format(TODAY, 'MMMM, yyyy') 286 | 287 | const NEXT_MONTH = addMonths(TODAY, 1) 288 | const NEXT_MONTH_NUMBER = format(NEXT_MONTH, 'MM') 289 | const NEXT_CALENDAR_NAME = format(NEXT_MONTH, 'MMMM, yyyy') 290 | 291 | test('should select a date', () => { 292 | render() 293 | 294 | const INPUT = screen.getByPlaceholderText(/select a date/i) 295 | fireEvent.click(INPUT) 296 | 297 | const CALENDAR_HEADER = screen.getByRole('heading') 298 | expect(CALENDAR_HEADER).toHaveTextContent(CURRENT_CALENDAR_NAME) 299 | 300 | fireEvent.click( 301 | screen.getByRole('button', { name: `${CURRENT_MONTH_NUMBER}-5` }) 302 | ) 303 | expect(INPUT).toHaveValue(`${CURRENT_MONTH_NUMBER}/05/${CURRENT_YEAR}`) 304 | 305 | expect(CALENDAR_HEADER).not.toBeInTheDocument() 306 | }) 307 | 308 | test('should change date in input', () => { 309 | render() 310 | 311 | const INPUT = screen.getByPlaceholderText(/select a date/i) 312 | fireEvent.click(INPUT) 313 | 314 | const CALENDAR_HEADER = screen.getByRole('heading') 315 | expect(CALENDAR_HEADER).toHaveTextContent(CURRENT_CALENDAR_NAME) 316 | 317 | fireEvent.change(INPUT, { target: { value: '01/10/2022' } }) 318 | 319 | fireEvent.click(INPUT) 320 | 321 | expect(INPUT).toHaveValue('01/10/2022') 322 | expect(screen.getByRole('button', { current: 'date' })).toHaveTextContent( 323 | '10' 324 | ) 325 | 326 | fireEvent.change(INPUT, { target: { value: '01/05/2022' } }) 327 | 328 | expect(CALENDAR_HEADER).not.toBeInTheDocument() 329 | }) 330 | 331 | test('should select a range date interval', () => { 332 | render() 333 | 334 | const START_INPUT = screen.getByPlaceholderText(/start date/i) 335 | const END_INPUT = screen.getByPlaceholderText(/end date/i) 336 | 337 | fireEvent.click(START_INPUT) 338 | 339 | const [HEADING_FIRST, HEADING_SECOND] = screen.getAllByRole('heading') 340 | 341 | expect(HEADING_FIRST).toHaveTextContent(CURRENT_CALENDAR_NAME) 342 | expect(HEADING_SECOND).toHaveTextContent(NEXT_CALENDAR_NAME) 343 | 344 | fireEvent.click( 345 | screen.getByRole('button', { name: `${CURRENT_MONTH_NUMBER}-5` }) 346 | ) 347 | 348 | fireEvent.click( 349 | screen.getByRole('button', { name: `${NEXT_MONTH_NUMBER}-5` }) 350 | ) 351 | 352 | expect(START_INPUT).toHaveValue(`${CURRENT_MONTH_NUMBER}/05/${CURRENT_YEAR}`) 353 | expect(END_INPUT).toHaveValue(`${NEXT_MONTH_NUMBER}/05/${CURRENT_YEAR}`) 354 | 355 | expect(HEADING_FIRST).not.toBeInTheDocument() 356 | expect(HEADING_SECOND).not.toBeInTheDocument() 357 | }) 358 | 359 | test('should change a range date interval in input', () => { 360 | render() 361 | 362 | const START_INPUT = screen.getByPlaceholderText(/start date/i) 363 | const END_INPUT = screen.getByPlaceholderText(/end date/i) 364 | 365 | fireEvent.click(START_INPUT) 366 | 367 | const [HEADING_FIRST, HEADING_SECOND] = screen.getAllByRole('heading') 368 | 369 | expect(HEADING_FIRST).toHaveTextContent(CURRENT_CALENDAR_NAME) 370 | expect(HEADING_SECOND).toHaveTextContent(NEXT_CALENDAR_NAME) 371 | 372 | fireEvent.change(START_INPUT, { target: { value: '01/10/2022' } }) 373 | 374 | expect(END_INPUT).toHaveFocus() 375 | 376 | fireEvent.change(END_INPUT, { target: { value: '02/05/2022' } }) 377 | 378 | /** reopen calendar */ 379 | fireEvent.click(START_INPUT) 380 | 381 | const [FIRST_SELECTED, SECOND_SELECTED] = screen.getAllByRole('button', { 382 | current: 'date', 383 | }) 384 | 385 | expect(FIRST_SELECTED).toHaveTextContent('10') 386 | expect(SECOND_SELECTED).toHaveTextContent('5') 387 | 388 | fireEvent.change(START_INPUT, { target: { value: '01/09/2022' } }) 389 | 390 | expect(HEADING_FIRST).not.toBeInTheDocument() 391 | expect(HEADING_SECOND).not.toBeInTheDocument() 392 | }) 393 | 394 | test('should change a range date interval end before start and start after end', () => { 395 | render() 396 | 397 | const START_INPUT = screen.getByPlaceholderText(/start date/i) 398 | const END_INPUT = screen.getByPlaceholderText(/end date/i) 399 | 400 | fireEvent.click(START_INPUT) 401 | 402 | const [HEADING_FIRST, HEADING_SECOND] = screen.getAllByRole('heading') 403 | 404 | expect(HEADING_FIRST).toHaveTextContent(CURRENT_CALENDAR_NAME) 405 | expect(HEADING_SECOND).toHaveTextContent(NEXT_CALENDAR_NAME) 406 | 407 | fireEvent.change(START_INPUT, { target: { value: '01/10/2022' } }) 408 | 409 | expect(END_INPUT).toHaveFocus() 410 | 411 | fireEvent.change(END_INPUT, { target: { value: '02/05/2022' } }) 412 | 413 | /** reopen calendar */ 414 | fireEvent.click(END_INPUT) 415 | 416 | expect(END_INPUT).toHaveFocus() 417 | 418 | const FIRST_HEADING = screen.getByRole('heading', { name: 'January, 2022' }) 419 | const LAST_HEADING = screen.getByRole('heading', { name: 'February, 2022' }) 420 | 421 | fireEvent.change(END_INPUT, { target: { value: '01/05/2022' } }) 422 | expect(START_INPUT).toHaveValue('') 423 | expect(START_INPUT).toHaveFocus() 424 | expect(FIRST_HEADING).toBeInTheDocument() 425 | 426 | fireEvent.change(START_INPUT, { target: { value: '01/07/2022' } }) 427 | expect(END_INPUT).toHaveValue('') 428 | expect(END_INPUT).toHaveFocus() 429 | expect(LAST_HEADING).toBeInTheDocument() 430 | 431 | fireEvent.change(END_INPUT, { target: { value: '01/10/2022' } }) 432 | expect(FIRST_HEADING).not.toBeInTheDocument() 433 | expect(LAST_HEADING).not.toBeInTheDocument() 434 | }) 435 | -------------------------------------------------------------------------------- /src/calendar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useMultiStyleConfig, Flex } from '@chakra-ui/react' 3 | import { CalendarContext } from './context' 4 | import { useCalendar } from './useCalendar' 5 | import { 6 | endOfWeek, 7 | isAfter, 8 | isBefore, 9 | isSameDay, 10 | isValid, 11 | Locale, 12 | startOfWeek, 13 | } from 'date-fns' 14 | import { CalendarDate, CalendarStyles, CalendarValues, Target } from './types' 15 | 16 | export type Calendar = React.PropsWithChildren<{ 17 | value: CalendarValues 18 | onSelectDate: (value: CalendarDate | CalendarValues) => void 19 | months?: number 20 | locale?: Locale 21 | allowOutsideDays?: boolean 22 | disablePastDates?: boolean | Date 23 | disableFutureDates?: boolean | Date 24 | disableWeekends?: boolean 25 | disableDates?: CalendarDate[] 26 | singleDateSelection?: boolean 27 | weekdayFormat?: string 28 | weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 29 | highlightToday?: boolean 30 | weekDateSelection?: boolean 31 | allowSelectSameDay?: boolean 32 | }> 33 | 34 | export function Calendar({ 35 | children, 36 | months, 37 | value, 38 | allowOutsideDays, 39 | singleDateSelection, 40 | disablePastDates, 41 | disableFutureDates, 42 | disableWeekends, 43 | disableDates, 44 | locale, 45 | weekdayFormat, 46 | onSelectDate, 47 | weekStartsOn, 48 | weekDateSelection, 49 | highlightToday, 50 | allowSelectSameDay, 51 | }: Calendar) { 52 | const styles = useMultiStyleConfig('Calendar', {}) as CalendarStyles 53 | 54 | const { resetDate, ...values } = useCalendar({ 55 | allowOutsideDays, 56 | blockFuture: false, 57 | start: value?.start || new Date(), 58 | months, 59 | locale, 60 | weekStartsOn, 61 | }) 62 | 63 | const [target, setTarget] = React.useState(Target.START) 64 | 65 | React.useEffect(() => { 66 | if (isValid(value.start)) { 67 | resetDate() 68 | } 69 | // missing resetDate, adding resetDate causes to calendar 70 | // impossible to navigation through months. 71 | // eslint-disable-next-line react-hooks/exhaustive-deps 72 | }, [value.start]) 73 | 74 | const selectDateHandler = (date: CalendarDate) => { 75 | if (singleDateSelection) { 76 | return onSelectDate(date) 77 | } 78 | 79 | if (weekDateSelection) { 80 | return onSelectDate({ 81 | start: startOfWeek(date, { locale, weekStartsOn }), 82 | end: endOfWeek(date, { locale, weekStartsOn }), 83 | }) 84 | } 85 | 86 | if ( 87 | !allowSelectSameDay && 88 | ((value.start && isSameDay(date, value.start)) || 89 | (value.end && isSameDay(date, value.end))) 90 | ) { 91 | return 92 | } 93 | 94 | if (value.start && isBefore(date, value.start)) { 95 | return onSelectDate({ ...value, start: date }) 96 | } 97 | 98 | if (value.end && isAfter(date, value.end)) { 99 | return onSelectDate({ ...value, end: date }) 100 | } 101 | 102 | if (target === Target.END) { 103 | setTarget(Target.START) 104 | return onSelectDate({ ...value, end: date }) 105 | } 106 | 107 | setTarget(Target.END) 108 | return onSelectDate({ ...value, start: date }) 109 | } 110 | 111 | return ( 112 | 128 | {children} 129 | 130 | ) 131 | } 132 | -------------------------------------------------------------------------------- /src/context.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Locale } from 'date-fns' 3 | import { CalendarDate } from './types' 4 | 5 | export type CalendarContext = { 6 | dates: { 7 | startDateOfMonth: Date 8 | endDateOfMonth: Date 9 | startWeek: Date 10 | endWeek: Date 11 | days: (CalendarDate | null)[] 12 | }[] 13 | nextMonth: VoidFunction 14 | prevMonth: VoidFunction 15 | onSelectDates: (date: CalendarDate) => void 16 | startSelectedDate?: CalendarDate 17 | endSelectedDate?: CalendarDate 18 | allowOutsideDays?: boolean 19 | disablePastDates?: boolean | Date 20 | disableFutureDates?: boolean | Date 21 | disableWeekends?: boolean 22 | disableDates?: CalendarDate[] 23 | locale?: Locale 24 | weekdayFormat?: string 25 | weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 26 | highlightToday?: boolean 27 | } 28 | 29 | export const CalendarContext = React.createContext({ 30 | dates: [], 31 | nextMonth: () => null, 32 | prevMonth: () => null, 33 | onSelectDates: () => null, 34 | weekStartsOn: 0, 35 | }) 36 | -------------------------------------------------------------------------------- /src/control-next-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Button, useMultiStyleConfig } from '@chakra-ui/react' 3 | import { CalendarContext } from './context' 4 | import { CalendarControlStyles } from './types' 5 | 6 | type CalendarNextButton = { 7 | as?: ({ onClick }: { onClick: VoidFunction }) => JSX.Element 8 | } 9 | 10 | export function CalendarNextButton({ as }: CalendarNextButton) { 11 | const styles = useMultiStyleConfig( 12 | 'CalendarControl', 13 | {} 14 | ) as CalendarControlStyles 15 | const { nextMonth } = React.useContext(CalendarContext) 16 | 17 | if (as) { 18 | return as({ onClick: nextMonth }) 19 | } 20 | 21 | return ( 22 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/control-prev-button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Button, useMultiStyleConfig } from '@chakra-ui/react' 3 | import { CalendarContext } from './context' 4 | import { CalendarControlStyles } from './types' 5 | 6 | type CalendarPrevButton = { 7 | as?: ({ onClick }: { onClick: VoidFunction }) => JSX.Element 8 | } 9 | 10 | export function CalendarPrevButton({ as }: CalendarPrevButton) { 11 | const styles = useMultiStyleConfig( 12 | 'CalendarControl', 13 | {} 14 | ) as CalendarControlStyles 15 | const { prevMonth } = React.useContext(CalendarContext) 16 | 17 | if (as) { 18 | return as({ onClick: prevMonth }) 19 | } 20 | 21 | return ( 22 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/control.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Flex, useMultiStyleConfig } from '@chakra-ui/react' 3 | import { CalendarControlStyles } from './types' 4 | 5 | export type CalendarControls = React.PropsWithChildren 6 | 7 | export function CalendarControls({ children }: CalendarControls) { 8 | const styles = useMultiStyleConfig( 9 | 'CalendarControl', 10 | {} 11 | ) as CalendarControlStyles 12 | 13 | return {children} 14 | } 15 | -------------------------------------------------------------------------------- /src/day.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Button, ButtonProps, useStyleConfig } from '@chakra-ui/react' 3 | import { useCalendarDay } from './useCalendarDay' 4 | import { format } from 'date-fns' 5 | 6 | export type CalendarDay = React.PropsWithChildren 7 | 8 | export function CalendarDay({ children, ...props }: CalendarDay) { 9 | const { day, interval, variant, isDisabled, onSelectDates } = useCalendarDay() 10 | const styles = useStyleConfig('CalendarDay', { variant, interval }) 11 | 12 | return ( 13 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './calendar' 2 | export * from './context' 3 | export * from './control' 4 | export * from './control-next-button' 5 | export * from './control-prev-button' 6 | export * from './day' 7 | export * from './month' 8 | export * from './month-days' 9 | export * from './month-name' 10 | export * from './month-week' 11 | export * from './months' 12 | export * from './types' 13 | export * from './useCalendar' 14 | export * from './useCalendarDay' 15 | export { theme as CalendarDefaultTheme } from './theme' 16 | -------------------------------------------------------------------------------- /src/month-days.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Grid, useMultiStyleConfig } from '@chakra-ui/react' 3 | import { format } from 'date-fns' 4 | import type { CalendarMonthStyles } from './types' 5 | import { CalendarContext } from './context' 6 | import { CalendarDay } from './day' 7 | import { MonthContext } from './month' 8 | import { DayContext } from './useCalendarDay' 9 | 10 | export function CalendarDays({ children }: React.PropsWithChildren) { 11 | const styles = useMultiStyleConfig('CalendarMonth', {}) as CalendarMonthStyles 12 | const { dates } = React.useContext(CalendarContext) 13 | const { month } = React.useContext(MonthContext) 14 | 15 | return ( 16 | 17 | {dates[Number(month)].days.map((day, index) => { 18 | if (!day) { 19 | return 20 | } 21 | 22 | return ( 23 | 24 | {children ? children : {children}} 25 | 26 | ) 27 | })} 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/month-name.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Heading, useMultiStyleConfig } from '@chakra-ui/react' 3 | import { format as dateFormat } from 'date-fns' 4 | import { CalendarContext } from './context' 5 | import { MonthContext } from './month' 6 | import { CalendarMonthStyles } from './types' 7 | 8 | export type CalendarMonthName = { 9 | format?: string 10 | } 11 | 12 | export function CalendarMonthName({ 13 | format = 'MMMM, yyyy', 14 | }: CalendarMonthName) { 15 | const styles = useMultiStyleConfig('CalendarMonth', {}) as CalendarMonthStyles 16 | const { dates, locale } = React.useContext(CalendarContext) 17 | const { month } = React.useContext(MonthContext) 18 | 19 | const currentMonth = dates[Number(month)].startDateOfMonth 20 | 21 | return ( 22 | 23 | {dateFormat(currentMonth, format, { locale })} 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/month-week.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Grid, Text, useMultiStyleConfig } from '@chakra-ui/react' 3 | import { addDays, format, startOfWeek } from 'date-fns' 4 | import { CalendarContext } from './context' 5 | import { CalendarMonthStyles } from './types' 6 | 7 | type Weekdays = { 8 | weekdayFormat?: string 9 | locale?: Locale 10 | weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 11 | } 12 | 13 | function weekdays({ weekdayFormat = 'E', locale, weekStartsOn }: Weekdays) { 14 | const start = startOfWeek(new Date(), { locale, weekStartsOn }) 15 | return [...Array(7).keys()].map(i => 16 | format(addDays(start, i), weekdayFormat, { locale }) 17 | ) 18 | } 19 | 20 | export function CalendarWeek() { 21 | const styles = useMultiStyleConfig('CalendarMonth', {}) as CalendarMonthStyles 22 | const { locale, weekdayFormat, weekStartsOn } = 23 | React.useContext(CalendarContext) 24 | const week = weekdays({ weekdayFormat, locale, weekStartsOn }) 25 | 26 | return ( 27 | 28 | {week.map((weekday, i) => ( 29 | 30 | {weekday} 31 | 32 | ))} 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/month.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Box, useMultiStyleConfig } from '@chakra-ui/react' 3 | import { CalendarMonthStyles } from './types' 4 | 5 | export type CalendarMonth = React.PropsWithChildren<{ month?: number }> 6 | 7 | type MonthContext = { 8 | month?: number 9 | } 10 | 11 | export const MonthContext = React.createContext({ 12 | month: 0, 13 | }) 14 | 15 | export function CalendarMonth({ children, month = 0 }: CalendarMonth) { 16 | const styles = useMultiStyleConfig('CalendarMonth', {}) as CalendarMonthStyles 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/months.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import type { GridProps } from '@chakra-ui/react' 3 | import { Grid, useMultiStyleConfig } from '@chakra-ui/react' 4 | import { CalendarStyles } from './types' 5 | 6 | export type CalendarMonths = React.PropsWithChildren 7 | 8 | export function CalendarMonths({ children, ...props }: CalendarMonths) { 9 | const styles = useMultiStyleConfig('Calendar', {}) as CalendarStyles 10 | 11 | return {children} 12 | } 13 | -------------------------------------------------------------------------------- /src/theme/calendar.ts: -------------------------------------------------------------------------------- 1 | import { ComponentMultiStyleConfig } from '@chakra-ui/react' 2 | 3 | export const Calendar: ComponentMultiStyleConfig = { 4 | parts: ['calendar', 'months'], 5 | 6 | baseStyle: { 7 | calendar: { 8 | position: 'relative', 9 | w: 'min-content', 10 | borderWidth: '1px', 11 | rounded: 'md', 12 | shadow: 'lg', 13 | }, 14 | 15 | months: { 16 | p: 4, 17 | w: '100%', 18 | gridTemplateColumns: '1fr 1fr', 19 | }, 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /src/theme/controls.ts: -------------------------------------------------------------------------------- 1 | import { ComponentMultiStyleConfig } from '@chakra-ui/react' 2 | 3 | export const CalendarControl: ComponentMultiStyleConfig = { 4 | parts: ['controls', 'button'], 5 | 6 | baseStyle: { 7 | controls: { 8 | position: 'absolute', 9 | p: 4, 10 | w: '100%', 11 | justifyContent: 'space-between', 12 | }, 13 | 14 | button: { 15 | h: 6, 16 | px: 2, 17 | lineHeight: 0, 18 | fontSize: 'md', 19 | rounded: 'md', 20 | }, 21 | }, 22 | } 23 | -------------------------------------------------------------------------------- /src/theme/day.ts: -------------------------------------------------------------------------------- 1 | import { ComponentSingleStyleConfig } from '@chakra-ui/react' 2 | 3 | export const CalendarDay: ComponentSingleStyleConfig = { 4 | baseStyle: { 5 | rounded: 'none', 6 | bgColor: 'transparent', 7 | 8 | _hover: { 9 | bgColor: 'gray.100', 10 | }, 11 | 12 | _disabled: { 13 | color: 'gray.200', 14 | _hover: { 15 | cursor: 'initial', 16 | bgColor: 'transparent', 17 | }, 18 | }, 19 | }, 20 | 21 | sizes: { 22 | sm: { 23 | h: 8, 24 | }, 25 | }, 26 | 27 | variants: { 28 | selected: { 29 | bgColor: 'pink.400', 30 | color: 'white', 31 | 32 | _hover: { 33 | bgColor: 'pink.300', 34 | }, 35 | }, 36 | 37 | range: { 38 | bgColor: 'pink.200', 39 | color: 'white', 40 | 41 | _hover: { 42 | bgColor: 'pink.100', 43 | }, 44 | 45 | _disabled: { 46 | _hover: { 47 | bgColor: 'pink.300', 48 | }, 49 | }, 50 | }, 51 | 52 | outside: { 53 | color: 'gray.300', 54 | }, 55 | today: { 56 | bgColor: 'pink.100', 57 | _hover: { 58 | bgColor: 'pink.200', 59 | }, 60 | }, 61 | }, 62 | 63 | defaultProps: { 64 | size: 'sm', 65 | }, 66 | } 67 | -------------------------------------------------------------------------------- /src/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme } from '@chakra-ui/react' 2 | import { Calendar } from './calendar' 3 | import { CalendarMonth } from './month' 4 | import { CalendarDay } from './day' 5 | import { CalendarControl } from './controls' 6 | 7 | export const theme = extendTheme({ 8 | components: { 9 | Calendar, 10 | CalendarMonth, 11 | CalendarDay, 12 | CalendarControl, 13 | }, 14 | }) 15 | -------------------------------------------------------------------------------- /src/theme/month.ts: -------------------------------------------------------------------------------- 1 | import { ComponentMultiStyleConfig } from '@chakra-ui/react' 2 | 3 | export const CalendarMonth: ComponentMultiStyleConfig = { 4 | parts: ['month', 'name', 'week', 'weekday', 'days'], 5 | 6 | baseStyle: { 7 | name: { 8 | h: 8, 9 | fontSize: 'md', 10 | lineHeight: 6, 11 | textAlign: 'center', 12 | textTransform: 'capitalize', 13 | }, 14 | 15 | week: { 16 | gridTemplateColumns: 'repeat(7, 1fr)', 17 | }, 18 | 19 | weekday: { 20 | color: 'gray.500', 21 | textAlign: 'center', 22 | textTransform: 'capitalize', 23 | }, 24 | 25 | days: { 26 | gridTemplateColumns: 'repeat(7, 1fr)', 27 | }, 28 | }, 29 | 30 | defaultProps: { 31 | name: { 32 | as: 'h2', 33 | }, 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { CSSObject } from '@chakra-ui/react' 2 | 3 | export type CalendarDate = Date | number 4 | 5 | export type CalendarValues = { 6 | start?: CalendarDate 7 | end?: CalendarDate 8 | } 9 | 10 | export type Buttons = ({ onClick }: { onClick: () => void }) => JSX.Element 11 | 12 | export enum Target { 13 | START = 'start', 14 | END = 'end', 15 | } 16 | 17 | export type CalendarThemeKeys = 'calendar' | 'months' 18 | export type CalendarStyles = Record 19 | 20 | export type CalendarMonthThemeKeys = 21 | | 'month' 22 | | 'name' 23 | | 'week' 24 | | 'weekday' 25 | | 'days' 26 | export type CalendarMonthStyles = Record 27 | 28 | export type CalendarControlThemeKeys = 'controls' | 'button' 29 | export type CalendarControlStyles = Record 30 | -------------------------------------------------------------------------------- /src/useCalendar.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | addMonths, 4 | eachDayOfInterval, 5 | endOfMonth, 6 | endOfWeek, 7 | isSameMonth, 8 | Locale, 9 | startOfMonth, 10 | startOfWeek, 11 | subMonths, 12 | } from 'date-fns' 13 | import type { CalendarDate } from './types' 14 | 15 | function replaceOutMonthDays(days: CalendarDate[], date: CalendarDate) { 16 | return days.map(d => (isSameMonth(date, d) ? d : null)) 17 | } 18 | 19 | export type UseCalendar = { 20 | start: CalendarDate 21 | blockFuture?: boolean 22 | allowOutsideDays?: boolean 23 | months?: number 24 | locale?: Locale 25 | weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6 26 | } 27 | 28 | export function useCalendar({ 29 | start, 30 | months = 1, 31 | blockFuture, 32 | allowOutsideDays, 33 | locale, 34 | weekStartsOn, 35 | }: UseCalendar) { 36 | const initialState = blockFuture ? subMonths(start, 1) : start 37 | const [date, setDate] = React.useState(initialState) 38 | 39 | const actions = React.useMemo( 40 | function actionsFn() { 41 | const nextMonth = () => setDate(prevSet => addMonths(prevSet, 1)) 42 | const prevMonth = () => setDate(prevSet => subMonths(prevSet, 1)) 43 | 44 | const resetDate = () => setDate(initialState) 45 | 46 | const dates = [...Array(months).keys()].map(i => { 47 | const month = addMonths(date, i) 48 | 49 | const startDateOfMonth = startOfMonth(month) 50 | const endDateOfMonth = endOfMonth(month) 51 | const startWeek = startOfWeek(startDateOfMonth, { 52 | locale, 53 | weekStartsOn, 54 | }) 55 | const endWeek = endOfWeek(endDateOfMonth, { locale, weekStartsOn }) 56 | const days = eachDayOfInterval({ start: startWeek, end: endWeek }) 57 | 58 | return { 59 | startDateOfMonth, 60 | endDateOfMonth, 61 | startWeek, 62 | endWeek, 63 | days: allowOutsideDays ? days : replaceOutMonthDays(days, month), 64 | } 65 | }) 66 | 67 | return { 68 | nextMonth, 69 | prevMonth, 70 | resetDate, 71 | dates, 72 | } 73 | }, 74 | [allowOutsideDays, date, initialState, months] 75 | ) 76 | 77 | return { 78 | startDate: date, 79 | ...actions, 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/useCalendarDay.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | eachDayOfInterval, 4 | endOfMonth, 5 | isAfter, 6 | isBefore, 7 | isSameDay, 8 | isWeekend, 9 | startOfMonth, 10 | } from 'date-fns' 11 | import { CalendarContext } from './context' 12 | import { MonthContext } from './month' 13 | import { CalendarDate } from './types' 14 | 15 | export type CalendarDayContext = { 16 | day: CalendarDate 17 | } 18 | 19 | export const DayContext = React.createContext({ day: 0 }) 20 | 21 | export function useCalendarDay() { 22 | const { 23 | dates, 24 | onSelectDates, 25 | startSelectedDate, 26 | endSelectedDate, 27 | disableDates, 28 | disableFutureDates, 29 | disablePastDates, 30 | disableWeekends, 31 | highlightToday, 32 | } = React.useContext(CalendarContext) 33 | 34 | const { day } = React.useContext(DayContext) 35 | const { month } = React.useContext(MonthContext) 36 | 37 | let variant: 'selected' | 'range' | 'outside' | 'today' | undefined 38 | 39 | if (highlightToday && isSameDay(new Date(), day)) { 40 | variant = 'today' 41 | } 42 | 43 | const isSelected = 44 | (startSelectedDate && isSameDay(day, startSelectedDate)) || 45 | (endSelectedDate && isSameDay(day, endSelectedDate)) 46 | 47 | if (isSelected) { 48 | variant = 'selected' 49 | } 50 | 51 | if ( 52 | (isBefore(day, startOfMonth(dates[Number(month)].startDateOfMonth)) || 53 | isAfter(day, endOfMonth(dates[Number(month)].startDateOfMonth))) && 54 | !isSelected 55 | ) { 56 | variant = 'outside' 57 | } 58 | 59 | const interval = 60 | startSelectedDate && 61 | endSelectedDate && 62 | eachDayOfInterval({ 63 | start: startSelectedDate, 64 | end: endSelectedDate, 65 | }) 66 | 67 | const isInRange = interval 68 | ? interval.some(date => isSameDay(day, date) && !isSelected) 69 | : false 70 | 71 | if (isInRange && !isSelected) { 72 | variant = 'range' 73 | } 74 | 75 | const isDisabled = 76 | (disablePastDates && 77 | isBefore( 78 | day, 79 | disablePastDates instanceof Date ? disablePastDates : new Date() 80 | )) || 81 | (disableFutureDates && 82 | isAfter( 83 | day, 84 | disableFutureDates instanceof Date ? disableFutureDates : new Date() 85 | )) || 86 | (disableWeekends && isWeekend(day)) || 87 | (disableDates && disableDates.some(date => isSameDay(day, date))) 88 | 89 | return { 90 | day, 91 | variant, 92 | isSelected, 93 | interval, 94 | isInRange, 95 | isDisabled, 96 | onSelectDates, 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "module": "esnext", 5 | "target": "es5", 6 | "lib": ["es6", "dom", "ES2016", "ES2017"], 7 | "jsx": "react-jsx", 8 | "sourceMap": true, 9 | "allowJs": false, 10 | "declaration": true, 11 | "declarationDir": "types", 12 | "emitDeclarationOnly": true, 13 | "moduleResolution": "node", 14 | "forceConsistentCasingInFileNames": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noImplicitAny": true, 18 | "strictNullChecks": true, 19 | "suppressImplicitAnyIndexErrors": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "esModuleInterop": true, 23 | "downlevelIteration": true, 24 | "rootDir": ".", 25 | 26 | "baseUrl": "./", 27 | "paths": { 28 | "renderer": ["./.jest/with-theme"] 29 | } 30 | }, 31 | "include": ["src"], 32 | "exclude": ["node_modules", "dist"] 33 | } 34 | --------------------------------------------------------------------------------