├── src ├── vite-env.d.ts ├── index.tsx ├── utils │ ├── class-names.ts │ ├── mod.ts │ ├── match.ts │ ├── story.ts │ ├── config.ts │ └── render.ts ├── hooks │ ├── useIsomorphicLayoutEffect.ts │ ├── useEvent.ts │ ├── useSyncRef.ts │ ├── useScrollIntoItemIfNeeded.ts │ ├── useOnClickOutside.ts │ ├── useDisposables.ts │ └── useEventListener.ts ├── GetStarted.mdx ├── style.css ├── components │ └── datepicker │ │ ├── index.tsx │ │ ├── item │ │ ├── Item.stories.tsx │ │ └── Item.tsx │ │ ├── input │ │ ├── Input.stories.tsx │ │ └── Input.tsx │ │ ├── provider │ │ ├── Provider.stories.tsx │ │ └── Provider.tsx │ │ ├── button │ │ ├── Button.tsx │ │ └── Button.stories.tsx │ │ ├── items │ │ ├── Items.tsx │ │ └── Items.stories.tsx │ │ └── picker │ │ ├── Picker.tsx │ │ └── Picker.stories.tsx ├── type.ts ├── jalali │ ├── config.ts │ ├── format.ts │ └── parse.ts └── context │ └── context.ts ├── .husky ├── pre-commit └── commit-msg ├── .storybook ├── manager-head.html ├── manager.ts ├── theme.ts ├── main.ts ├── fix-title.js └── preview.tsx ├── media └── screenshot.png ├── postcss.config.js ├── .prettierrc ├── .yarnrc.yml ├── tailwind.config.ts ├── tsconfig.node.json ├── test.js ├── .gitignore ├── .eslintrc.cjs ├── tsconfig.json ├── LICENSE ├── vite.config.ts ├── .github └── workflows │ ├── deply-storybook.yml │ └── release-please.yml ├── package.json ├── README.md └── CHANGELOG.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './components/datepicker'; 2 | export { config } from './utils/config'; 3 | -------------------------------------------------------------------------------- /.storybook/manager-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aliakbarazizi/headless-datepicker/HEAD/media/screenshot.png -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit ${1} 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/manager-api'; 2 | import theme from './theme'; 3 | 4 | addons.setConfig({ 5 | theme: theme, 6 | }); 7 | -------------------------------------------------------------------------------- /src/utils/class-names.ts: -------------------------------------------------------------------------------- 1 | export function classNames( 2 | ...classes: (false | null | undefined | string)[] 3 | ): string { 4 | return classes.filter(Boolean).join(' '); 5 | } 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true, 4 | "importOrder": ["^[./].*(? any }, 4 | >( 5 | value: T, 6 | map: Map, 7 | ): Map extends { [key in T]: (value: key) => infer Return } ? Return : never { 8 | if (value in map) { 9 | const callback: any = map[value]; 10 | return typeof callback === 'function' ? callback(value) : callback; 11 | } 12 | throw new Error('Invalid match value'); 13 | } 14 | -------------------------------------------------------------------------------- /src/GetStarted.mdx: -------------------------------------------------------------------------------- 1 | import { Canvas, Markdown, Meta } from '@storybook/blocks'; 2 | import ReadMe from '../README.md?raw'; 3 | import * as ProviderStories from './components/datepicker/provider/Provider.stories'; 4 | 5 | 6 | 7 | {ReadMe.match(/#[^#]*/)[0].replace(/

/s, '', '')} 8 | 9 | 10 | 11 | {ReadMe.replace(/#[^#]*/, '')} 12 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .dark { 6 | color-scheme: dark; 7 | } 8 | 9 | html, 10 | body { 11 | height: 100%; 12 | } 13 | 14 | body { 15 | @apply bg-white dark:bg-zinc-600 dark:text-slate-100; 16 | } 17 | 18 | ul { 19 | list-style-type: disc; 20 | } 21 | 22 | #storybook-root { 23 | height: 100%; 24 | } 25 | 26 | .docblock-argstable-body td span[class] { 27 | white-space: pre; 28 | } 29 | 30 | img { 31 | display: inline; 32 | } 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .pnp.* 26 | .yarn/* 27 | !.yarn/patches 28 | !.yarn/plugins 29 | !.yarn/releases 30 | !.yarn/sdks 31 | !.yarn/versions 32 | 33 | storybook-static -------------------------------------------------------------------------------- /src/hooks/useEvent.ts: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; 3 | 4 | export const useEvent = function useEvent< 5 | F extends (...args: any[]) => any, 6 | P extends any[] = Parameters, 7 | R = ReturnType, 8 | >(cb: (...args: P) => R) { 9 | const cache = useRef(cb); 10 | 11 | useIsomorphicLayoutEffect(() => { 12 | cache.current = cb; 13 | }, [cb]); 14 | 15 | return React.useCallback((...args: P) => cache.current(...args), [cache]); 16 | }; 17 | -------------------------------------------------------------------------------- /src/hooks/useSyncRef.ts: -------------------------------------------------------------------------------- 1 | import { ForwardedRef, RefObject, useEffect } from 'react'; 2 | 3 | export const useSyncRef = ( 4 | innerRef: RefObject, 5 | externalRef: ForwardedRef, 6 | ) => { 7 | // keep both refs in sync 8 | useEffect(() => { 9 | // handle no ref... ^_^U 10 | if (!externalRef) return; 11 | 12 | // handle callback refs 13 | if (typeof externalRef === 'function') { 14 | externalRef(innerRef.current); 15 | } 16 | // handle object refs 17 | else { 18 | externalRef.current = innerRef.current; 19 | } 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /src/hooks/useScrollIntoItemIfNeeded.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react'; 2 | import { itemDataAttribute } from '../components/datepicker/item/Item'; 3 | 4 | export function useScrollIntoItemIfNeeded( 5 | enable: boolean, 6 | type?: string, 7 | value?: string | number, 8 | ) { 9 | useLayoutEffect(() => { 10 | if (enable && value && type) { 11 | const el = document.querySelector( 12 | `[${itemDataAttribute}="${type}-${value}"]`, 13 | ); 14 | 15 | if (el) { 16 | el.scrollIntoView({ block: 'nearest' }); 17 | } 18 | } 19 | }, [type, value, enable]); 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2020: true, 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:@typescript-eslint/recommended', 9 | 'plugin:react-hooks/recommended', 10 | 'plugin:storybook/recommended', 11 | 'prettier', 12 | ], 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | ecmaVersion: 'latest', 16 | sourceType: 'module', 17 | }, 18 | rules: { 19 | '@typescript-eslint/no-explicit-any': 'off', 20 | '@typescript-eslint/no-non-null-assertion': 'off', 21 | '@typescript-eslint/no-unused-vars': 'off', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "allowJs": false, 9 | "outDir": "dist", 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true 23 | }, 24 | "include": ["src", "./.storybook"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useOnClickOutside.ts: -------------------------------------------------------------------------------- 1 | import { RefObject } from 'react'; 2 | import useEventListener from './useEventListener'; 3 | 4 | type Handler = (event: MouseEvent) => void; 5 | 6 | function useOnClickOutside( 7 | refs: RefObject | Array | undefined>, 8 | handler: Handler, 9 | ): void { 10 | useEventListener('mousedown', (event) => { 11 | // Do nothing if clicking ref's element or descendent elements 12 | 13 | if ( 14 | (Array.isArray(refs) ? refs : [refs]).some((ref) => { 15 | if (ref === undefined) return false; 16 | const el = ref?.current; 17 | 18 | if (!el || el.contains(event.target as Node)) { 19 | return true; 20 | } 21 | }) 22 | ) { 23 | return; 24 | } 25 | 26 | handler(event); 27 | }); 28 | } 29 | 30 | export default useOnClickOutside; 31 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import { withoutVitePlugins } from '@storybook/builder-vite'; 2 | import type { StorybookConfig } from '@storybook/react-vite'; 3 | 4 | const config: StorybookConfig = { 5 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 6 | addons: [ 7 | '@storybook/addon-links', 8 | '@storybook/addon-essentials', 9 | '@storybook/addon-interactions', 10 | '@storybook/addon-styling', 11 | ], 12 | framework: { 13 | name: '@storybook/react-vite', 14 | options: {}, 15 | }, 16 | docs: { 17 | autodocs: 'tag', 18 | }, 19 | typescript: { 20 | reactDocgen: 'react-docgen-typescript', 21 | }, 22 | async viteFinal(config) { 23 | return { 24 | ...config, 25 | plugins: await withoutVitePlugins(config.plugins, ['vite:dts']), 26 | }; 27 | }, 28 | core: { 29 | disableTelemetry: true, 30 | }, 31 | }; 32 | export default config; 33 | -------------------------------------------------------------------------------- /src/components/datepicker/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps, ComponentButton } from './button/Button'; 2 | import { ComponentInput, Input, InputProps } from './input/Input'; 3 | import { ComponentItem, Item, ItemProps } from './item/Item'; 4 | import { ComponentItems, Items, ItemsProps } from './items/Items'; 5 | import { ComponentPicker, Picker, PickerProps } from './picker/Picker'; 6 | import { 7 | ComponentProvider, 8 | ProviderProps as DatepickerProps, 9 | Provider, 10 | } from './provider/Provider'; 11 | 12 | export type { 13 | DatepickerProps, 14 | PickerProps, 15 | InputProps, 16 | ButtonProps, 17 | ItemsProps, 18 | ItemProps, 19 | }; 20 | 21 | export type * from '../../context/context'; 22 | 23 | const Datepicker = Object.assign(Provider as ComponentProvider, { 24 | Picker: Picker as ComponentPicker, 25 | Input: Input as ComponentInput, 26 | Button: Button as ComponentButton, 27 | Items: Items as ComponentItems, 28 | Item: Item as ComponentItem, 29 | }); 30 | 31 | export { Datepicker }; 32 | -------------------------------------------------------------------------------- /src/components/datepicker/item/Item.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { Item } from './Item'; 3 | 4 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 5 | const meta = { 6 | title: 'Datepicker/Item', 7 | component: Item, 8 | tags: ['autodocs'], 9 | argTypes: { 10 | as: { 11 | table: { 12 | defaultValue: { 13 | summary: 'div', 14 | }, 15 | }, 16 | }, 17 | }, 18 | parameters: { 19 | showDatepicker: true, 20 | }, 21 | } satisfies Meta; 22 | 23 | export default meta; 24 | type Story = StoryObj; 25 | 26 | export const DateItem = { 27 | parameters: { 28 | override: { 29 | item: `{item}`, 30 | }, 31 | }, 32 | args: { 33 | item: { 34 | type: 'day', 35 | isInCurrentMonth: true, 36 | isDisabled: false, 37 | isSelected: false, 38 | isHeader: false, 39 | isToday: false, 40 | key: 0, 41 | text: '1', 42 | value: new Date(), 43 | }, 44 | }, 45 | } satisfies Story; 46 | -------------------------------------------------------------------------------- /src/type.ts: -------------------------------------------------------------------------------- 1 | import { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react'; 2 | 3 | type OurProps = { 4 | /** 5 | * React Element type 6 | * 7 | * You can either use html tag as string or component name 8 | */ 9 | as?: Tag; 10 | 11 | /** 12 | * You can pass function as argument to access Datepicker context values 13 | */ 14 | children?: ReactNode | ((slot: Slot) => ReactNode); 15 | 16 | /** 17 | * You can use function for className to access Datepicker context values 18 | */ 19 | className?: 'className' extends keyof ComponentPropsWithoutRef 20 | ? string | ((slot: Slot) => string) 21 | : never; 22 | }; 23 | 24 | type OmitOurProps = Omit< 25 | Props, 26 | OmitProps | keyof OurProps 27 | >; 28 | 29 | export type Props< 30 | Tag extends ElementType, 31 | Slot = object, 32 | OmitProps extends PropertyKey = never, 33 | Overrides = object, 34 | > = OmitOurProps, OmitProps | keyof Overrides> & 35 | OurProps & 36 | Overrides; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ali Akbar Azizi 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 | -------------------------------------------------------------------------------- /.storybook/fix-title.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | /** 5 | * 6 | * @param {string} filePath 7 | * @param {string} title 8 | */ 9 | function fixTitle(filePath, title) { 10 | const htmlDocumentPath = path.resolve(__dirname, filePath); 11 | const htmlDocument = fs.readFileSync(htmlDocumentPath, 'utf-8'); 12 | const updatedHtmlDocument = htmlDocument.replace( 13 | /.*<\/title>/, 14 | `<title>${title}`, 15 | ); 16 | 17 | fs.writeFileSync(htmlDocumentPath, updatedHtmlDocument); 18 | } 19 | 20 | try { 21 | const args = process.argv.slice(2); 22 | const [title, distPath] = args; 23 | 24 | const storybookDistPath = `${distPath}/storybook-static`; 25 | const indexPath = `${storybookDistPath}/index.html`; 26 | const iframePath = `${storybookDistPath}/iframe.html`; 27 | 28 | console.log(`Rewriting index.html document title to ${title}.`); 29 | fixTitle(indexPath, title); 30 | 31 | console.log(`Rewriting iframe.html document title to ${title}.`); 32 | fixTitle(iframePath, title); 33 | 34 | console.log('Title rewrite complete.'); 35 | } catch (error) { 36 | console.log('Title rewrite failed.'); 37 | console.error(error); 38 | process.exit(1); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/datepicker/input/Input.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { Input } from './Input'; 3 | 4 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 5 | const meta = { 6 | title: 'Datepicker/Input', 7 | component: Input, 8 | tags: ['autodocs'], 9 | argTypes: { 10 | as: { 11 | table: { 12 | defaultValue: { 13 | summary: 'input', 14 | }, 15 | }, 16 | }, 17 | }, 18 | parameters: { 19 | showDatepicker: true, 20 | }, 21 | } satisfies Meta; 22 | 23 | export default meta; 24 | type Story = StoryObj; 25 | 26 | export const InputDateHour: Story = { 27 | args: { 28 | format: 'yyyy/MM/dd HH:mm', 29 | className: 30 | 'flex w-60 rounded-md p-2 shadow-sm outline-none ring-1 ring-gray-500 focus-within:ring-2 focus-within:ring-blue-600', 31 | }, 32 | }; 33 | 34 | export const InputDate: Story = { 35 | args: { 36 | format: 'yyyy/MM/dd', 37 | className: 38 | 'flex w-60 rounded-md p-2 shadow-sm outline-none ring-1 ring-gray-500 focus-within:ring-2 focus-within:ring-blue-600', 39 | }, 40 | }; 41 | 42 | export const InputHour: Story = { 43 | args: { 44 | format: 'HH:mm', 45 | className: 46 | 'flex w-60 rounded-md p-2 shadow-sm outline-none ring-1 ring-gray-500 focus-within:ring-2 focus-within:ring-blue-600', 47 | }, 48 | }; 49 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { resolve } from 'path'; 5 | import { defineConfig } from 'vite'; 6 | import dts from 'vite-plugin-dts'; 7 | import pkg from './package.json'; 8 | 9 | const dateFnsDirs = fs 10 | .readdirSync(path.join('.', 'node_modules', 'date-fns')) 11 | .map((d) => `date-fns/${d}`); 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig({ 15 | plugins: [ 16 | react(), 17 | dts({ 18 | insertTypesEntry: true, 19 | rollupTypes: true, 20 | }), 21 | ], 22 | build: { 23 | lib: { 24 | // Could also be a dictionary or array of multiple entry points 25 | entry: { 26 | 'headless-datepicker': resolve(__dirname, 'src/index.tsx'), 27 | jalali: resolve(__dirname, 'src/jalali/config.ts'), 28 | }, 29 | // formats: ['es', 'umd'], 30 | // the proper extensions will be added 31 | }, 32 | rollupOptions: { 33 | // make sure to externalize deps that shouldn't be bundled 34 | // into your library 35 | external: Object.keys(pkg.dependencies) 36 | .concat(Object.keys(pkg.peerDependencies)) 37 | .concat(dateFnsDirs) 38 | .concat(['react/jsx-runtime']), 39 | output: { 40 | // Provide global variables to use in the UMD build 41 | // for externalized deps 42 | globals: { 43 | react: 'React', 44 | 'react-dom': 'ReactDOM', 45 | 'react/jsx-runtime': 'react/jsx-runtime', 46 | }, 47 | }, 48 | }, 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /.github/workflows/deply-storybook.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish storybook to GitHub Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | workflow_dispatch: 9 | 10 | permissions: 11 | contents: read 12 | pages: write 13 | id-token: write 14 | 15 | concurrency: 16 | group: 'pages' 17 | cancel-in-progress: false 18 | 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | 26 | - name: Use Node.js 27 | uses: actions/setup-node@v3 28 | with: 29 | node-version: '18.x' 30 | 31 | - name: Cache node modules 32 | id: cache-nodemodules 33 | uses: actions/cache@v2 34 | env: 35 | cache-name: cache-node-modules 36 | with: 37 | # caching node_modules 38 | path: node_modules 39 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} 40 | restore-keys: | 41 | ${{ runner.os }}-build-${{ env.cache-name }}- 42 | ${{ runner.os }}-build- 43 | ${{ runner.os }}- 44 | 45 | - name: Install dependencies 46 | if: steps.cache-nodemodules.outputs.cache-hit != 'true' 47 | run: yarn --immutable 48 | 49 | - name: Build project 50 | run: yarn build:storybook 51 | 52 | - name: Upload artifact 53 | uses: actions/upload-pages-artifact@v1 54 | with: 55 | path: ./storybook-static 56 | deploy: 57 | environment: 58 | name: github-pages 59 | url: ${{ steps.deployment.outputs.page_url }} 60 | runs-on: ubuntu-latest 61 | needs: build 62 | steps: 63 | - name: Deploy to GitHub Pages 64 | id: deployment 65 | uses: actions/deploy-pages@v2 66 | -------------------------------------------------------------------------------- /src/components/datepicker/item/Item.tsx: -------------------------------------------------------------------------------- 1 | import { ElementType, Ref, useContext } from 'react'; 2 | import { 3 | DateItemType, 4 | DatepickerSlot, 5 | HourItemType, 6 | useDatepickerSlot, 7 | } from '../../../context/context'; 8 | import { Props } from '../../../type'; 9 | import { forwardRef, render } from '../../../utils/render'; 10 | import { PickerContext } from '../picker/Picker'; 11 | import { ButtonProps } from '..'; 12 | 13 | const DEFAULT_TAG = 'button'; 14 | 15 | export const itemDataAttribute = 'data-calendar-item-id' as const; 16 | 17 | export type ItemProps< 18 | ElemenElementTag extends ElementType = typeof DEFAULT_TAG, 19 | > = Props< 20 | ElemenElementTag, 21 | DatepickerSlot, 22 | typeof itemDataAttribute, 23 | { 24 | item: DateItemType | HourItemType; 25 | } & Partial> 26 | >; 27 | 28 | export const Item = forwardRef( 29 | ( 30 | { item, action, ...props }: ItemProps, 31 | ref: Ref, 32 | ) => { 33 | const { id } = useContext(PickerContext); 34 | 35 | const { state, slot, dispatch } = useDatepickerSlot(); 36 | 37 | const ourProps = { 38 | [itemDataAttribute]: item.type + '-' + item.text, 39 | onClick: 40 | ('isHeader' in item && item.isHeader) || state.disabled 41 | ? undefined 42 | : () => { 43 | dispatch({ 44 | type: 'select', 45 | payload: { item, pickerId: id, action }, 46 | }); 47 | }, 48 | }; 49 | 50 | return render(ourProps, props, slot, DEFAULT_TAG, ref); 51 | }, 52 | ); 53 | 54 | export interface ComponentItem { 55 | ( 56 | props: ItemProps & React.RefAttributes, 57 | ): JSX.Element; 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | permissions: 7 | contents: write 8 | pull-requests: write 9 | 10 | name: release-please 11 | jobs: 12 | release-please: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Release 16 | id: release 17 | uses: google-github-actions/release-please-action@v3 18 | with: 19 | release-type: node 20 | package-name: test-release-please 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | if: ${{ steps.release.outputs.releases_created }} 26 | 27 | # Setup .npmrc file to publish to npm 28 | - name: Setup Node 29 | uses: actions/setup-node@v3 30 | with: 31 | node-version: '18.x' 32 | registry-url: 'https://registry.npmjs.org' 33 | if: ${{ steps.release.outputs.releases_created }} 34 | 35 | - name: Cache node modules 36 | id: cache-nodemodules 37 | uses: actions/cache@v2 38 | env: 39 | cache-name: cache-node-modules 40 | with: 41 | # caching node_modules 42 | path: node_modules 43 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/yarn.lock') }} 44 | restore-keys: | 45 | ${{ runner.os }}-build-${{ env.cache-name }}- 46 | ${{ runner.os }}-build- 47 | ${{ runner.os }}- 48 | if: ${{ steps.release.outputs.releases_created }} 49 | 50 | - name: CI 51 | run: yarn install --immutable 52 | if: steps.release.outputs.releases_created && steps.cache-nodemodules.outputs.cache-hit != 'true' 53 | 54 | - name: Build 55 | run: yarn build:dist 56 | if: ${{ steps.release.outputs.releases_created }} 57 | 58 | - name: Publish 59 | run: yarn npm publish --access public 60 | if: ${{ steps.release.outputs.releases_created }} 61 | env: 62 | YARN_NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 63 | -------------------------------------------------------------------------------- /src/hooks/useDisposables.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** 4 | * React reducer dispatch cause component re-render when the state don't changed 5 | * Unless we call it in useEffect 6 | * So with useDisposabled we queue the changed in useEffect 7 | * @see https://stackoverflow.com/a/58188135/1827594 8 | */ 9 | export function useDisposables() { 10 | // Using useState instead of useRef so that we can use the initializer function. 11 | const [d] = useState(disposables); 12 | useEffect(() => () => d.dispose(), [d]); 13 | return d; 14 | } 15 | 16 | function disposables() { 17 | const _disposables: Array<() => void> = []; 18 | 19 | const api = { 20 | addEventListener( 21 | element: HTMLElement | Window | Document, 22 | name: TEventName, 23 | listener: (event: WindowEventMap[TEventName]) => any, 24 | options?: boolean | AddEventListenerOptions, 25 | ) { 26 | element.addEventListener(name, listener as any, options); 27 | return api.add(() => 28 | element.removeEventListener(name, listener as any, options), 29 | ); 30 | }, 31 | 32 | requestAnimationFrame(...args: Parameters) { 33 | const raf = requestAnimationFrame(...args); 34 | return api.add(() => cancelAnimationFrame(raf)); 35 | }, 36 | 37 | nextFrame(...args: Parameters) { 38 | return api.requestAnimationFrame(() => { 39 | return api.requestAnimationFrame(...args); 40 | }); 41 | }, 42 | 43 | add(cb: () => void) { 44 | _disposables.push(cb); 45 | return () => { 46 | const idx = _disposables.indexOf(cb); 47 | if (idx >= 0) { 48 | for (const dispose of _disposables.splice(idx, 1)) { 49 | dispose(); 50 | } 51 | } 52 | }; 53 | }, 54 | 55 | dispose() { 56 | for (const dispose of _disposables.splice(0)) { 57 | dispose(); 58 | } 59 | }, 60 | }; 61 | 62 | return api; 63 | } 64 | -------------------------------------------------------------------------------- /src/components/datepicker/provider/Provider.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { useRef, useState } from 'react'; 3 | import { storyToJsx } from '../../../utils/story'; 4 | import { Input } from '../input/Input'; 5 | import { InputDateHour } from '../input/Input.stories'; 6 | import { Picker } from '../picker/Picker'; 7 | import { DateHourPicker } from '../picker/Picker.stories'; 8 | import { Provider } from './Provider'; 9 | 10 | const ProviderWithHooks = (args: any) => { 11 | const [value, setVaue] = useState(new Date()); 12 | const ref = useRef(null); 13 | 14 | return ( 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | const meta = { 23 | title: 'Datepicker/Provider', 24 | component: Provider, 25 | tags: ['autodocs'], 26 | parameters: { 27 | showPreview: false, 28 | }, 29 | argTypes: { 30 | as: { 31 | table: { 32 | defaultValue: { 33 | summary: 'React.Fragment', 34 | }, 35 | }, 36 | }, 37 | value: { control: false }, 38 | defaultValue: { control: false }, 39 | config: { control: false }, 40 | filterDate: { control: false }, 41 | }, 42 | render: (args) => , 43 | } satisfies Meta; 44 | 45 | export default meta; 46 | type Story = StoryObj; 47 | 48 | export const DatePicker = { 49 | parameters: { 50 | override: { 51 | children: `${storyToJsx(InputDateHour, {}, 'Input', 1)} 52 | ${storyToJsx(DateHourPicker, { alwaysOpen: false }, 'Picker', 1)}`, 53 | value: '{value}', 54 | onChange: '{setValue}', 55 | }, 56 | }, 57 | args: {}, 58 | } satisfies Story; 59 | 60 | export const FilterDays = { 61 | parameters: { 62 | override: { 63 | children: `${storyToJsx(InputDateHour, {}, 'Input', 1)} 64 | ${storyToJsx(DateHourPicker, { alwaysOpen: false }, 'Picker', 1)}`, 65 | value: '{value}', 66 | onChange: '{setValue}', 67 | }, 68 | }, 69 | args: { 70 | filterDate: (date: Date) => date.getDay() % 2 === 0, 71 | }, 72 | } satisfies Story; 73 | -------------------------------------------------------------------------------- /src/components/datepicker/button/Button.tsx: -------------------------------------------------------------------------------- 1 | import { ElementType, Ref, useContext, useRef } from 'react'; 2 | import { 3 | Action, 4 | DatepickerSlot, 5 | useDatepickerSlot, 6 | } from '../../../context/context'; 7 | import { useSyncRef } from '../../../hooks/useSyncRef'; 8 | import { Props } from '../../../type'; 9 | import { forwardRef, render } from '../../../utils/render'; 10 | import { PickerContext } from '../picker/Picker'; 11 | 12 | const DEFAULT_TAG = 'button'; 13 | 14 | export type ButtonProps< 15 | ElemenElementTag extends ElementType = typeof DEFAULT_TAG, 16 | > = Props< 17 | ElemenElementTag, 18 | DatepickerSlot, 19 | never, 20 | { 21 | /** 22 | * You can add the `Picker.id` in the end of action to specify the picker you want. 23 | * 24 | * For example `openTestPicker` will open the picker with `TestPicker` id. 25 | * 26 | * The only exception is `today` and `todayHour` since they set value for all pickers. 27 | * 28 | * The default target Picker is the parent Picker. 29 | * If no picker found, it will be the first Picker. 30 | * 31 | * Action can be one of these 32 | * - `today` set the value to today 33 | * - `todayHour` set the value to today with current hour 34 | * - `open` or `'open' + pickerId` open the calendar 35 | * - `close` or `'close' + pickerId` close the calendar 36 | * - `toggle` or `'toggle' + pickerId` close the calendar 37 | * - `next` or `'next' + pickerId` go to next month or year (depend on calendar mode) 38 | * - `prev` or `'prev' + pickerId` go to prev month or year (depend on calendar mode) 39 | * - `year` or `'year' + pickerId` set showing items to year 40 | * - `month` or `'month' + pickerId` set showing items to month 41 | * - `day` or `'day' + pickerId` set showing items to day 42 | */ 43 | action: Action; 44 | } 45 | >; 46 | 47 | export const Button = forwardRef( 48 | ( 49 | { action, ...props }: ButtonProps, 50 | ref: Ref, 51 | ) => { 52 | const { id } = useContext(PickerContext); 53 | 54 | const buttonRef = useRef(null); 55 | useSyncRef(buttonRef, ref); 56 | 57 | const { slot, dispatch } = useDatepickerSlot(); 58 | 59 | const ourProps = { 60 | onClick: () => 61 | dispatch({ 62 | type: 'action', 63 | payload: { action, ref: buttonRef, pickerId: id }, 64 | }), 65 | }; 66 | 67 | return render(ourProps, props, slot, DEFAULT_TAG, buttonRef); 68 | }, 69 | ); 70 | export interface ComponentButton { 71 | ( 72 | props: ButtonProps & React.RefAttributes, 73 | ): JSX.Element; 74 | } 75 | -------------------------------------------------------------------------------- /src/hooks/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef } from 'react'; 2 | import useIsomorphicLayoutEffect from './useIsomorphicLayoutEffect'; 3 | 4 | // MediaQueryList Event based useEventListener interface 5 | function useEventListener( 6 | eventName: K, 7 | handler: (event: MediaQueryListEventMap[K]) => void, 8 | element: RefObject, 9 | options?: boolean | AddEventListenerOptions, 10 | ): void; 11 | 12 | // Window Event based useEventListener interface 13 | function useEventListener( 14 | eventName: K, 15 | handler: (event: WindowEventMap[K]) => void, 16 | element?: undefined, 17 | options?: boolean | AddEventListenerOptions, 18 | ): void; 19 | 20 | // Element Event based useEventListener interface 21 | function useEventListener< 22 | K extends keyof HTMLElementEventMap, 23 | T extends HTMLElement = HTMLDivElement, 24 | >( 25 | eventName: K, 26 | handler: (event: HTMLElementEventMap[K]) => void, 27 | element: RefObject, 28 | options?: boolean | AddEventListenerOptions, 29 | ): void; 30 | 31 | // Document Event based useEventListener interface 32 | function useEventListener( 33 | eventName: K, 34 | handler: (event: DocumentEventMap[K]) => void, 35 | element: RefObject, 36 | options?: boolean | AddEventListenerOptions, 37 | ): void; 38 | 39 | function useEventListener< 40 | KW extends keyof WindowEventMap, 41 | KH extends keyof HTMLElementEventMap, 42 | KM extends keyof MediaQueryListEventMap, 43 | T extends HTMLElement | MediaQueryList | void = void, 44 | >( 45 | eventName: KW | KH | KM, 46 | handler: ( 47 | event: 48 | | WindowEventMap[KW] 49 | | HTMLElementEventMap[KH] 50 | | MediaQueryListEventMap[KM] 51 | | Event, 52 | ) => void, 53 | element?: RefObject, 54 | options?: boolean | AddEventListenerOptions, 55 | ) { 56 | // Create a ref that stores handler 57 | const savedHandler = useRef(handler); 58 | 59 | useIsomorphicLayoutEffect(() => { 60 | savedHandler.current = handler; 61 | }, [handler]); 62 | 63 | useEffect(() => { 64 | // Define the listening target 65 | const targetElement: T | Window = element?.current ?? window; 66 | 67 | if (!(targetElement && targetElement.addEventListener)) return; 68 | 69 | // Create event listener that calls handler function stored in ref 70 | const listener: typeof handler = (event) => savedHandler.current(event); 71 | 72 | targetElement.addEventListener(eventName, listener, options); 73 | 74 | // Remove event listener on cleanup 75 | return () => { 76 | targetElement.removeEventListener(eventName, listener, options); 77 | }; 78 | }, [eventName, element, options]); 79 | } 80 | 81 | export default useEventListener; 82 | -------------------------------------------------------------------------------- /src/utils/story.ts: -------------------------------------------------------------------------------- 1 | const printWidth = 80; 2 | 3 | export function addProvider(code: string) { 4 | return `const [value, setValue] = useState(new Date()); 5 | 6 | ${code.replace(/\n^/gm, '\n ')} 7 | `; 8 | } 9 | 10 | export function storyToJsx( 11 | story: any, 12 | override: any, 13 | displayName: string, 14 | intent: number, 15 | ) { 16 | const args: string[] = []; 17 | 18 | const props = { 19 | ...story.args, 20 | ...story.parameters.override, 21 | ...override, 22 | }; 23 | 24 | const space = ' '.repeat(intent * 2); 25 | 26 | let children = props.children; 27 | 28 | Object.entries(props as { [key: string]: any }).forEach(([key, value]) => { 29 | let v = 30 | (story.parameters.override && key in story.parameters.override) || 31 | key in override || 32 | key === 'children' 33 | ? value 34 | : typeof value === 'boolean' || typeof value === 'number' 35 | ? `{${value}}` 36 | : `"${value}"`; 37 | 38 | if (typeof v === 'string') { 39 | v = v.replace(/\n^/gm, '\n' + space); 40 | } 41 | 42 | if (key === 'children') { 43 | children = v; 44 | return; 45 | } 46 | 47 | if (v === false) { 48 | return; 49 | } 50 | 51 | args.push(v === '{true}' ? `${key}` : `${key}=${v}`); 52 | }); 53 | 54 | const isRoot = displayName === 'Datepicker'; 55 | 56 | const lineWidth = 57 | space.length + 58 | args.reduce((sum, a) => sum + a.length, 0) + 59 | (args.length - (isRoot ? 1 : 0)) * 2 + 60 | ``.length; 61 | 62 | const shouldAddBreakLine = 63 | lineWidth >= printWidth && 64 | (args.length > 1 || !args[0].match(/^\w+="[^"]*"$/)); 65 | 66 | const argDelimiter = shouldAddBreakLine ? '\n ' + space : ' '; 67 | 68 | let arg = argDelimiter + args.join(argDelimiter); 69 | 70 | if (typeof children === 'string') { 71 | arg += shouldAddBreakLine ? '\n' + space : ''; 72 | return replaceChildren( 73 | `<${displayName}${arg}/>`, 74 | children, 75 | displayName, 76 | intent, 77 | ); 78 | } else { 79 | arg += shouldAddBreakLine ? '\n' + space : ' '; 80 | return ``; 81 | } 82 | } 83 | 84 | export function replaceChildren( 85 | code: string, 86 | children: string, 87 | displayName: string, 88 | intent = 0, 89 | ) { 90 | const space = ' '.repeat(intent * 2); 91 | 92 | const prefix = displayName === 'Datepicker' ? '' : 'Datepicker.'; 93 | 94 | return children 95 | ? code 96 | // .replace(`<${displayName}`, `', 99 | // '>\n ' + space + children + `\n${space}`, 100 | // ) 101 | .replace( 102 | new RegExp(`<${displayName}((?:=>|<[^>]*>|[^>])*?)/?>(.|\n)*$`, 'g'), 103 | `<${prefix}${displayName}$1>\n ${space}${children}\n${space}`, 104 | ) 105 | : code; 106 | } 107 | -------------------------------------------------------------------------------- /src/components/datepicker/items/Items.tsx: -------------------------------------------------------------------------------- 1 | import { ElementType, Ref, useContext, useMemo } from 'react'; 2 | import { 3 | DateItemType, 4 | DatepickerSlot, 5 | HourItemType, 6 | useDatepickerSlot, 7 | } from '../../../context/context'; 8 | import { useScrollIntoItemIfNeeded } from '../../../hooks/useScrollIntoItemIfNeeded'; 9 | import { Props } from '../../../type'; 10 | import { forwardRef, render } from '../../../utils/render'; 11 | import { PickerContext } from '../picker/Picker'; 12 | 13 | const DEFAULT_TAG = 'div'; 14 | 15 | type ItemsType = DateItemType | HourItemType; 16 | 17 | export type ItemsProps< 18 | Type extends ItemsType['type'], 19 | ElemenElementTag extends ElementType = typeof DEFAULT_TAG, 20 | > = Props< 21 | ElemenElementTag, 22 | { 23 | items: Extract[]; 24 | type: Type; 25 | } & DatepickerSlot, 26 | never, 27 | { 28 | /** 29 | * Specifiy which type of items will calculate. 30 | * If it's empty you must set `defaultType` property in `Picker` component, 31 | * And the value will be calculated automatically. 32 | */ 33 | type?: Type; 34 | 35 | /** 36 | * Scroll to selected item when mounted 37 | * this is only for year, minute and hour 38 | */ 39 | disableAutoScroll?: boolean; 40 | } 41 | >; 42 | 43 | export const Items = forwardRef( 44 | ( 45 | { 46 | type: _type, 47 | disableAutoScroll, 48 | ...props 49 | }: ItemsProps, 50 | ref: Ref, 51 | ) => { 52 | const { id, defaultType } = useContext(PickerContext); 53 | const { state } = useDatepickerSlot(); 54 | 55 | const picker = id ? state.pickers[id] : undefined; 56 | 57 | const type: ItemsType['type'] | undefined = 58 | _type || picker?.type || defaultType; 59 | 60 | if (type === undefined) { 61 | throw new Error( 62 | 'No type provided, You need either need set the type to Items or set the defaultType to Picker component', 63 | ); 64 | } 65 | 66 | const value = state.valueRef.current; 67 | 68 | const filterDate = state.filterDate; 69 | 70 | const items = useMemo( 71 | () => 72 | type === 'hour' || type === 'minute' 73 | ? state.config[(type + 's') as `${typeof type}s`]({ 74 | type, 75 | hour: state.hour, 76 | minute: state.minute, 77 | } as any) 78 | : state.config[(type + 's') as `${typeof type}s`]({ 79 | type, 80 | year: state.year, 81 | month: state.month, 82 | value: value, 83 | startOfWeek: state.startOfWeek, 84 | } as any).map((item) => 85 | item.type === 'day' && !item.isHeader && !filterDate(item.value) 86 | ? { ...item, isDisabled: true } 87 | : item, 88 | ), 89 | [ 90 | type, 91 | value, 92 | state.config, 93 | state.month, 94 | state.year, 95 | state.hour, 96 | state.minute, 97 | state.startOfWeek, 98 | filterDate, 99 | ], 100 | ); 101 | 102 | useScrollIntoItemIfNeeded( 103 | disableAutoScroll !== true && 104 | picker !== undefined && 105 | (picker.alwaysOpen === true || picker.isOpen) && 106 | ['year', 'hour', 'minute'].includes(type), 107 | type, 108 | type !== 'day' ? state[type] : undefined, 109 | ); 110 | 111 | const ourProps = {}; 112 | 113 | return render( 114 | ourProps, 115 | props, 116 | { 117 | items, 118 | type, 119 | ...state, 120 | }, 121 | DEFAULT_TAG, 122 | ref, 123 | ); 124 | }, 125 | ); 126 | 127 | export interface ComponentItems { 128 | < 129 | Type extends ItemsType['type'], 130 | ElementTag extends ElementType = typeof DEFAULT_TAG, 131 | >( 132 | props: ItemsProps & React.RefAttributes, 133 | ): JSX.Element; 134 | } 135 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headless-datetimepicker", 3 | "description": "React headless datepicker", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/aliakbarazizi/headless-datepicker.git" 7 | }, 8 | "keywords": [ 9 | "React", 10 | "Datepicker", 11 | "headless" 12 | ], 13 | "author": "Ali Akbar Azizi", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/aliakbarazizi/headless-datepicker/issues" 17 | }, 18 | "homepage": "https://github.com/aliakbarazizi/headless-datepicker#readme", 19 | "files": [ 20 | "dist" 21 | ], 22 | "version": "4.0.0", 23 | "main": "./dist/headless-datepicker.js", 24 | "module": "./dist/headless-datepicker.mjs", 25 | "types": "./dist/headless-datepicker.d.ts", 26 | "typesVersions": { 27 | ">=4.2": { 28 | "*": [ 29 | "dist/headless-datepicker.d.ts" 30 | ], 31 | "jalali": [ 32 | "dist/jalali.d.ts" 33 | ] 34 | } 35 | }, 36 | "exports": { 37 | ".": { 38 | "types": "./dist/headless-datepicker.d.ts", 39 | "require": "./dist/headless-datepicker.mjs", 40 | "import": "./dist/headless-datepicker.mjs", 41 | "default": "./dist/headless-datepicker.mjs" 42 | }, 43 | "./jalali": { 44 | "types": "./dist/jalali.d.ts", 45 | "require": "./dist/jalali.mjs", 46 | "import": "./dist/jalali.mjs", 47 | "default": "./dist/jalali.mjs" 48 | } 49 | }, 50 | "scripts": { 51 | "dev": "vite", 52 | "storybook": "storybook dev -p 6006", 53 | "tsc": "tsc --watch", 54 | "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 55 | "build": "yarn lint && yarn build:tsc && yarn build:dist && yarn build:storybook", 56 | "build:tsc": "tsc", 57 | "build:dist": "vite build", 58 | "build:storybook": "storybook build --docs && node ./.storybook/fix-title.js 'React headless datepicker' ../", 59 | "preview": "vite preview", 60 | "prepare": "husky install" 61 | }, 62 | "dependencies": { 63 | "@floating-ui/react-dom": "^2.0.1", 64 | "date-fns": "^2.30.0" 65 | }, 66 | "devDependencies": { 67 | "@commitlint/cli": "^17.6.7", 68 | "@commitlint/config-conventional": "^17.6.7", 69 | "@storybook/addon-essentials": "^7.2.1", 70 | "@storybook/addon-interactions": "^7.2.1", 71 | "@storybook/addon-links": "^7.2.1", 72 | "@storybook/addon-styling": "^1.3.5", 73 | "@storybook/blocks": "^7.2.1", 74 | "@storybook/manager-api": "^7.2.1", 75 | "@storybook/react": "^7.2.1", 76 | "@storybook/react-vite": "^7.2.1", 77 | "@storybook/testing-library": "^0.2.0", 78 | "@storybook/theming": "^7.2.1", 79 | "@trivago/prettier-plugin-sort-imports": "^4.2.0", 80 | "@types/jalaali-js": "^1.1.1", 81 | "@types/react": "^18.2.18", 82 | "@types/react-dom": "^18.2.7", 83 | "@typescript-eslint/eslint-plugin": "^6.2.1", 84 | "@typescript-eslint/parser": "^6.2.1", 85 | "@vitejs/plugin-react": "^4.0.4", 86 | "autoprefixer": "^10.4.14", 87 | "eslint": "^8.46.0", 88 | "eslint-config-prettier": "^8.10.0", 89 | "eslint-plugin-react-hooks": "^4.6.0", 90 | "eslint-plugin-storybook": "^0.6.13", 91 | "husky": "^8.0.3", 92 | "jalaali-js": "^1.2.6", 93 | "lint-staged": "^13.2.3", 94 | "postcss": "^8.4.27", 95 | "prettier": "^3.0.1", 96 | "prettier-plugin-tailwindcss": "^0.4.1", 97 | "react": "^18.2.0", 98 | "react-dom": "^18.2.0", 99 | "react-syntax-highlighter": "^15.5.0", 100 | "storybook": "^7.2.1", 101 | "tailwindcss": "^3.3.3", 102 | "typescript": "5.0.4", 103 | "vite": "^4.4.8", 104 | "vite-plugin-dts": "^3.4.0" 105 | }, 106 | "peerDependencies": { 107 | "react": "^18.2.0", 108 | "react-dom": "^18.2.0" 109 | }, 110 | "lint-staged": { 111 | "*.{ts,tsx,js,jsx,json,css,md}": [ 112 | "prettier -w" 113 | ] 114 | }, 115 | "commitlint": { 116 | "extends": [ 117 | "@commitlint/config-conventional" 118 | ] 119 | }, 120 | "resolutions": { 121 | "markdown-to-jsx": "~7.1.8" 122 | }, 123 | "packageManager": "yarn@3.6.1" 124 | } 125 | -------------------------------------------------------------------------------- /src/components/datepicker/input/Input.tsx: -------------------------------------------------------------------------------- 1 | import { isValid } from 'date-fns'; 2 | import { 3 | ElementType, 4 | InputHTMLAttributes, 5 | Ref, 6 | useCallback, 7 | useMemo, 8 | useRef, 9 | useState, 10 | } from 'react'; 11 | import { DatepickerSlot, useDatepickerSlot } from '../../../context/context'; 12 | import { useDisposables } from '../../../hooks/useDisposables'; 13 | import { useEvent } from '../../../hooks/useEvent'; 14 | import { useSyncRef } from '../../../hooks/useSyncRef'; 15 | import { Props } from '../../../type'; 16 | import { forwardRef, render } from '../../../utils/render'; 17 | 18 | const DEFAULT_TAG = 'input'; 19 | 20 | export type InputProps< 21 | ElemenElementTag extends ElementType = typeof DEFAULT_TAG, 22 | > = Props< 23 | ElemenElementTag, 24 | DatepickerSlot, 25 | never, 26 | { 27 | /** 28 | * The string of tokens that used to format date 29 | * 30 | * You can pass function for custom formatting functions 31 | * 32 | * The default value is "yyyy/MM/dd" 33 | * 34 | * @see https://date-fns.org/docs/format 35 | * @param date current value 36 | * @returns string the value to show in input 37 | */ 38 | format?: string | ((date: Date | null) => string); 39 | 40 | /** 41 | * Parse the value of input when changed to Date 42 | * It will be ignored if the format value is not function. 43 | * 44 | * If you don't provide this and format value is a function the input will be readonly 45 | * 46 | * @param date 47 | * @param currentDate the current value of the Date it usefull to use it for reference in parse 48 | * @returns 49 | */ 50 | parse?: (date: string, currentDate: Date | null) => Date; 51 | } 52 | >; 53 | 54 | export const Input = forwardRef( 55 | ( 56 | { 57 | format = 'yyyy/MM/dd', 58 | parse, 59 | type, 60 | ...props 61 | }: InputProps, 62 | ref: Ref, 63 | ) => { 64 | const inputRef = useRef(null); 65 | useSyncRef(inputRef, ref); 66 | 67 | const { state, slot, dispatch } = useDatepickerSlot(); 68 | 69 | const formatter = useCallback( 70 | (date: Date | null) => 71 | typeof format === 'function' 72 | ? format(date) 73 | : state.config.format(date, format), 74 | [format, state.config], 75 | ); 76 | 77 | const [dirtyInputValue, setDirtyInputValue] = useState( 78 | undefined, 79 | ); 80 | 81 | const inputValue = useMemo( 82 | () => formatter(slot.value), 83 | [slot.value, formatter], 84 | ); 85 | 86 | const disposables = useDisposables(); 87 | 88 | const onFocus = useEvent(() => 89 | disposables.nextFrame(() => 90 | dispatch({ 91 | type: 'action', 92 | payload: { action: 'open', ref: inputRef }, 93 | }), 94 | ), 95 | ); 96 | 97 | const onChange = useEvent((e) => setDirtyInputValue(e.target.value)); 98 | 99 | const onBlur = useEvent((e) => { 100 | let parseValue: Date | null = null; 101 | 102 | if (e.target.value) 103 | try { 104 | parseValue = 105 | typeof format === 'function' 106 | ? parse!(e.target.value, slot.value) 107 | : state.config.parse(e.target.value, format, slot.value); 108 | } catch (e) { 109 | /* empty */ 110 | } 111 | 112 | if (parseValue !== null && isValid(parseValue)) { 113 | state.onChange(parseValue); 114 | } 115 | 116 | disposables.nextFrame(() => setDirtyInputValue(undefined)); 117 | }); 118 | 119 | const readOnly = 120 | typeof format === 'function' && typeof parse !== 'function'; 121 | 122 | const ourProps: InputHTMLAttributes = { 123 | type: type || 'text', 124 | readOnly, 125 | disabled: state.disabled, 126 | value: dirtyInputValue !== undefined ? dirtyInputValue : inputValue, 127 | onFocus, 128 | onChange: !readOnly ? onChange : undefined, 129 | onBlur: !readOnly ? onBlur : undefined, 130 | }; 131 | 132 | return render(ourProps, props, slot, DEFAULT_TAG, inputRef); 133 | }, 134 | ); 135 | 136 | export interface ComponentInput { 137 | ( 138 | props: InputProps & React.RefAttributes, 139 | ): JSX.Element; 140 | } 141 | -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | addDays, 3 | format as dateFromat, 4 | eachDayOfInterval, 5 | endOfMonth, 6 | parse, 7 | startOfDay, 8 | startOfMonth, 9 | startOfToday, 10 | } from 'date-fns'; 11 | import { 12 | DateItemType, 13 | DateParts, 14 | DatepickerConfig, 15 | } from '../components/datepicker'; 16 | import { mod } from './mod'; 17 | 18 | export const config: DatepickerConfig = { 19 | dayNames: [ 20 | 'Sunday', 21 | 'Monday', 22 | 'Tuesday', 23 | 'Wednesday', 24 | 'Thursday', 25 | 'Friday', 26 | 'Saturday', 27 | ], 28 | monthNames: [ 29 | 'January', 30 | 'February', 31 | 'March', 32 | 'April', 33 | 'May', 34 | 'June', 35 | 'July', 36 | 'August', 37 | 'September', 38 | 'October', 39 | 'November', 40 | 'December', 41 | ], 42 | format: function (date, format) { 43 | return date ? dateFromat(date, format) : ''; 44 | }, 45 | parse: function (date, format, referenceDate) { 46 | const parseDate = parse(date, format, referenceDate || new Date()); 47 | 48 | return parseDate; 49 | }, 50 | 51 | toDateParts: function (date) { 52 | return new Intl.DateTimeFormat('en-US', { 53 | year: 'numeric', 54 | month: 'numeric', 55 | day: 'numeric', 56 | }) 57 | .formatToParts(date) 58 | .reduce((acc, part) => { 59 | if (part.type !== 'literal') 60 | acc[part.type as keyof DateParts] = +part.value; 61 | return acc; 62 | }, {} as any); 63 | }, 64 | 65 | years: function ({ type, year }) { 66 | const todayYear = new Date().getFullYear(); 67 | 68 | return [...Array(200).keys()].map((value) => ({ 69 | type, 70 | key: type + value, 71 | isToday: todayYear === value + 1900, 72 | isSelected: year === value + 1900, 73 | isHeader: false, 74 | isDisabled: false, 75 | 76 | value: value + 1900, 77 | text: value + 1900 + '', 78 | })); 79 | }, 80 | months: function ({ type, month }) { 81 | const todayMonth = new Date().getMonth(); 82 | 83 | return [...this.monthNames.keys()].map((value) => ({ 84 | type, 85 | key: type + value, 86 | isToday: todayMonth === value, 87 | isSelected: month === value + 1, 88 | isHeader: false, 89 | isDisabled: false, 90 | 91 | value: value + 1, 92 | text: this.monthNames[value], 93 | })); 94 | }, 95 | days: function ({ type, month, startOfWeek, year, value }) { 96 | const date = new Date(year, month - 1, 1); 97 | 98 | const start = startOfMonth(date); 99 | const end = endOfMonth(date); 100 | 101 | const endOfWeek = mod(startOfWeek - 1, 7); 102 | 103 | const todayDate = startOfToday().getTime(); 104 | const selectedDate = value ? startOfDay(value).getTime() : 0; 105 | 106 | return this.dayNames 107 | .map>((_day, i) => { 108 | const index = mod(startOfWeek + i, 7); 109 | return { 110 | type, 111 | key: 'weekday' + index, 112 | isToday: false, 113 | isSelected: false, 114 | isHeader: true, 115 | isDisabled: false, 116 | 117 | value: i, 118 | text: this.dayNames[index], 119 | }; 120 | }) 121 | .concat( 122 | eachDayOfInterval({ 123 | start: addDays(start, -mod(start.getDay() - startOfWeek, 7)), 124 | end: addDays(end, mod(endOfWeek - end.getDay(), 7)), 125 | }).map((date) => ({ 126 | type, 127 | key: date.toString(), 128 | isToday: todayDate === date.getTime(), 129 | isSelected: selectedDate === date.getTime(), 130 | isHeader: false, 131 | isInCurrentMonth: date >= start && date <= end, 132 | isDisabled: date < start || date > end, 133 | 134 | value: date, 135 | text: date.getDate() + '', 136 | })), 137 | ); 138 | }, 139 | hours: function ({ type, hour }) { 140 | return [...Array(24).keys()].map((value) => ({ 141 | type, 142 | key: value, 143 | value: value, 144 | text: value + '', 145 | isToday: false, 146 | isSelected: hour === value, 147 | isHeader: false, 148 | isDisabled: false, 149 | })); 150 | }, 151 | 152 | minutes: function ({ type, minute }) { 153 | return [...Array(60).keys()].map((value) => ({ 154 | type, 155 | key: value, 156 | value: value, 157 | text: value + '', 158 | isToday: false, 159 | isSelected: minute === value, 160 | isHeader: false, 161 | isDisabled: false, 162 | })); 163 | }, 164 | }; 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Date Picker 2 | 3 | [![npm version](https://badge.fury.io/js/headless-datetimepicker.svg)](https://badge.fury.io/js/headless-datetimepicker) 4 | [![npm](https://img.shields.io/npm/dm/headless-datetimepicker)](https://www.npmjs.com/package/headless-datetimepicker) 5 | 6 |

7 | 8 |

9 | 10 | The Headless Datepicker is a powerful and flexible tool designed for ReactJS applications. 11 | It allows developers to create customizable and visually appealing datepickers with ease. 12 | Unlike traditional datepickers, this component is "headless," meaning it provides the core functionality 13 | and logic while allowing developers to design their own user interface. 14 | 15 | ## Features 16 | 17 | - **Datepicker, Hourpicker, and Calendar Modes:** 18 | The component supports multiple modes, including datepicker, hourpicker, and calendar modes, allowing users to select dates, hours, or navigate through a full calendar. 19 | 20 | - **Headless Design:** 21 | The component follows a headless architecture, separating the logic from the presentation layer. This enables developers to design and customize the user interface according to their application's specific needs. 22 | 23 | - **Multi Picker Support:** 24 | The component allows for nesting multiple pickers within each other, enabling advanced and complex selection scenarios. 25 | 26 | - **Keyboard Navigation: (TODO)** 27 | Users can easily navigate and interact with the datepicker using keyboard shortcuts, enhancing accessibility and improving the user experience. 28 | 29 | - **Written in TypeScript with Type Support:** 30 | The component is written in TypeScript, providing strong typing and enabling developers to benefit from type-checking during development. 31 | 32 | - **Support for other Calendar Types with Config:** 33 | The component provides support for other calendar types through configuration options. Developers can customize the calendar type based on their requirements. 34 | 35 | - **Built-in Config for Jalali Calendar:** 36 | The component comes with a built-in configuration for the Jalali calendar, making it easy to implement and use the Jalali calendar system. 37 | 38 | ## Installation 39 | 40 | The package can be installed via [npm](https://github.com/npm/cli): 41 | 42 | ``` 43 | npm install headless-datetimepicker --save 44 | ``` 45 | 46 | Or via [yarn](https://github.com/yarnpkg/yarn): 47 | 48 | ``` 49 | yarn add headless-datetimepicker 50 | ``` 51 | 52 | ## Usage 53 | 54 | Import Datepicker component 55 | 56 | ```js 57 | import { Datepicker } from 'headless-datetimepicker'; 58 | ``` 59 | 60 | ## Basic example 61 | 62 | ```jsx 63 | 64 | 65 | 66 | {({ monthName, hour, minute, year }) => ( 67 | <> 68 | Prev 69 | Next 70 | 71 | {({ items }) => 72 | items.map((item) => ( 73 | 74 | {item.text} 75 | 76 | )) 77 | } 78 | 79 | 80 | )} 81 | 82 | 83 | ``` 84 | 85 | ## Using other calendar types (e.g., Jalali) with Headless Datepicker 86 | 87 | The Headless Datepicker library provides the flexibility to support various calendar types, allowing you to tailor the datepicker based on your specific regional requirements. To use a custom calendar type or the built-in Jalali calendar, follow these simple steps: 88 | 89 | ### Configuration Object 90 | 91 | Manually create a configuration object for your desired calendar type or use the internal Jalali configuration provided by the library. 92 | 93 | - For the Jalali calendar: 94 | 95 | ```js 96 | import { config } from 'headless-datetimepicker/jalali'; 97 | ``` 98 | 99 | - For custom calendar: 100 | 101 | create a config object base on [config](https://github.com/aliakbarazizi/headless-datepicker/blob/main/src/utils/config.ts) 102 | 103 | ### Pass Configuration to Datepicker 104 | 105 | Use the config prop to pass your configuration to the Datepicker component. 106 | 107 | ```jsx 108 | ... 109 | ``` 110 | 111 | **The configuration object should remain unchanged during React re-renders to maintain consistent behavior, and it's recommended to declare it outside the component to avoid unnecessary re-creations.** 112 | 113 | ## Documentation 114 | 115 | Please see the [https://aliakbarazizi.github.io/headless-datepicker/](https://aliakbarazizi.github.io/headless-datepicker/) 116 | 117 | ## License 118 | 119 | Licensed under MIT license, see [LICENSE](LICENSE) for the full license. 120 | -------------------------------------------------------------------------------- /src/jalali/config.ts: -------------------------------------------------------------------------------- 1 | import { addDays, eachDayOfInterval, startOfDay, startOfToday } from 'date-fns'; 2 | // @ts-expect-error wrong type in jalali-js type 3 | import { jalaaliToDateObject, toJalaali } from 'jalaali-js'; 4 | import type { DateItemType, DateParts, DatepickerConfig } from '..'; 5 | import { format } from './format'; 6 | import { parse } from './parse'; 7 | 8 | export const config: DatepickerConfig = { 9 | dayNames: [ 10 | 'یکشنبه', 11 | 'دوشنبه', 12 | 'سه‌شنبه', 13 | 'چهارشنبه', 14 | 'پنج‌شنبه', 15 | 'جمعه', 16 | 'شنبه', 17 | ], 18 | monthNames: [ 19 | 'فروردین', 20 | 'اردیبهشت', 21 | 'خرداد', 22 | 'تیر', 23 | 'مرداد', 24 | 'شهریور', 25 | 'مهر', 26 | 'آبان', 27 | 'آذر', 28 | 'دی', 29 | 'بهمن', 30 | 'اسفند', 31 | ], 32 | format: function (date, _format) { 33 | if (!date) return ''; 34 | const jalali = toJalaali(date); 35 | 36 | return format( 37 | { 38 | year: jalali.jy, 39 | month: jalali.jm - 1, 40 | day: jalali.jd, 41 | hour: date.getHours(), 42 | minute: date.getMinutes(), 43 | second: date.getSeconds(), 44 | millisecond: date.getMilliseconds(), 45 | timestamp: date.getTime(), 46 | timezoneOffset: date.getTimezoneOffset(), 47 | }, 48 | _format, 49 | ); 50 | }, 51 | parse: function (date, format, referenceDate) { 52 | const _date = parse(date, format, referenceDate || new Date()); 53 | 54 | const jalali = jalaaliToDateObject( 55 | _date.year, 56 | _date.month + 1, 57 | _date.day, 58 | ) as Date; 59 | 60 | jalali.setHours(_date.hour, _date.minute, _date.second, _date.millisecond); 61 | 62 | return jalali; 63 | }, 64 | toDateParts: function (date) { 65 | return new Intl.DateTimeFormat('fa-IR-u-nu-latn', { 66 | year: 'numeric', 67 | month: 'numeric', 68 | day: 'numeric', 69 | }) 70 | .formatToParts(date) 71 | .reduce((acc, part) => { 72 | if (part.type !== 'literal') 73 | acc[part.type as keyof DateParts] = +part.value; 74 | return acc; 75 | }, {} as any); 76 | }, 77 | 78 | years: function ({ type, year }) { 79 | const todayYear = toJalaali(new Date()).jy; 80 | 81 | return [...Array(200).keys()].map((value) => ({ 82 | type, 83 | key: type + value, 84 | isToday: todayYear === value + 1300, 85 | isSelected: year === value + 1300, 86 | isHeader: false, 87 | isDisabled: false, 88 | 89 | value: value + 1300, 90 | text: value + 1300 + '', 91 | })); 92 | }, 93 | months: function ({ type, month }) { 94 | const todayMonth = toJalaali(new Date()).jm; 95 | return [...this.monthNames.keys()].map((value) => ({ 96 | type, 97 | key: type + value, 98 | isToday: todayMonth === value + 1, 99 | isSelected: month === value + 1, 100 | isHeader: false, 101 | isDisabled: false, 102 | 103 | value: value + 1, 104 | text: this.monthNames[value], 105 | })); 106 | }, 107 | days: function ({ type, month, startOfWeek, year, value }) { 108 | const start = jalaaliToDateObject(year, month, 1); 109 | const end = jalaaliToDateObject(year, month + 1, 1); 110 | end.setDate(end.getDate() - 1); 111 | const endOfWeek = mod(startOfWeek - 1, 7); 112 | 113 | const format = new Intl.DateTimeFormat('fa-IR-u-nu-latn', { 114 | day: 'numeric', 115 | }); 116 | 117 | const todayDate = startOfToday().getTime(); 118 | const selectedDate = value ? startOfDay(value).getTime() : 0; 119 | 120 | return this.dayNames 121 | .map>((_day, i) => { 122 | const index = mod(startOfWeek + i, 7); 123 | return { 124 | type, 125 | key: 'weekday' + index, 126 | isToday: false, 127 | isSelected: false, 128 | isHeader: true, 129 | isDisabled: false, 130 | 131 | value: i, 132 | text: this.dayNames[index], 133 | }; 134 | }) 135 | .concat( 136 | eachDayOfInterval({ 137 | start: addDays(start, -mod(start.getDay() - startOfWeek, 7)), 138 | end: addDays(end, mod(endOfWeek - end.getDay(), 7)), 139 | }).map((date) => ({ 140 | type, 141 | key: date.toString(), 142 | isToday: todayDate === date.getTime(), 143 | isSelected: selectedDate === date.getTime(), 144 | 145 | isHeader: false, 146 | isDisabled: date < start || date > end, 147 | isInCurrentMonth: date >= start && date <= end, 148 | 149 | value: date, 150 | text: format.format(date), 151 | })), 152 | ); 153 | }, 154 | hours: function ({ type, hour }) { 155 | return [...Array(24).keys()].map((value) => ({ 156 | type, 157 | key: value, 158 | value: value, 159 | text: value + '', 160 | isToday: false, 161 | isSelected: hour === value, 162 | isHeader: false, 163 | isDisabled: false, 164 | })); 165 | }, 166 | minutes: function ({ type, minute }) { 167 | return [...Array(60).keys()].map((value) => ({ 168 | type, 169 | key: value, 170 | value: value, 171 | text: value + '', 172 | isToday: false, 173 | isSelected: minute === value, 174 | isHeader: false, 175 | isDisabled: false, 176 | })); 177 | }, 178 | }; 179 | 180 | function mod(n: number, m: number) { 181 | return ((n % m) + m) % m; 182 | } 183 | -------------------------------------------------------------------------------- /src/components/datepicker/button/Button.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { classNames } from '../../../utils/class-names'; 3 | import { Items } from '../items/Items'; 4 | import { DateItems } from '../items/Items.stories'; 5 | import { Picker } from '../picker/Picker'; 6 | import { Button } from './Button'; 7 | 8 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 9 | const meta = { 10 | title: 'Datepicker/Button', 11 | component: Button, 12 | tags: ['autodocs'], 13 | argTypes: { 14 | as: { 15 | table: { 16 | defaultValue: { 17 | summary: 'button', 18 | }, 19 | }, 20 | }, 21 | action: { 22 | control: 'select', 23 | options: [ 24 | 'open', 25 | 'close', 26 | 'toggle', 27 | 'next', 28 | 'prev', 29 | 'year', 30 | 'month', 31 | 'day', 32 | 'today', 33 | 'todayHour', 34 | ], 35 | }, 36 | }, 37 | parameters: { 38 | showDatepicker: true, 39 | }, 40 | decorators: [ 41 | (Story, { args }) => { 42 | return ( 43 |
53 | 54 |
55 | ); 56 | }, 57 | ], 58 | } satisfies Meta; 59 | 60 | export default meta; 61 | type Story = StoryObj; 62 | 63 | export const Toggle = { 64 | parameters: { 65 | showDatepicker: false, 66 | }, 67 | args: { 68 | action: 'toggle', 69 | className: 70 | 'leading-2 p-2 text-lg font-semibold hover:bg-gray-700 hover:text-white flex items-center space-x-2', 71 | children: 'Toggle picker state', 72 | }, 73 | decorators: [ 74 | (Story) => ( 75 |
76 |
77 | 78 |
79 | 86 | 87 | 88 |
89 | ), 90 | ], 91 | } satisfies Story; 92 | 93 | export const ShowYear = { 94 | args: { 95 | action: 'showYear', 96 | className: 97 | 'leading-2 p-2 text-lg font-semibold hover:bg-gray-700 hover:text-white', 98 | children: 'Show year', 99 | }, 100 | } satisfies Story; 101 | 102 | export const ToggleYear = { 103 | args: { 104 | action: 'toggleYear', 105 | className: 106 | 'leading-2 p-2 text-lg font-semibold hover:bg-gray-700 hover:text-white', 107 | children: 'Toggle year', 108 | }, 109 | } satisfies Story; 110 | 111 | export const ShowMonth = { 112 | args: { 113 | action: 'showMonth', 114 | className: 115 | 'leading-2 p-2 text-lg font-semibold hover:bg-gray-700 hover:text-white', 116 | children: 'Show month', 117 | }, 118 | } satisfies Story; 119 | 120 | export const ToggleMonth = { 121 | args: { 122 | action: 'toggleMonth', 123 | className: 124 | 'leading-2 p-2 text-lg font-semibold hover:bg-gray-700 hover:text-white', 125 | children: 'Toggle month', 126 | }, 127 | } satisfies Story; 128 | 129 | export const Prev = { 130 | parameters: { 131 | override: { 132 | children: 'Prev', 133 | }, 134 | }, 135 | args: { 136 | action: 'prev', 137 | className: 138 | 'rounded-full p-2 text-sm font-medium hover:bg-gray-700 hover:text-white rtl:rotate-180', 139 | children: () => ( 140 | 145 | 146 | 147 | ), 148 | }, 149 | } satisfies Story; 150 | 151 | export const Next = { 152 | parameters: { 153 | override: { 154 | children: 'Next', 155 | }, 156 | }, 157 | args: { 158 | action: 'next', 159 | className: 160 | 'rounded-full p-2 text-sm font-medium hover:bg-gray-700 hover:text-white rtl:rotate-180', 161 | children: () => ( 162 | 167 | 168 | 169 | ), 170 | }, 171 | } satisfies Story; 172 | 173 | export const Today = { 174 | args: { 175 | action: 'today', 176 | className: 'w-full bg-blue-700 p-2 text-sm font-medium hover:bg-blue-600', 177 | children: 'Today', 178 | }, 179 | } satisfies Story; 180 | 181 | export const TodayHour = { 182 | args: { 183 | action: 'todayHour', 184 | className: 'w-full bg-blue-700 p-2 text-sm font-medium hover:bg-blue-600', 185 | children: 'Today with hour', 186 | }, 187 | } satisfies Story; 188 | -------------------------------------------------------------------------------- /src/utils/render.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ElementType, 3 | ForwardedRef, 4 | Fragment, 5 | ReactElement, 6 | Ref, 7 | RefAttributes, 8 | cloneElement, 9 | createElement, 10 | isValidElement, 11 | forwardRef as reactForwardRef, 12 | } from 'react'; 13 | import { Props } from '../type'; 14 | import { classNames } from './class-names'; 15 | 16 | export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; 17 | 18 | export function render( 19 | ourProps: Expand>, 20 | theirProps: Expand>, 21 | slot: TSlot = {} as TSlot, 22 | tag: ElementType, 23 | ref: Ref, 24 | strategy: { 25 | visible?: boolean; 26 | hideOnClose?: boolean; 27 | } = {}, 28 | ) { 29 | if (strategy?.visible === false && strategy.hideOnClose !== true) { 30 | return null; 31 | } 32 | 33 | const { as, children, ...props } = mergeProps(theirProps, ourProps); 34 | 35 | const Component = as || tag; 36 | 37 | const resolvedChildren = 38 | typeof children === 'function' ? children(slot) : children; 39 | 40 | if (typeof props.className === 'function') { 41 | props.className = props.className(slot); 42 | } 43 | 44 | if (Component === Fragment) { 45 | if (Object.keys(props).length > 0) { 46 | if ( 47 | !isValidElement(resolvedChildren) || 48 | (Array.isArray(resolvedChildren) && resolvedChildren.length > 1) 49 | ) { 50 | throw new Error( 51 | [ 52 | 'Passing props on "Fragment"!', 53 | '', 54 | `The current component is rendering a "Fragment".`, 55 | `However we need to passthrough the following props:`, 56 | Object.keys(props) 57 | .map((line) => ` - ${line}`) 58 | .join('\n'), 59 | '', 60 | 'You can apply a few solutions:', 61 | [ 62 | 'Add an `as="..."` prop, to ensure that we render an actual element instead of a "Fragment".', 63 | 'Render a single element as the child so that we can forward the props onto that element.', 64 | ] 65 | .map((line) => ` - ${line}`) 66 | .join('\n'), 67 | ].join('\n'), 68 | ); 69 | } 70 | 71 | const { childClassName, ...childProps } = resolvedChildren.props; 72 | 73 | const newClassName = 74 | typeof childClassName === 'function' 75 | ? (...args: unknown[]) => 76 | classNames(childClassName(...args), props.className as string) 77 | : classNames(childClassName, props.className); 78 | 79 | const extraProps = newClassName 80 | ? { className: newClassName, ref } 81 | : { ref }; 82 | 83 | return cloneElement( 84 | resolvedChildren, 85 | Object.assign( 86 | {}, 87 | // Filter out undefined values so that they don't override the existing values 88 | mergeProps(childProps, props), 89 | extraProps, 90 | ), 91 | ); 92 | } 93 | } 94 | 95 | return createElement(Component, { ...props, ref }, resolvedChildren); 96 | } 97 | 98 | function mergeProps(...listOfProps: Props[]) { 99 | if (listOfProps.length === 0) return {}; 100 | if (listOfProps.length === 1) return listOfProps[0]; 101 | 102 | const target: Props = {}; 103 | 104 | const eventHandlers: Record< 105 | PropertyKey, 106 | (( 107 | event: { defaultPrevented: boolean }, 108 | ...args: unknown[] 109 | ) => void | undefined)[] 110 | > = {}; 111 | 112 | for (const props of listOfProps) { 113 | for (const prop in props) { 114 | // Collect event handlers 115 | if (prop.startsWith('on') && typeof props[prop] === 'function') { 116 | eventHandlers[prop] ??= []; 117 | eventHandlers[prop].push(props[prop]); 118 | } else { 119 | // Override incoming prop 120 | target[prop] = props[prop]; 121 | } 122 | } 123 | } 124 | 125 | // Do not attach any event handlers when there is a `disabled` or `aria-disabled` prop set. 126 | if (target.disabled || target['aria-disabled']) { 127 | return Object.assign( 128 | target, 129 | // Set all event listeners that we collected to `undefined`. This is 130 | // important because of the `cloneElement` from above, which merges the 131 | // existing and new props, they don't just override therefore we have to 132 | // explicitly nullify them. 133 | Object.fromEntries( 134 | Object.keys(eventHandlers).map((eventName) => [eventName, undefined]), 135 | ), 136 | ); 137 | } 138 | 139 | // Merge event handlers 140 | for (const eventName in eventHandlers) { 141 | Object.assign(target, { 142 | [eventName]( 143 | event: { nativeEvent?: Event; defaultPrevented: boolean }, 144 | ...args: unknown[] 145 | ) { 146 | const handlers = eventHandlers[eventName]; 147 | 148 | for (const handler of handlers) { 149 | if ( 150 | (event instanceof Event || event?.nativeEvent instanceof Event) && 151 | event.defaultPrevented 152 | ) { 153 | return; 154 | } 155 | 156 | handler(event, ...args); 157 | } 158 | }, 159 | }); 160 | } 161 | 162 | return target; 163 | } 164 | 165 | export function forwardRef( 166 | component: (props: P, ref: ForwardedRef) => ReactElement | null, 167 | ): (props: P & RefAttributes) => ReactElement | null { 168 | return reactForwardRef(component) as any; 169 | } 170 | -------------------------------------------------------------------------------- /src/components/datepicker/items/Items.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { classNames } from '../../../utils/class-names'; 3 | import { Item } from './../item/Item.tsx'; 4 | import { Items } from './Items'; 5 | 6 | // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction 7 | const meta = { 8 | title: 'Datepicker/Items', 9 | component: Items, 10 | tags: ['autodocs'], 11 | argTypes: { 12 | as: { 13 | table: { 14 | defaultValue: { 15 | summary: 'div', 16 | }, 17 | }, 18 | }, 19 | className: { 20 | control: false, 21 | table: { 22 | type: { 23 | summary: `string | ((slot: DatepickerSlot & { 24 | items: ItemsType[]; 25 | type: Type; 26 | }) => string)`, 27 | }, 28 | }, 29 | }, 30 | children: { 31 | control: false, 32 | table: { 33 | type: { 34 | summary: `ReactNode | ((slot: DatepickerSlot & { 35 | items: ItemsType[]; 36 | type: Type; 37 | }) => ReactNode)`, 38 | }, 39 | }, 40 | }, 41 | }, 42 | parameters: { 43 | showDatepicker: true, 44 | }, 45 | decorators: [ 46 | (Story, { args }) => { 47 | return ( 48 |
56 | 57 |
58 | ); 59 | }, 60 | ], 61 | } satisfies Meta; 62 | 63 | export default meta; 64 | type Story = StoryObj; 65 | 66 | export const DateItems = { 67 | parameters: { 68 | override: { 69 | className: `{({ type }) => 70 | classNames( 71 | 'grid w-full auto-rows-max gap-4 overflow-y-auto scroll-smooth', 72 | type == 'day' && 'grid-cols-7', 73 | type == 'month' && 'grid-cols-3', 74 | type == 'year' && 'max-h-[274px] grid-cols-4', 75 | ) 76 | }`, 77 | children: `{({ items }) => 78 | items.map((item) => ( 79 | 98 | {item.isHeader ? item.text.substring(0, 2) : item.text} 99 | 100 | )) 101 | }`, 102 | }, 103 | }, 104 | argTypes: { 105 | type: { 106 | options: ['day', 'month', 'year'], 107 | control: { type: 'radio' }, 108 | }, 109 | }, 110 | args: { 111 | type: 'day', 112 | className: ({ type }) => 113 | classNames( 114 | 'grid w-full auto-rows-max gap-4 overflow-y-auto scroll-smooth', 115 | type == 'day' && 'grid-cols-7', 116 | type == 'month' && 'grid-cols-3', 117 | type == 'year' && 'max-h-[274px] grid-cols-4', 118 | ), 119 | children: ({ items }) => 120 | items.map((item) => ( 121 | 140 | {item.isHeader ? item.text.substring(0, 2) : item.text} 141 | 142 | )), 143 | }, 144 | } satisfies Story; 145 | 146 | export const HourItems = { 147 | parameters: { 148 | override: { 149 | children: `{({ items }) => 150 | items.map((item) => ( 151 | 160 | {('0' + item.text).slice(-2)} 161 | 162 | )) 163 | }`, 164 | }, 165 | }, 166 | argTypes: { 167 | type: { 168 | options: ['hour', 'minute'], 169 | control: { type: 'radio' }, 170 | }, 171 | }, 172 | args: { 173 | type: 'hour', 174 | className: 'overflow-y-auto scroll-smooth px-4', 175 | disableAutoScroll: true, 176 | children: ({ items }) => 177 | items.map((item) => ( 178 | 187 | {('0' + item.text).slice(-2)} 188 | 189 | )), 190 | }, 191 | } satisfies Story; 192 | -------------------------------------------------------------------------------- /src/components/datepicker/provider/Provider.tsx: -------------------------------------------------------------------------------- 1 | import { isEqual as isEqualDate } from 'date-fns'; 2 | import { 3 | ElementType, 4 | Fragment, 5 | Ref, 6 | forwardRef, 7 | useEffect, 8 | useReducer, 9 | useRef, 10 | } from 'react'; 11 | import { 12 | DatepickerConfig, 13 | DatepickerContext, 14 | DatepickerState, 15 | datePickerReducer, 16 | getSlot, 17 | } from '../../../context/context'; 18 | import { useDisposables } from '../../../hooks/useDisposables'; 19 | import { useEvent } from '../../../hooks/useEvent'; 20 | import { Props } from '../../../type'; 21 | import { config as defaultConfig } from '../../../utils/config'; 22 | import { render } from '../../../utils/render'; 23 | 24 | const DEFAULT_TAG = Fragment; 25 | 26 | export type ProviderProps< 27 | ElemenElementTag extends ElementType = typeof DEFAULT_TAG, 28 | > = Props< 29 | ElemenElementTag, 30 | DatepickerState, 31 | 'onChange' | 'defaultValue' | 'value', 32 | { 33 | /** 34 | * Default value of the date 35 | */ 36 | defaultValue?: Date; 37 | 38 | /** 39 | * Value of date picker 40 | */ 41 | value?: Date | null; 42 | 43 | /** 44 | * On value change 45 | * @param value The new date value 46 | * @returns void 47 | */ 48 | onChange?: (value: Date | null) => void; 49 | 50 | /** 51 | * Disable keyboard navigation 52 | */ 53 | disabledKeyboardNavigation?: boolean; 54 | 55 | /** 56 | * Disable calendar (it will disabled Input too) 57 | */ 58 | disabled?: boolean; 59 | 60 | /** 61 | * Override calendar config 62 | * 63 | * @see DatepickerConfig 64 | */ 65 | config?: DatepickerConfig; 66 | 67 | /** 68 | * 0 for Sunday 69 | * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getDay#return_value 70 | */ 71 | startOfWeek?: number; 72 | 73 | /** 74 | * Filter date, if it returns false the date will be disabled 75 | */ 76 | filterDate?: (date: Date) => boolean; 77 | } 78 | >; 79 | 80 | export const Provider = forwardRef( 81 | ( 82 | { 83 | defaultValue, 84 | value, 85 | onChange: controlledOnChange, 86 | 87 | disabledKeyboardNavigation, 88 | disabled = false, 89 | config = defaultConfig, 90 | startOfWeek = 0, 91 | filterDate: _filterDate = () => true, 92 | ...props 93 | }: ProviderProps, 94 | ref: Ref, 95 | ) => { 96 | const valueRef = useRef(value || defaultValue || null); 97 | 98 | const disposables = useDisposables(); 99 | 100 | const onChange = useEvent((value: Date | null) => { 101 | if (isEqual(valueRef.current, value)) return; 102 | 103 | if (value && !_filterDate(value)) return; 104 | 105 | disposables.nextFrame(() => { 106 | valueRef.current = value; 107 | controlledOnChange?.(valueRef.current); 108 | dispatch({ 109 | type: 'externalValueChanged', 110 | payload: value || new Date(), 111 | }); 112 | }); 113 | }); 114 | 115 | const filterDate = useEvent(_filterDate); 116 | 117 | const [state, dispatch] = useReducer(datePickerReducer, null, () => { 118 | const date = valueRef.current || new Date(); 119 | const parts = config.toDateParts(date); 120 | 121 | return { 122 | config, 123 | disabled, 124 | year: parts.year, 125 | month: parts.month, 126 | hour: date.getHours(), 127 | minute: date.getMinutes(), 128 | calendarOpen: false, 129 | hourOpen: false, 130 | valueRef, 131 | startOfWeek, 132 | onChange, 133 | filterDate, 134 | pickers: {}, 135 | }; 136 | }); 137 | 138 | useEffect(() => { 139 | onChange(value || null); 140 | }, [value, onChange]); 141 | 142 | useEffect(() => { 143 | dispatch({ type: 'defaultChanged', payload: { startOfWeek } }); 144 | }, [startOfWeek]); 145 | 146 | useEffect(() => { 147 | dispatch({ type: 'defaultChanged', payload: { disabled } }); 148 | }, [disabled]); 149 | 150 | // let d = useDisposables() 151 | // let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { 152 | // switch (event.key) { 153 | // // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/#keyboard-interaction-13 154 | 155 | // case Keys.Space: 156 | // case Keys.Enter: 157 | // case Keys.ArrowDown: 158 | // event.preventDefault() 159 | // actions.openListbox() 160 | // d.nextFrame(() => { 161 | // if (!data.value) actions.goToOption(Focus.First) 162 | // }) 163 | // break 164 | 165 | // case Keys.ArrowUp: 166 | // event.preventDefault() 167 | // actions.openListbox() 168 | // d.nextFrame(() => { 169 | // if (!data.value) actions.goToOption(Focus.Last) 170 | // }) 171 | // break 172 | // } 173 | // }) 174 | 175 | // let handleKeyUp = useEvent((event: ReactKeyboardEvent) => { 176 | // switch (event.key) { 177 | // case Keys.Space: 178 | // // Required for firefox, event.preventDefault() in handleKeyDown for 179 | // // the Space key doesn't cancel the handleKeyUp, which in turn 180 | // // triggers a *click*. 181 | // event.preventDefault() 182 | // break 183 | // } 184 | // }) 185 | 186 | const ourProps = {}; 187 | 188 | return ( 189 | 190 | {render(ourProps, props, getSlot(state), DEFAULT_TAG, ref)} 191 | 192 | ); 193 | }, 194 | ); 195 | 196 | function isEqual(first: Date | null, second: Date | null) { 197 | return ( 198 | first === second || 199 | (first !== null && second !== null && isEqualDate(first, second)) 200 | ); 201 | } 202 | 203 | export interface ComponentProvider { 204 | ( 205 | props: ProviderProps & React.RefAttributes, 206 | ): JSX.Element; 207 | } 208 | -------------------------------------------------------------------------------- /src/components/datepicker/picker/Picker.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | autoUpdate, 3 | flip, 4 | offset, 5 | shift, 6 | useFloating, 7 | } from '@floating-ui/react-dom'; 8 | import { UseFloatingOptions } from '@floating-ui/react-dom'; 9 | import { 10 | ElementType, 11 | Ref, 12 | createContext, 13 | useContext, 14 | useEffect, 15 | useId, 16 | useMemo, 17 | useRef, 18 | } from 'react'; 19 | import { 20 | DatepickerSlot, 21 | ItemType, 22 | useDatepickerSlot, 23 | } from '../../../context/context'; 24 | import useOnClickOutside from '../../../hooks/useOnClickOutside'; 25 | import { useSyncRef } from '../../../hooks/useSyncRef'; 26 | import { Props } from '../../../type'; 27 | import { forwardRef, render } from '../../../utils/render'; 28 | 29 | const DEFAULT_TAG = 'div'; 30 | 31 | const defaultMiddleware = [ 32 | offset(10), 33 | flip({ fallbackAxisSideDirection: 'end', crossAxis: false }), 34 | shift(), 35 | ]; 36 | 37 | export type PickerProps< 38 | ElemenElementTag extends ElementType = typeof DEFAULT_TAG, 39 | > = Props< 40 | ElemenElementTag, 41 | DatepickerSlot, 42 | never, 43 | { 44 | /** 45 | * Set a unique id for the picker 46 | * It can be useful when you have multiple pickers 47 | * and you want to use `