diff --git a/.eslintignore b/.eslintignore index 1686f0dba..1a8092039 100644 --- a/.eslintignore +++ b/.eslintignore @@ -57,3 +57,4 @@ __js templates docs CHANGELOG.md +.yalc diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 000000000..c37815e2b --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/post-checkout'.\n"; exit 2; } +git lfs post-checkout "$@" diff --git a/.husky/post-commit b/.husky/post-commit new file mode 100755 index 000000000..e5230c305 --- /dev/null +++ b/.husky/post-commit @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/post-commit'.\n"; exit 2; } +git lfs post-commit "$@" diff --git a/.husky/post-merge b/.husky/post-merge new file mode 100755 index 000000000..c99b752a5 --- /dev/null +++ b/.husky/post-merge @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/post-merge'.\n"; exit 2; } +git lfs post-merge "$@" diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 000000000..216e91527 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,3 @@ +#!/bin/sh +command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting '.git/hooks/pre-push'.\n"; exit 2; } +git lfs pre-push "$@" diff --git a/.prettierignore b/.prettierignore index 1686f0dba..1a8092039 100644 --- a/.prettierignore +++ b/.prettierignore @@ -57,3 +57,4 @@ __js templates docs CHANGELOG.md +.yalc diff --git a/.storybook/preview.js b/.storybook/preview.js index cd0ecf095..aba49d07f 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -16,6 +16,7 @@ export const decorators = [ (Story, context) => { document.body.id = kebabCase(context.kind); document.body.classList.add("font-sans"); + document.body.classList.add("antialiased"); return ; }, diff --git a/package.json b/package.json index 600557680..c1e97bfb3 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,8 @@ ], "scripts": { "postinstall": "concurrently \"husky install\" \"patch-package\"", - "boot": "concurrently \"yarn keys\" \"yarn previews\"", - "keys": "node scripts/builds/keys", "previews": "node scripts/builds/create-previews.js", - "storybook": "cross-env TAILWIND_MODE=watch start-storybook -p 6006", + "storybook": "yarn previews && cross-env TAILWIND_MODE=watch start-storybook -p 6006", "test": "jest --config ./jest.config.ts --no-cache", "lint": "eslint --color --ext .js,.jsx,.ts,.tsx .", "lint:fix": "eslint --color --ext .js,.jsx,.ts,.tsx . --fix", @@ -86,41 +84,52 @@ ] }, "dependencies": { - "@chakra-ui/counter": "^1.2.5", - "@chakra-ui/hooks": "^1.8.2", - "@chakra-ui/react-utils": "^1.2.2", - "@chakra-ui/utils": "^1.10.2", - "@radix-ui/popper": "^0.1.0", - "@radix-ui/react-use-rect": "^0.1.1", - "@radix-ui/react-use-size": "^0.1.0", - "@react-aria/i18n": "^3.3.5", - "@react-aria/slider": "^3.0.4", - "@react-aria/spinbutton": "^3.0.2", - "@react-aria/utils": "^3.11.1", - "@react-stately/slider": "^3.0.4", - "@react-types/slider": "^3.0.3", + "@chakra-ui/react-utils": "^1.2.3", + "@chakra-ui/utils": "^1.10.4", + "@internationalized/date": "3.0.0-rc.0", + "@react-aria/button": "^3.4.4", + "@react-aria/calendar": "3.0.0-rc.0", + "@react-aria/datepicker": "3.0.0-rc.0", + "@react-aria/i18n": "^3.3.9", + "@react-aria/numberfield": "^3.1.6", + "@react-aria/slider": "^3.0.8", + "@react-aria/spinbutton": "^3.0.6", + "@react-aria/utils": "^3.12.0", + "@react-stately/calendar": "3.0.0-rc.0", + "@react-stately/datepicker": "3.0.0-rc.0", + "@react-stately/numberfield": "^3.0.7", + "@react-stately/slider": "^3.0.8", + "ariakit": "2.0.0-next.26", + "ariakit-utils": "0.17.0-next.18", "date-fns": "^2.28.0", "raf": "^3.4.1", - "react-remove-scroll": "^2.4.4", + "react-remove-scroll": "^2.5.3", "reakit-system": "^0.15.2", "reakit-utils": "^0.15.2", "reakit-warning": "^0.6.2" }, "devDependencies": { - "@babel/cli": "7.17.6", - "@babel/core": "7.17.9", + "@babel/cli": "7.17.10", + "@babel/core": "7.17.10", "@babel/plugin-proposal-class-properties": "7.16.7", "@babel/plugin-proposal-logical-assignment-operators": "7.16.7", "@babel/plugin-proposal-private-methods": "7.16.11", "@babel/plugin-proposal-private-property-in-object": "7.16.7", - "@babel/preset-env": "7.16.11", + "@babel/preset-env": "7.17.10", "@babel/preset-react": "7.16.7", "@babel/preset-typescript": "7.16.7", - "@commitlint/cli": "16.2.3", - "@commitlint/config-conventional": "16.2.1", + "@commitlint/cli": "16.2.4", + "@commitlint/config-conventional": "16.2.4", "@emotion/css": "11.9.0", - "@react-spring/web": "9.4.4", - "@release-it/conventional-changelog": "4.3.0", + "@react-spring/web": "9.4.5", + "@react-types/button": "^3.4.5", + "@react-types/calendar": "3.0.0-rc.0", + "@react-types/datepicker": "3.0.0-rc.0", + "@react-types/dialog": "^3.3.5", + "@react-types/numberfield": "^3.2.0", + "@react-types/shared": "^3.12.0", + "@react-types/slider": "^3.0.6", + "@release-it/conventional-changelog": "5.0.0", "@storybook/addon-a11y": "6.4.22", "@storybook/addon-actions": "6.4.22", "@storybook/addon-essentials": "6.4.22", @@ -130,77 +139,79 @@ "@storybook/react": "6.4.22", "@testing-library/dom": "8.13.0", "@testing-library/jest-dom": "5.16.4", - "@testing-library/react": "13.1.1", + "@testing-library/react": "13.2.0", "@testing-library/react-hooks": "8.0.0", "@testing-library/user-event": "14.1.1", - "@types/jest": "27.4.1", + "@types/jest": "27.5.0", "@types/jest-axe": "3.5.3", "@types/jest-in-case": "1.0.5", "@types/mockdate": "3.0.0", - "@types/node": "17.0.18", + "@types/node": "17.0.31", "@types/raf": "3.4.0", - "@types/react": "18.0.5", - "@types/react-dom": "18.0.1", + "@types/react": "18.0.9", + "@types/react-dom": "18.0.3", "@types/react-transition-group": "4.4.4", "@types/testing-library__jest-dom": "5.14.3", "all-contributors-cli": "6.20.0", "ast-to-markdown": "1.0.0", - "autoprefixer": "10.4.4", - "babel-jest": "27.5.1", - "babel-loader": "8.2.4", + "autoprefixer": "10.4.7", + "babel-jest": "28.1.0", + "babel-loader": "8.2.5", "babel-plugin-jsx-remove-data-test-id": "3.0.0", "chalk": "4.1.0", "codesandbox": "2.2.3", "concurrently": "7.1.0", "cross-env": "7.0.3", - "eslint": "8.13.0", + "eslint": "8.15.0", "eslint-config-prettier": "8.5.0", "eslint-config-react-app": "7.0.1", "eslint-plugin-prettier": "4.0.0", "eslint-plugin-simple-import-sort": "7.0.0", - "eslint-plugin-storybook": "0.5.10", - "gacp": "2.10.2", + "eslint-plugin-storybook": "0.5.11", + "gacp": "3.0.1", "glob": "8.0.1", "glob-fs": "0.1.7", - "husky": "7.0.4", - "jest": "27.5.1", + "husky": "8.0.0", + "jest": "28.1.0", "jest-axe": "6.0.0", + "jest-environment-jsdom": "^28.1.0", "jest-in-case": "1.0.2", - "jest-matcher-utils": "27.5.1", - "lint-staged": "12.3.8", + "jest-matcher-utils": "28.1.0", + "lint-staged": "12.4.1", "lodash": "4.17.21", "markdown-to-ast": "6.0.3", "markdown-toc": "1.2.0", - "md-node-inject": "1.0.1", + "md-node-inject": "2.0.0", "mockdate": "3.0.5", "node-fetch": "2.6.1", "outdent": "0.8.0", "patch-package": "6.4.7", "pinst": "3.0.0", - "postcss": "8.4.12", + "postcss": "8.4.13", "postcss-import": "14.1.0", + "postcss-merge-selectors": "^0.0.6", "postcss-scopify": "0.1.9", "prettier": "2.6.2", "raw-loader": "4.0.2", - "react": "18.0.0", - "react-dom": "18.0.0", + "react": "18.1.0", + "react-dom": "18.1.0", "react-hook-form": "7.30.0", - "react-test-renderer": "18.0.0", + "react-test-renderer": "18.1.0", "react-transition-group": "4.4.2", "react-virtual": "2.10.4", "reakit": "1.3.11", "reakit-test-utils": "0.15.2", - "release-it": "14.14.2", + "release-it": "15.0.0", "rimraf": "3.0.2", - "sort-package-json": "1.55.0", + "sort-package-json": "1.57.0", "storybook-addon-preview": "2.2.0", "storybook-addon-react-docgen": "1.2.42", "strip-comments": "2.0.1", "tailwindcss": "3.0.24", - "ts-jest": "27.1.4", + "ts-jest": "28.0.2", "ts-morph": "14.0.0", "ts-node": "10.7.0", - "typescript": "4.6.3", + "typescript": "4.6.4", "webpack": "5.72.0", "yaml": "2.0.1" }, diff --git a/postcss.config.js b/postcss.config.js index 9422c9153..acda68d4d 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -31,9 +31,22 @@ function addIdScope() { module.exports = { plugins: [ + require("postcss-import"), require("tailwindcss"), require("postcss-flexbugs-fixes"), require("autoprefixer")({ flexbox: "no-2009" }), + require("postcss-merge-selectors")({ + matchers: { + active: { + selectorFilter: /(:active|\[data-active\])/, + promote: true, + }, + focusVisible: { + selectorFilter: /(:focus-visible|\[data-focus-visible\])/, + promote: true, + }, + }, + }), rewriteRootRule(), addIdScope(), ], diff --git a/src/__mocks__/styleMock.js b/src/__mocks__/styleMock.js deleted file mode 100644 index f053ebf79..000000000 --- a/src/__mocks__/styleMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/src/accordion/Accordion.ts b/src/accordion/Accordion.ts deleted file mode 100644 index 2c9298771..000000000 --- a/src/accordion/Accordion.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { CompositeHTMLProps, CompositeOptions, useComposite } from "reakit"; - -import { createComponent, createHook } from "../system"; - -import { ACCORDION_KEYS } from "./__keys"; - -export const useAccordion = createHook({ - name: "Accordion", - compose: useComposite, - keys: ACCORDION_KEYS, - - useComposeProps(options, htmlProps) { - const compositeHtmlProp = useComposite(options, htmlProps); - - return { - ...compositeHtmlProp, - - // When none selected i.e, selectedId={null} - // as per composite https://github.com/reakit/reakit/blob/master/packages/reakit/src/Composite/Composite.ts#L372 - // it applies tabindex={0} which we need to remove it. - tabIndex: undefined, - }; - }, -}); - -export const Accordion = createComponent({ - as: "div", - memo: true, - useHook: useAccordion, -}); - -export type AccordionOptions = CompositeOptions; - -export type AccordionHTMLProps = CompositeHTMLProps; - -export type AccordionProps = AccordionOptions & AccordionHTMLProps; diff --git a/src/accordion/AccordionBaseState.ts b/src/accordion/AccordionBaseState.ts deleted file mode 100644 index 2877e7e6e..000000000 --- a/src/accordion/AccordionBaseState.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - CompositeActions, - CompositeInitialState, - CompositeState, - useCompositeState, -} from "reakit"; - -export function useAccordionBaseState( - props: AccordionBaseInitialState = {}, -): AccordionBaseStateReturn { - const composite = useCompositeState({ - orientation: "vertical", - ...props, - }); - - const panels = useCompositeState(); - - return { - panels: panels.items, - registerPanel: panels.registerItem, - unregisterPanel: panels.unregisterItem, - ...composite, - }; -} - -export type AccordionBaseState = CompositeState & { - /** - * Lists all the panels. - */ - panels: CompositeState["items"]; -}; - -export type AccordionBaseActions = CompositeActions & { - /** - * Registers a accordion panel. - */ - registerPanel: CompositeActions["registerItem"]; - - /** - * Unregisters a accordion panel. - */ - unregisterPanel: CompositeActions["unregisterItem"]; -}; - -export type AccordionBaseInitialState = CompositeInitialState; - -export type AccordionBaseStateReturn = AccordionBaseState & - AccordionBaseActions; diff --git a/src/accordion/AccordionMultiState.ts b/src/accordion/AccordionMultiState.ts deleted file mode 100644 index 0e6697448..000000000 --- a/src/accordion/AccordionMultiState.ts +++ /dev/null @@ -1,111 +0,0 @@ -import * as React from "react"; -import { Dispatch, SetStateAction } from "react"; -import { useControllableState } from "@chakra-ui/hooks"; - -import { - AccordionBaseActions, - AccordionBaseInitialState, - AccordionBaseState, - useAccordionBaseState, -} from "./AccordionBaseState"; - -export function useAccordionMultiState( - props: AccordionMultiInitialState = {}, -): AccordionMultiStateReturn { - const { manual = true } = props; - - const { move, ...baseState } = useAccordionBaseState(props); - - const [selectedIds, setSelectedIds] = useControllableState({ - defaultValue: props?.defaultSelectedIds || [], - value: props?.selectedIds, - onChange: props?.onSelectedIdsChange, - }); - - const select = React.useCallback( - (id: string) => { - move(id); - - if (selectedIds.includes(id)) { - setSelectedIds(prevIds => prevIds?.filter(pId => pId !== id)); - - return; - } - - setSelectedIds(prevIds => [...prevIds, id]); - }, - - [move, selectedIds, setSelectedIds], - ); - - return { - selectedIds, - setSelectedIds, - select, - manual, - allowToggle: true, - allowMultiple: true, - move, - ...baseState, - }; -} - -export type AccordionMultiState = AccordionBaseState & { - /** - * The current selected accordion's `id`. - */ - selectedIds: string[]; - - /** - * Allow to toggle accordion items - * @default false - */ - allowToggle: boolean; - - /** - * Allow to open multiple accordion items - */ - allowMultiple: boolean; - - /** - * Whether the accodion selection should be manual. - * @default true - */ - manual: boolean; -}; - -export type AccordionMultiActions = AccordionBaseActions & { - /** - * Sets the value. - */ - setSelectedIds: Dispatch>; - - /** - * Moves into and selects an accordion by its `id`. - */ - select: (id: string) => void; -}; - -export type AccordionMultiInitialState = Pick< - Partial, - "manual" | "selectedIds" -> & { - /** - * The initial value to be used, in uncontrolled mode - * @default [] - */ - defaultSelectedIds?: string[] | (() => string[]); - - /** - * The callback fired when the value changes - */ - onSelectedIdsChange?: (value: string[]) => void; - - /** - * The function that determines if the state should be updated - */ - shouldUpdate?: (prev: string[], next: string[]) => boolean; -} & AccordionBaseInitialState; - -export type AccordionMultiStateReturn = AccordionMultiState & - AccordionMultiActions; diff --git a/src/accordion/AccordionPanel.ts b/src/accordion/AccordionPanel.ts deleted file mode 100644 index 83f47d3cf..000000000 --- a/src/accordion/AccordionPanel.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as React from "react"; -import { - DisclosureContentHTMLProps, - DisclosureContentOptions, - unstable_IdHTMLProps, - unstable_IdOptions, - unstable_useId, - useDisclosureContent, -} from "reakit"; -import { useForkRef } from "reakit-utils"; - -import { createComponent, createHook } from "../system"; - -import { ACCORDION_PANEL_KEYS } from "./__keys"; -import { AccordionMultiStateReturn } from "./AccordionMultiState"; -import { AccordionStateReturn } from "./AccordionState"; -import { getAccordionId, isPanelVisible } from "./helpers"; - -export const useAccordionPanel = createHook< - AccordionPanelOptions, - AccordionPanelHTMLProps ->({ - name: "AccordionPanel", - compose: [unstable_useId, useDisclosureContent], - keys: ACCORDION_PANEL_KEYS, - - useProps(options, { ref: htmlRef, ...htmlProps }) { - const ref = React.useRef(null); - const { id, registerPanel, unregisterPanel } = options; - const accordionId = getAccordionId(options); - - React.useLayoutEffect(() => { - if (!id) return; - - registerPanel?.({ id, ref, groupId: accordionId }); - - return () => { - unregisterPanel?.(id); - }; - }, [accordionId, id, registerPanel, unregisterPanel]); - - return { - ref: useForkRef(ref, htmlRef), - role: "region", - "aria-labelledby": accordionId, - ...htmlProps, - }; - }, - - useComposeOptions(options) { - return { - visible: isPanelVisible(options), - ...options, - }; - }, -}); - -export const AccordionPanel = createComponent({ - as: "div", - memo: true, - useHook: useAccordionPanel, -}); - -export type AccordionPanelOptions = { - /** - * Accordion's id - */ - accordionId?: string; -} & DisclosureContentOptions & - unstable_IdOptions & - Pick< - AccordionStateReturn, - | "items" - | "panels" - | "selectedId" - | "allowMultiple" - | "registerPanel" - | "unregisterPanel" - > & - Pick; - -export type AccordionPanelHTMLProps = DisclosureContentHTMLProps & - unstable_IdHTMLProps; - -export type AccordionPanelProps = AccordionPanelOptions & - AccordionPanelHTMLProps; diff --git a/src/accordion/AccordionState.ts b/src/accordion/AccordionState.ts deleted file mode 100644 index 297deff9f..000000000 --- a/src/accordion/AccordionState.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as React from "react"; -import { Dispatch, SetStateAction } from "react"; -import { useControllableState } from "@chakra-ui/hooks"; - -import { - AccordionBaseActions, - AccordionBaseInitialState, - AccordionBaseState, - useAccordionBaseState, -} from "./AccordionBaseState"; -import { StringOrNull } from "./helpers"; - -export function useAccordionState( - props: AccordionInitialState = {}, -): AccordionStateReturn { - const { manual = true, allowToggle = false } = props; - const { move, ...baseState } = useAccordionBaseState(props); - - const [selectedId, setSelectedId] = useControllableState({ - defaultValue: props?.defaultSelectedId || null, - value: props?.selectedId, - onChange: props?.onSelectedIdChange, - }); - - const select = React.useCallback( - (id: string) => { - move(id); - - if (allowToggle && id === selectedId) { - setSelectedId(null); - return; - } - - setSelectedId(id); - }, - - [move, allowToggle, selectedId, setSelectedId], - ); - - return { - selectedId, - setSelectedId, - select, - manual, - allowToggle, - allowMultiple: false, - move, - ...baseState, - }; -} - -export type AccordionState = AccordionBaseState & { - /** - * The current selected accordion's `id`. - */ - selectedId: StringOrNull; - - /** - * Allow to toggle accordion items - * @default false - */ - allowToggle: boolean; - - /** - * Allow to open multiple accordion items - */ - allowMultiple: boolean; - - /** - * Whether the accodion selection should be manual. - * @default true - */ - manual: boolean; -}; - -export type AccordionActions = AccordionBaseActions & { - /** - * Sets the value. - */ - setSelectedId: Dispatch>; - - /** - * Moves into and selects an accordion by its `id`. - */ - select: (id: string) => void; -}; - -export type AccordionInitialState = Pick< - Partial, - "manual" | "allowToggle" | "selectedId" -> & { - /** - * The initial value to be used, in uncontrolled mode - * @default null - */ - defaultSelectedId?: StringOrNull | (() => StringOrNull); - /** - * The callback fired when the value changes - */ - onSelectedIdChange?: (value: StringOrNull) => void; - /** - * The function that determines if the state should be updated - */ - shouldUpdate?: (prev: StringOrNull, next: StringOrNull) => boolean; -} & AccordionBaseInitialState; - -export type AccordionStateReturn = AccordionState & AccordionActions; diff --git a/src/accordion/AccordionTrigger.ts b/src/accordion/AccordionTrigger.ts deleted file mode 100644 index 35fd86830..000000000 --- a/src/accordion/AccordionTrigger.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as React from "react"; -import { - ButtonHTMLProps, - ButtonOptions, - CompositeItemHTMLProps, - CompositeItemOptions, - useButton, - useCompositeItem, -} from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; -import { ariaAttr } from "../utils"; - -import { ACCORDION_TRIGGER_KEYS } from "./__keys"; -import { AccordionMultiStateReturn } from "./AccordionMultiState"; -import { AccordionStateReturn } from "./AccordionState"; -import { isAccordionSelected, useAccordionPanelId } from "./helpers"; - -export const useAccordionTrigger = createHook< - AccordionTriggerOptions, - AccordionTriggerHTMLProps ->({ - name: "Accordion", - compose: [useButton, useCompositeItem], - keys: ACCORDION_TRIGGER_KEYS, - - // * Do not add `focusable: true` in useOptions or else the disabled button - // * will receive the focus & considered as an element in composite navigation - - useProps( - options, - { - onClick: htmlOnClick, - onKeyDown: htmlOnKeyDown, - onFocus: htmlOnFocus, - ...htmlProps - }, - ) { - const { manual, id, allowToggle, select, first, last } = options; - const selected = isAccordionSelected(options); - const accordionPanelId = useAccordionPanelId(options); - - const onKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - const _first = first && (() => setTimeout(first)); - const _last = last && (() => setTimeout(last)); - const keyMap = { Home: _first, End: _last }; - const action = keyMap[event.key as keyof typeof keyMap]; - - if (action) { - event.preventDefault(); - event.stopPropagation(); - action(); - } - }, - [first, last], - ); - - const handleSelection = React.useCallback(() => { - if (!id) return; - - select?.(id); - }, [id, select]); - - const onClick = React.useCallback( - () => handleSelection(), - [handleSelection], - ); - - const onFocus = React.useCallback(() => { - if (manual) return; - - handleSelection(); - }, [manual, handleSelection]); - - return { - "aria-expanded": selected, - "aria-controls": accordionPanelId, - "aria-disabled": ariaAttr(!allowToggle && selected), - onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), - onClick: callAllHandlers(htmlOnClick, onClick), - onFocus: callAllHandlers(htmlOnFocus, onFocus), - ...htmlProps, - }; - }, - - useComposeProps(options, htmlProps) { - const buttonHtmlProps = useButton(options, htmlProps); - const compositeHtmlProps = useCompositeItem(options, buttonHtmlProps); - - return { - ...compositeHtmlProps, - - // *Add the tabIndex = 0 to button to make it tabbable in composite - tabIndex: 0, - }; - }, -}); - -export const AccordionTrigger = createComponent({ - as: "button", - memo: true, - useHook: useAccordionTrigger, -}); - -export type AccordionTriggerOptions = ButtonOptions & - CompositeItemOptions & - Pick< - AccordionStateReturn, - | "panels" - | "select" - | "manual" - | "selectedId" - | "allowToggle" - | "allowMultiple" - > & - Pick; - -export type AccordionTriggerHTMLProps = ButtonHTMLProps & - CompositeItemHTMLProps; - -export type AccordionTriggerProps = AccordionTriggerOptions & - AccordionTriggerHTMLProps; diff --git a/src/accordion/__keys.ts b/src/accordion/__keys.ts deleted file mode 100644 index e82d0f306..000000000 --- a/src/accordion/__keys.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Automatically generated -export const USE_ACCORDION_BASE_STATE_KEYS = [ - "baseId", - "unstable_virtual", - "rtl", - "orientation", - "currentId", - "loop", - "wrap", - "shift", - "unstable_includesBaseElement", -] as const; -export const ACCORDION_BASE_STATE_KEYS = [ - ...USE_ACCORDION_BASE_STATE_KEYS, - "unstable_idCountRef", - "items", - "groups", - "unstable_moves", - "unstable_hasActiveWidget", - "panels", - "setBaseId", - "registerItem", - "unregisterItem", - "registerGroup", - "unregisterGroup", - "move", - "next", - "previous", - "up", - "down", - "first", - "last", - "sort", - "unstable_setVirtual", - "setRTL", - "setOrientation", - "setCurrentId", - "setLoop", - "setWrap", - "setShift", - "reset", - "unstable_setIncludesBaseElement", - "unstable_setHasActiveWidget", - "registerPanel", - "unregisterPanel", -] as const; -export const USE_ACCORDION_MULTI_STATE_KEYS = [ - ...USE_ACCORDION_BASE_STATE_KEYS, - "manual", - "selectedIds", - "defaultSelectedIds", - "onSelectedIdsChange", - "shouldUpdate", -] as const; -export const ACCORDION_MULTI_STATE_KEYS = [ - ...ACCORDION_BASE_STATE_KEYS, - "selectedIds", - "allowToggle", - "allowMultiple", - "manual", - "setSelectedIds", - "select", -] as const; -export const USE_ACCORDION_STATE_KEYS = [ - ...USE_ACCORDION_BASE_STATE_KEYS, - "selectedId", - "manual", - "allowToggle", - "defaultSelectedId", - "onSelectedIdChange", - "shouldUpdate", -] as const; -export const ACCORDION_STATE_KEYS = [ - ...ACCORDION_BASE_STATE_KEYS, - "selectedId", - "allowToggle", - "allowMultiple", - "manual", - "setSelectedId", - "select", -] as const; -export const ACCORDION_KEYS = [ - ...ACCORDION_MULTI_STATE_KEYS, - ...ACCORDION_STATE_KEYS, -] as const; -export const ACCORDION_PANEL_KEYS = [...ACCORDION_KEYS, "accordionId"] as const; -export const ACCORDION_TRIGGER_KEYS = ACCORDION_KEYS; diff --git a/src/accordion/__tests__/Accordion.test.tsx b/src/accordion/__tests__/Accordion.test.tsx deleted file mode 100644 index 0489f379d..000000000 --- a/src/accordion/__tests__/Accordion.test.tsx +++ /dev/null @@ -1,250 +0,0 @@ -/* eslint-disable jest/no-conditional-expect */ -import * as React from "react"; -import { axe, press, render } from "reakit-test-utils"; -import userEvent from "@testing-library/user-event"; - -import { - Accordion, - AccordionInitialState, - AccordionMultiInitialState, - AccordionMultiStateReturn, - AccordionPanel, - AccordionStateReturn, - AccordionTrigger, - useAccordionMultiState, - useAccordionState, -} from "../index"; - -const AccordionComponent = ( - state: AccordionStateReturn | AccordionMultiStateReturn, -) => { - return ( - - - trigger 1 - - panel 1 - - - trigger 2 - - - panel 2 - - trigger 3 - - panel 3 - - - disabled - - - disabled panel - - ); -}; - -const AccordionSingleComponent = (props: Partial) => { - const state = useAccordionState(props); - - return ; -}; - -const AccordionMultipleComponent = ( - props: Partial, -) => { - const state = useAccordionMultiState(props); - - return ; -}; - -describe("Accordion", () => { - it("should render correctly", () => { - const { asFragment } = render( - , - ); - - expect(asFragment()).toMatchSnapshot(); - }); - - it("Accordion should have proper keyboard navigation", () => { - const { getByText: text } = render(); - - press.Tab(); - expect(text("trigger 1")).toHaveFocus(); - press.ArrowDown(); - expect(text("trigger 2")).toHaveFocus(); - press.ArrowDown(); - expect(text("trigger 3")).toHaveFocus(); - press.ArrowDown(); - expect(text("disabled")).not.toHaveFocus(); - press.ArrowDown(); - expect(text("disabled")).not.toHaveFocus(); - press.ArrowUp(); - expect(text("trigger 2")).toHaveFocus(); - press.ArrowUp(); - expect(text("trigger 1")).toHaveFocus(); - }); - - it("Accordion should work proper with mouse", () => { - const { getByText: text } = render(); - - expect(text("panel 1")).not.toBeVisible(); - - userEvent.click(text("trigger 1")); - expect(text("panel 1")).toBeVisible(); - - userEvent.click(text("trigger 2")); - expect(text("panel 2")).toBeVisible(); - - userEvent.click(text("trigger 3")); - expect(text("panel 3")).toBeVisible(); - }); - - it("Accordion should have proper keyboard navigation when on loop", () => { - const { getByText: text } = render(); - - press.Tab(); - expect(text("trigger 1")).toHaveFocus(); - press.ArrowDown(); - expect(text("trigger 2")).toHaveFocus(); - press.ArrowDown(); - expect(text("trigger 3")).toHaveFocus(); - press.ArrowDown(); - expect(text("disabled")).not.toHaveFocus(); - press.ArrowDown(); - expect(text("trigger 2")).toHaveFocus(); - press.ArrowUp(); - expect(text("trigger 1")).toHaveFocus(); - press.ArrowUp(); - expect(text("disabled")).not.toHaveFocus(); - expect(text("trigger 3")).toHaveFocus(); - }); - - it.each([true, false])("Accordion allowToggle: %s", toggle => { - const { getByText: text } = render( - , - ); - - const panel1 = text("panel 1"); - - press.Tab(); - expect(text("trigger 1")).toHaveFocus(); - expect(panel1).not.toBeVisible(); - - if (toggle) { - // if allowToggle is true then pressing again will close it - press.Enter(); - expect(panel1).toBeVisible(); - press.Enter(); - expect(panel1).not.toBeVisible(); - } else { - // if allowToggle is false then pressing again will close it - press.Enter(); - expect(panel1).toBeVisible(); - press.Enter(); - expect(panel1).toBeVisible(); - } - }); - - it("Accordion should open/close properly", () => { - const { getByText: text } = render(); - const panel1 = text("panel 1"); - const panel2 = text("panel 2"); - - press.Tab(); - expect(text("trigger 1")).toHaveFocus(); - expect(panel1).not.toBeVisible(); - // should work with SPACE too - press.Space(); - expect(panel1).toBeVisible(); - - // go to next panel - press.ArrowDown(); - expect(panel2).not.toBeVisible(); - press.Enter(); - expect(panel2).toBeVisible(); - - // panel 1 should be closed now if allowMultiple: false - expect(panel1).not.toBeVisible(); - }); - - it("Accordion should open/close properly with AllowMultiple", () => { - const { getByText: text } = render(); - const panel1 = text("panel 1"); - const panel2 = text("panel 2"); - - press.Tab(); - expect(text("trigger 1")).toHaveFocus(); - expect(panel1).not.toBeVisible(); - - press.Enter(); - expect(panel1).toBeVisible(); - - // go to next panel - press.ArrowDown(); - press.Enter(); - expect(panel2).toBeVisible(); - - // panel 1 should be visible since allowmultiple is true - expect(panel1).toBeVisible(); - }); - - it("Accordion should have none selected by default", () => { - const { getByText: text } = render(); - - press.Tab(); - expect(text("panel 1")).not.toBeVisible(); - expect(text("panel 2")).not.toBeVisible(); - expect(text("panel 3")).not.toBeVisible(); - }); - - it("Accordion with selectedId given to be selected properly", () => { - const { getByText: text } = render( - , - ); - - press.Tab(); - expect(text("panel 1")).not.toBeVisible(); - expect(text("panel 2")).toBeVisible(); - }); - - it("Accordion manual: false", () => { - const { getByText: text } = render( - , - ); - - press.Tab(); - expect(text("trigger 1")).toHaveFocus(); - expect(text("panel 1")).toBeVisible(); - - // go to next panel - press.ArrowDown(); - expect(text("trigger 2")).toHaveFocus(); - expect(text("panel 2")).toBeVisible(); - - // go to next panel - press.ArrowDown(); - expect(text("trigger 3")).toHaveFocus(); - expect(text("panel 3")).toBeVisible(); - }); - - it("Accordion disabled item", () => { - const { getByText: text } = render(); - - press.Tab(); - expect(text("trigger 1")).toHaveFocus(); - press.Enter(); - expect(text("panel 1")).toBeVisible(); - - expect(text("disabled")).toBeDisabled(); - expect(text("disabled panel")).not.toBeVisible(); - }); - - test("Accordion renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/accordion/__tests__/AccordionState.test.ts b/src/accordion/__tests__/AccordionState.test.ts deleted file mode 100644 index 916f60459..000000000 --- a/src/accordion/__tests__/AccordionState.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { renderHook } from "reakit-test-utils/hooks"; -import { jestSerializerStripFunctions } from "reakit-test-utils/jestSerializerStripFunctions"; - -import { useAccordionState } from "../AccordionState"; -import { AccordionInitialState } from ".."; - -expect.addSnapshotSerializer(jestSerializerStripFunctions); - -function render({ - baseId = "base", - ...initialState -}: Partial = {}) { - return renderHook(() => useAccordionState({ baseId, ...initialState })) - .result; -} - -describe("useAccordionState", () => { - test("initial state", () => { - const { current } = render(); - - expect(current).toMatchSnapshot(); - }); -}); diff --git a/src/accordion/__tests__/__snapshots__/Accordion.test.tsx.snap b/src/accordion/__tests__/__snapshots__/Accordion.test.tsx.snap deleted file mode 100644 index acd1aeb02..000000000 --- a/src/accordion/__tests__/__snapshots__/Accordion.test.tsx.snap +++ /dev/null @@ -1,93 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Accordion should render correctly 1`] = ` - - - - - trigger 1 - - - - panel 1 - - - - trigger 2 - - - - panel 2 - - - - trigger 3 - - - - panel 3 - - - - disabled - - - - disabled panel - - - -`; diff --git a/src/accordion/__tests__/__snapshots__/AccordionState.test.ts.snap b/src/accordion/__tests__/__snapshots__/AccordionState.test.ts.snap deleted file mode 100644 index d37eedb6d..000000000 --- a/src/accordion/__tests__/__snapshots__/AccordionState.test.ts.snap +++ /dev/null @@ -1,27 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`useAccordionState initial state 1`] = ` -Object { - "allowMultiple": false, - "allowToggle": false, - "baseId": "base", - "currentId": undefined, - "groups": Array [], - "items": Array [], - "loop": false, - "manual": true, - "orientation": "vertical", - "panels": Array [], - "rtl": false, - "selectedId": null, - "shift": false, - "unstable_hasActiveWidget": false, - "unstable_idCountRef": Object { - "current": 0, - }, - "unstable_includesBaseElement": false, - "unstable_moves": 0, - "unstable_virtual": false, - "wrap": false, -} -`; diff --git a/src/accordion/__utils.ts b/src/accordion/__utils.ts new file mode 100644 index 000000000..f4e545bca --- /dev/null +++ b/src/accordion/__utils.ts @@ -0,0 +1,13 @@ +import { createStoreContext } from "ariakit-utils/store"; + +import { AccordionState } from "./accordion-state"; + +export const AccordionContext = createStoreContext(); + +export const getSelectedId = (state?: AccordionState, id?: string) => { + if (!id) return; + + if (state?.allowMultiple) return state?.selectedId?.includes(id); + + return state?.selectedId === id; +}; diff --git a/src/accordion/accordion-base.ts b/src/accordion/accordion-base.ts new file mode 100644 index 000000000..67c94b4ed --- /dev/null +++ b/src/accordion/accordion-base.ts @@ -0,0 +1,38 @@ +import { CompositeOptions, useComposite } from "ariakit"; +import { useStoreProvider } from "ariakit-utils/store"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Props } from "ariakit-utils/types"; + +import { AccordionContext } from "./__utils"; +import { AccordionState } from "./accordion-state"; + +export const useAccordion = createHook( + ({ state, ...props }) => { + props = useStoreProvider({ state, ...props }, AccordionContext); + props = useComposite({ state, ...props }); + + return props; + }, +); + +export const Accordion = createComponent(props => { + const htmlProps = useAccordion(props); + + return createElement("div", htmlProps); +}); + +export type AccordionOptions = Omit< + CompositeOptions, + "state" +> & { + /** + * Object returned by the `useAccordionState` hook. + */ + state: AccordionState; +}; + +export type AccordionProps = Props>; diff --git a/src/accordion/accordion-disclosure.ts b/src/accordion/accordion-disclosure.ts new file mode 100644 index 000000000..0c08155af --- /dev/null +++ b/src/accordion/accordion-disclosure.ts @@ -0,0 +1,151 @@ +import { FocusEvent, MouseEvent, useCallback } from "react"; +import { + CompositeItemOptions, + useCompositeItem, +} from "ariakit/composite/composite-item"; +import { Item } from "ariakit/ts/collection/__utils"; +import { useEvent, useId } from "ariakit-utils/hooks"; +import { createMemoComponent, useStore } from "ariakit-utils/store"; +import { createElement, createHook } from "ariakit-utils/system"; +import { As, Props } from "ariakit-utils/types"; + +import { AccordionContext, getSelectedId } from "./__utils"; +import { AccordionState } from "./accordion-state"; + +function getPanelId(panels?: AccordionState["panels"], id?: string) { + if (!id) return; + return panels?.items.find(panel => panel.accordionId === id)?.id; +} + +/** + * A component hook that returns props that can be passed to `Role` or any other + * Ariakit component to render a accordion element. The underlying element must be + * wrapped in a `Accordion` component or a component that implements the + * `useAccordion` props. + * @see https://ariakit.org/components/accordion + * @example + * ```jsx + * const state = useAccordionState(); + * const props = useAccordionDisclosure({ state }); + * + * Accordion 1 + * Panel 1 + * + * ``` + */ +export const useAccordionDisclosure = createHook( + ({ + state, + accessibleWhenDisabled = true, + getItem: getItemProp, + ...props + }) => { + const id = useId(props.id); + + state = useStore(state || AccordionContext, [ + useCallback( + (s: AccordionState) => { + if (!id) return; + + if (s.allowMultiple) { + return s.selectedId?.includes(id); + } + + return s.selectedId === id; + }, + [id], + ), + "panels", + "toggle", + "selectOnMove", + ]); + + const onClickProp = props.onClick; + + const onClick = useEvent((event: MouseEvent) => { + onClickProp?.(event); + if (event.defaultPrevented) return; + + state?.toggle(id); + }); + + const onFocusProp = props.onFocus; + + const onFocus = useEvent((event: FocusEvent) => { + onFocusProp?.(event); + if (event.defaultPrevented) return; + if (!state?.selectOnMove) return; + + state?.toggle(id); + }); + + const panelId = getPanelId(state?.panels, id); + + props = { + id, + "aria-expanded": getSelectedId(state, id), + "aria-controls": panelId || undefined, + ...props, + onClick, + onFocus, + }; + + const dimmed = props.disabled; + + const getItem = useCallback( + (item: Item) => { + const nextItem = { ...item, dimmed }; + if (getItemProp) return getItemProp(nextItem); + + return nextItem; + }, + [dimmed, getItemProp], + ); + + props = useCompositeItem({ + state, + ...props, + accessibleWhenDisabled, + getItem, + }); + + return { ...props, tabIndex: 0 }; + }, +); + +/** + * A component that renders a accordion element. The underlying element must be + * wrapped in a `Accordion` component. + * @see https://ariakit.org/components/accordion + * @example + * ```jsx + * const accordion = useAccordionState(); + * + * Accordion 1 + * Panel 1 + * Accordion 2 + * Panel 2 + * + * ``` + */ +export const AccordionDisclosure = + createMemoComponent(props => { + const htmlProps = useAccordionDisclosure(props); + + return createElement("button", htmlProps); + }); + +export type AccordionDisclosureOptions = Omit< + CompositeItemOptions, + "state" +> & { + /** + * Object returned by the `useAccordionState` hook. If not provided, the parent + * `Accordion` component's context will be used. + */ + state?: AccordionState; +}; + +export type AccordionDisclosureProps = Props< + AccordionDisclosureOptions +>; diff --git a/src/accordion/accordion-panel.ts b/src/accordion/accordion-panel.ts new file mode 100644 index 000000000..fb948be10 --- /dev/null +++ b/src/accordion/accordion-panel.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + CollectionItemOptions, + useCollectionItem, +} from "ariakit/collection/collection-item"; +import { + DisclosureContentOptions, + useDisclosureContent, + useDisclosureState, +} from "ariakit/disclosure"; +import { FocusableOptions, useFocusable } from "ariakit/focusable"; +import { Item } from "ariakit/ts/collection/__utils"; +import { getAllTabbableIn } from "ariakit-utils/focus"; +import { useForkRef, useId } from "ariakit-utils/hooks"; +import { useStore } from "ariakit-utils/store"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Props } from "ariakit-utils/types"; + +import { AccordionContext, getSelectedId } from "./__utils"; +import { AccordionState } from "./accordion-state"; + +function getAccordionId(panels?: AccordionState["panels"], id?: string) { + if (!id) return; + + return panels?.items.find(panel => panel.id === id)?.accordionId; +} + +/** + * A component hook that returns props that can be passed to `Role` or any other + * Ariakit component to render a accordion panel element. + * @see https://ariakit.org/components/accordion + * @example + * ```jsx + * const state = useAccordionState(); + * const props = useAccordionPanel({ state }); + * + * Accordion 1 + * Panel 1 + * + * ``` + */ +export const useAccordionPanel = createHook( + ({ state, accordionId: accordionIdProp, getItem: getItemProp, ...props }) => { + const ref = useRef(null); + const id = useId(props.id); + + state = useStore(state || AccordionContext, [ + "selectedId", + "panels", + "setSelectedId", + ]); + + const accordionId = accordionIdProp || getAccordionId(state?.panels, id); + + props = { + id, + role: "region", + "aria-labelledby": accordionId || undefined, + ...props, + ref: useForkRef(ref, props.ref), + }; + + const [hasTabbableChildren, setHasTabbableChildren] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const tabbable = getAllTabbableIn(element); + setHasTabbableChildren(!!tabbable.length); + }, []); + + props = useFocusable({ focusable: hasTabbableChildren, ...props }); + + const visible = !!accordionId && getSelectedId(state, accordionId); + const disclosure = useDisclosureState({ visible }); + props = useDisclosureContent({ state: disclosure, ...props }); + + const getItem = useCallback( + (item: Item) => { + const nextItem = { ...item, id, accordionId: accordionIdProp }; + if (getItemProp) return getItemProp(nextItem); + + return nextItem; + }, + [id, accordionIdProp, getItemProp], + ); + + props = useCollectionItem({ + state: state?.panels, + ...props, + getItem, + shouldRegisterItem: !!id ? props.shouldRegisterItem : false, + }); + + return props; + }, +); + +/** + * A component that renders a accordion panel element. + * @see https://ariakit.org/components/accordion + * @example + * ```jsx + * const accordion = useAccordionState(); + * + * Accordion 1 + * Panel 1 + * Accordion 2 + * Panel 2 + * + * ``` + */ +export const AccordionPanel = createComponent(props => { + const htmlProps = useAccordionPanel(props); + + return createElement("div", htmlProps); +}); + +export type AccordionPanelOptions = FocusableOptions & + Omit & + Omit, "state"> & { + /** + * Object returned by the `useAccordionState` hook. + */ + state?: AccordionState; + /** + * The id of the accordion that controls this panel. By default, this value will + * be inferred based on the order of the accordions and the panels. + */ + accordionId?: string | null; + }; + +export type AccordionPanelProps = Props< + AccordionPanelOptions +>; diff --git a/src/accordion/accordion-state.ts b/src/accordion/accordion-state.ts new file mode 100644 index 000000000..578d08f9e --- /dev/null +++ b/src/accordion/accordion-state.ts @@ -0,0 +1,309 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + CollectionState, + useCollectionState, +} from "ariakit/collection/collection-state"; +import { + CompositeState, + CompositeStateProps, + useCompositeState, +} from "ariakit/composite/composite-state"; +import { useControlledState, useLiveRef } from "ariakit-utils/hooks"; +import { useStorePublisher } from "ariakit-utils/store"; +import { SetState } from "ariakit-utils/types"; + +type Item = CompositeState["items"][number] & { + dimmed?: boolean; +}; + +type Panel = CollectionState["items"][number] & { + id: string; + accordionId?: string | null; +}; + +function findEnabledAccordionById(items: Item[], id?: string | null) { + return items.find(item => item.id === id && !item.disabled && !item.dimmed); +} + +function findFirstEnabledAccordion(items: Item[]) { + return items.find(item => !item.disabled && !item.dimmed); +} + +/** + * Provides state for the `Accordion` components. + * @example + * ```jsx + * const accordion = useAccordionState(); + * + * Accordion 1 + * Panel 1 + * Accordion 2 + * Panel 2 + * + * ``` + */ +export function useAccordionState({ + orientation = "vertical", + focusLoop = true, + selectOnMove = false, + shouldSelectFirstId = false, + allowMultiple = false, + allowToggle = allowMultiple || false, + ...props +}: AccordionStateProps = {}): AccordionState { + const [selectedId, setSelectedId] = useControlledState( + props.defaultSelectedId, + props.selectedId, + props.setSelectedId, + ); + const composite = useCompositeState({ orientation, focusLoop, ...props }); + const panels = useCollectionState(); + const compositeRef = useLiveRef(composite); + const firstEnabledAccordionSelected = useRef(false); + + const select: AccordionState["toggle"] = useCallback( + id => { + // Runs when the accordion has `allowMultiple` - `false` + if (!allowMultiple || id == null) { + setSelectedId(id); + + return; + } + + // Runs when the accordion has `allowMultiple` - `true` + setSelectedId([id]); + }, + [allowMultiple, setSelectedId], + ); + + // Automatically set selectedId if it's undefined. + useEffect(() => { + if (!shouldSelectFirstId) return; + if (allowToggle) return; + if (selectedId !== undefined) return; + + // First, we try to set selectedId based on the current active accordion. + const activeId = compositeRef.current.activeId; + if (!activeId) return; + + const accordion = findEnabledAccordionById(composite.items, activeId); + if (!accordion) { + // If there's no active accordion or the active accordion is dimmed, we get the first + // enabled accordion instead. + const firstEnabledAccordion = findFirstEnabledAccordion(composite.items); + if (!firstEnabledAccordion) return; + + select(firstEnabledAccordion?.id); + + return; + } + + select(activeId); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + allowToggle, + shouldSelectFirstId, + selectedId, + composite.items, + allowMultiple, + setSelectedId, + ]); + + // Automatically set selectedId if it's undefined. + useEffect(() => { + if (!shouldSelectFirstId || !allowToggle) return; + if (firstEnabledAccordionSelected.current === true) return; + + // First, we try to set selectedId based on the current active accordion. + const activeId = compositeRef.current.activeId; + if (!activeId) return; + + const accordion = findEnabledAccordionById(composite.items, activeId); + if (!accordion) { + // If there's no active accordion or the active accordion is dimmed, we get the first + // enabled accordion instead. + const firstEnabledAccordion = findFirstEnabledAccordion(composite.items); + if (!firstEnabledAccordion) return; + + select(firstEnabledAccordion?.id); + + return; + } + + firstEnabledAccordionSelected.current = true; + + select(activeId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + allowToggle, + shouldSelectFirstId, + selectedId, + composite.items, + setSelectedId, + allowMultiple, + select, + ]); + + // Keep panels accordionIds in sync with the current accordions. + useEffect(() => { + if (!composite.items.length) return; + + panels.setItems(prevPanels => { + const hasOrphanPanels = prevPanels.some(panel => !panel.accordionId); + if (!hasOrphanPanels) return prevPanels; + + return prevPanels.map((panel, i) => { + if (panel.accordionId) return panel; + + const accordion = composite.items[i]; + + return { ...panel, accordionId: accordion?.id }; + }); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [composite.items, panels.setItems]); + + const toggle: AccordionState["toggle"] = useCallback( + id => { + // If the accordion is already selected, we unselect it. + if (allowToggle && id === selectedId) { + setSelectedId(undefined); + + return; + } + + // If the accordion is toggled, we move the composite accordingly. + composite.move(id); + + // Runs when the accordion has `allowMultiple` - `false` + // and the `id` is `null`. + if (!allowMultiple || id == null) { + setSelectedId(id); + + return; + } + + // Runs when the accordion has `allowMultiple` - `true` + if (selectedId?.includes(id)) { + setSelectedId(prevId => + (prevId as string[])?.filter(pId => pId !== id), + ); + + return; + } + + setSelectedId((prevId: string[]) => { + if (prevId == null) return [id]; + + return [...prevId, id]; + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [allowToggle, selectedId, setSelectedId, composite.move, allowMultiple], + ); + + const state = useMemo( + () => ({ + ...composite, + allowToggle, + allowMultiple, + selectedId, + setSelectedId, + toggle, + panels, + selectOnMove, + }), + [ + composite, + allowToggle, + allowMultiple, + selectedId, + setSelectedId, + toggle, + panels, + selectOnMove, + ], + ); + + return useStorePublisher(state); +} + +export type AccordionState = CompositeState & { + /** + * Whether the accordion panels can be toggled on click. If it's set to + * `false`, the panels cannot be closed on the next click. + * @default false + */ + allowToggle: boolean; + /** + * Whether multiple accordion panels can be viewed at once. If it's set to + * `false`, the accordion will only allow one panel to be viewed at once. + * @default false + */ + allowMultiple: boolean; + /** + * The id of the accordion whose panel is currently visible. + */ + selectedId: AccordionState["activeId"] | string[]; + /** + * Sets the `selectedId` state. + */ + setSelectedId: SetState; + /** + * Selects the accordion panel for the accordion with the given id. + */ + toggle: AccordionState["move"]; + /** + * A collection state containing the accordion panels. + */ + panels: CollectionState; + /** + * Whether the accordion should be selected when it receives focus. If it's set to + * `false`, the accordion will be selected only when it's clicked. + * @default false + */ + selectOnMove?: boolean; + /** + * Whether the first accordion should be selected by default. If it's set to `false`, + * the accordion will be selected only when it's clicked. + * @default false + */ + shouldSelectFirstId?: boolean; +}; + +export type AccordionStateProps = CompositeStateProps & + Partial< + Pick< + AccordionState, + | "selectedId" + | "selectOnMove" + | "shouldSelectFirstId" + | "allowToggle" + | "allowMultiple" + > + > & { + /** + * The id of the accordion whose panel should be initially visible. + * @example + * ```jsx + * const accordion = useAccordionState({ defaultSelectedId: "accordion-1" }); + * + * Accordion 1 + * Panel 1 + * + * ``` + */ + defaultSelectedId?: AccordionState["selectedId"]; + /** + * Function that will be called when setting the accordion `selectedId` state. + * @example + * function Accordions({ visibleAccordion, onAccordionChange }) { + * const accordion = useAccordionState({ + * selectedId: visibleAccordion, + * setSelectedId: onAccordionChange, + * }); + * } + */ + setSelectedId?: (selectedId: AccordionState["selectedId"]) => void; + }; diff --git a/src/accordion/helpers.ts b/src/accordion/helpers.ts deleted file mode 100644 index abdde46bf..000000000 --- a/src/accordion/helpers.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as React from "react"; - -import { AccordionPanelOptions } from "./AccordionPanel"; -import { AccordionTriggerOptions } from "./AccordionTrigger"; - -export type StringOrNull = string | null; - -/** - * When is used without accordionId: - * - * - First render: getAccordionId will return undefined because options.panels - * doesn't contain the current panel yet (registerPanel wasn't called yet). - * Thus registerPanel will be called without groupId (accordionId). - * - * - Second render: options.panels already contains the current panel (because - * registerPanel was called in the previous render). This means that we'll be - * able to get the related accordionId with the accordion panel index. Basically, - * we filter out all the accordions and panels that have already matched. In this - * phase, registerPanel will be called again with the proper groupId (accordionId). - * - * - In the third render, panel.groupId will be already defined, so we just - * return it. registerPanel is not called. - */ -export function getAccordionId(options: AccordionPanelOptions) { - const { panels, id, items } = options; - const panel = panels?.find(p => p.id === id); - const accordionId = options.accordionId || panel?.groupId; - - if (accordionId || !panel || !panels || !items) { - return accordionId; - } - - const panelIndex = getPanelIndex(panels, panel); - const accordionsWithoutPanel = getAccordionsWithoutPanel(items, panels); - - return accordionsWithoutPanel[panelIndex]?.id || undefined; -} - -function getPanelIndex( - panels: AccordionPanelOptions["panels"], - panel: typeof panels[number], -) { - const panelsWithoutAccordionId = panels.filter(p => !p.groupId); - - return panelsWithoutAccordionId.indexOf(panel); -} - -function getAccordionsWithoutPanel( - accordions: AccordionPanelOptions["items"], - panels: AccordionPanelOptions["panels"], -) { - const panelsAccordionIds = panels.map(panel => panel.groupId).filter(Boolean); - - return accordions.filter( - item => panelsAccordionIds.indexOf(item.id || undefined) === -1, - ); -} - -export function isPanelVisible(options: AccordionPanelOptions) { - const { allowMultiple, selectedId, selectedIds } = options; - const accordionId = getAccordionId(options); - - if (allowMultiple) - return accordionId ? selectedIds?.includes(accordionId) : false; - - return accordionId ? selectedId === accordionId : false; -} - -export function isAccordionSelected(options: AccordionTriggerOptions) { - const { id, allowMultiple, selectedId, selectedIds } = options; - - if (!id) return; - - if (allowMultiple) return selectedIds?.includes(id); - - return selectedId === id; -} - -export function useAccordionPanelId(options: AccordionTriggerOptions) { - const { panels, id } = options; - - return React.useMemo( - () => panels?.find(panel => panel.groupId === id)?.id || undefined, - [panels, id], - ); -} diff --git a/src/accordion/index.ts b/src/accordion/index.ts index 5fa39e4de..d47bdfdd5 100644 --- a/src/accordion/index.ts +++ b/src/accordion/index.ts @@ -1,8 +1,4 @@ -export * from "./__keys"; -export * from "./Accordion"; -export * from "./AccordionBaseState"; -export * from "./AccordionBaseState"; -export * from "./AccordionMultiState"; -export * from "./AccordionPanel"; -export * from "./AccordionState"; -export * from "./AccordionTrigger"; +export * from "./accordion-base"; +export * from "./accordion-disclosure"; +export * from "./accordion-panel"; +export * from "./accordion-state"; diff --git a/src/accordion/stories/AccordionBasic.component.tsx b/src/accordion/stories/AccordionBasic.component.tsx index 7c7480bfd..e265097a9 100644 --- a/src/accordion/stories/AccordionBasic.component.tsx +++ b/src/accordion/stories/AccordionBasic.component.tsx @@ -1,48 +1,44 @@ import * as React from "react"; import { - Accordion as RenderlesskitAccordion, - AccordionInitialState, + Accordion, + AccordionDisclosure, AccordionPanel, - AccordionTrigger, + AccordionStateProps, useAccordionState, } from "../../index"; -export const Accordion: React.FC = props => { +export const AccordionBasic: React.FC = props => { const state = useAccordionState(props); return ( - + - Trigger 1 + Trigger 1 - Panel 1 + Panel 1 - Trigger 2 + Trigger 2 - Panel 2 + Panel 2 - - Trigger 3 - + Trigger 3 - Panel 3 + Panel 3 - Trigger 4 + Trigger 4 - Panel 4 + Panel 4 - - Trigger 5 - + Trigger 5 - Panel 5 + Panel 5 - Trigger 6 + Trigger 6 - Panel 6 - + Panel 6 + ); }; -export default Accordion; +export default AccordionBasic; diff --git a/src/accordion/stories/AccordionBasic.stories.tsx b/src/accordion/stories/AccordionBasic.stories.tsx index 01c255546..e4454db4a 100644 --- a/src/accordion/stories/AccordionBasic.stories.tsx +++ b/src/accordion/stories/AccordionBasic.stories.tsx @@ -1,46 +1,49 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; +import { createPreviewTabs } from "../../../.storybook/utils"; import js from "./templates/AccordionBasicJsx"; import ts from "./templates/AccordionBasicTsx"; -import Accordion from "./AccordionBasic.component"; +import { AccordionBasic } from "./AccordionBasic.component"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; export default { - component: Accordion, title: "Accordion/Basic", + component: AccordionBasic, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts }), }, - argTypes: createControls({ - ignore: [ - "selectedId", - "wrap", - "baseId", - "unstable_virtual", - "rtl", - "orientation", - "currentId", - "shift", - "unstable_includesBaseElement", - "onSelectedIdChange", - "shouldUpdate", - ], - }), } as Meta; -export const Default: Story = args => ; +export const Default: Story = {}; + +export const DefaultFirstIdSelected: Story = { + args: { shouldSelectFirstId: true }, +}; + +export const DefaultSelected: Story = { + args: { defaultSelectedId: "Trigger 3" }, +}; + +export const SelectOnMove: Story = { + args: { selectOnMove: true }, +}; -export const DefaultSelected = Default.bind({}); -DefaultSelected.args = { defaultSelectedId: "accordion3" }; +export const NoLoop: Story = { + args: { focusLoop: false }, +}; -export const AutoSelect = Default.bind({}); -AutoSelect.args = { manual: false }; +export const AllowToggle: Story = { + args: { allowToggle: true }, +}; -export const Loop = Default.bind({}); -Loop.args = { loop: true }; +export const DefaultFirstIdToggle: Story = { + args: { shouldSelectFirstId: true, allowToggle: true }, +}; -export const AllowToggle = Default.bind({}); -AllowToggle.args = { allowToggle: true }; +export const SelectOnMoveToggle: Story = { + args: { selectOnMove: true, allowToggle: true }, +}; diff --git a/src/accordion/stories/AccordionMulti.component.tsx b/src/accordion/stories/AccordionMulti.component.tsx deleted file mode 100644 index 80fc5e74a..000000000 --- a/src/accordion/stories/AccordionMulti.component.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import * as React from "react"; - -import { - Accordion as RenderlesskitAccordion, - AccordionMultiInitialState, - AccordionPanel, - AccordionTrigger, - useAccordionMultiState, -} from "../../index"; - -export const Accordion: React.FC = props => { - const state = useAccordionMultiState(props); - - return ( - - - Trigger 1 - - Panel 1 - - Trigger 2 - - Panel 2 - - - Trigger 3 - - - Panel 3 - - - Trigger 4 - - - Panel 4 - - - Trigger 5 - - - Panel 5 - - Trigger 6 - - Panel 6 - - ); -}; - -export default Accordion; diff --git a/src/accordion/stories/AccordionMulti.stories.tsx b/src/accordion/stories/AccordionMulti.stories.tsx deleted file mode 100644 index fabd2ff5e..000000000 --- a/src/accordion/stories/AccordionMulti.stories.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; - -import js from "./templates/AccordionMultiJsx"; -import ts from "./templates/AccordionMultiTsx"; -import Accordion from "./AccordionMulti.component"; - -export default { - component: Accordion, - title: "Accordion/Multi", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts }), - }, - argTypes: createControls({ - ignore: [ - "selectedIds", - "wrap", - "baseId", - "unstable_virtual", - "rtl", - "orientation", - "currentId", - "shift", - "unstable_includesBaseElement", - "onSelectedIdsChange", - "shouldUpdate", - ], - }), -} as Meta; - -export const Default: Story = args => ; - -export const DefaultSelected = Default.bind({}); -DefaultSelected.args = { defaultSelectedIds: ["accordion3", "accordion4"] }; - -export const AutoSelect = Default.bind({}); -AutoSelect.args = { manual: false }; - -export const Loop = Default.bind({}); -Loop.args = { loop: true }; diff --git a/src/accordion/stories/AccordionMultiple.component.tsx b/src/accordion/stories/AccordionMultiple.component.tsx new file mode 100644 index 000000000..3c3bd564e --- /dev/null +++ b/src/accordion/stories/AccordionMultiple.component.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; + +import { + Accordion, + AccordionDisclosure, + AccordionPanel, + AccordionStateProps, + useAccordionState, +} from "../../index"; + +export const AccordionMultiple: React.FC = props => { + const state = useAccordionState({ allowMultiple: true, ...props }); + + return ( + + + Trigger 1 + + Panel 1 + + Trigger 2 + + Panel 2 + + Trigger 3 + + Panel 3 + + Trigger 4 + + Panel 4 + + Trigger 5 + + Panel 5 + + Trigger 6 + + Panel 6 + + ); +}; + +export default AccordionMultiple; diff --git a/src/accordion/stories/AccordionMultiple.stories.tsx b/src/accordion/stories/AccordionMultiple.stories.tsx new file mode 100644 index 000000000..ce2edc1b1 --- /dev/null +++ b/src/accordion/stories/AccordionMultiple.stories.tsx @@ -0,0 +1,33 @@ +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/AccordionMultipleJsx"; +import ts from "./templates/AccordionMultipleTsx"; +import { AccordionMultiple } from "./AccordionMultiple.component"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + +export default { + title: "Accordion/Multiple", + component: AccordionMultiple, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = {}; + +export const DefaultFirstIdSelected: Story = { + args: { shouldSelectFirstId: true }, +}; + +export const DefaultSelected: Story = { + args: { defaultSelectedId: ["Trigger 3", "Trigger 4"] }, +}; + +export const NoLoop: Story = { + args: { focusLoop: false }, +}; diff --git a/src/accordion/stories/AccordionStyled.component.tsx b/src/accordion/stories/AccordionStyled.component.tsx index 40aef1d0e..85d8286a1 100644 --- a/src/accordion/stories/AccordionStyled.component.tsx +++ b/src/accordion/stories/AccordionStyled.component.tsx @@ -1,32 +1,28 @@ import * as React from "react"; import { - Accordion as RenderlesskitAccordion, - AccordionInitialState, + Accordion, + AccordionDisclosure, AccordionPanel, - AccordionTrigger, + AccordionStateProps, useAccordionState, } from "../../index"; // Styled based on https://www.w3.org/TR/wai-aria-practices-1.2/examples/accordion/accordion.html -export const Accordion: React.FC = props => { +export const AccordionStyled: React.FC = props => { const state = useAccordionState(props); return ( - + - + Personal Information - + - + @@ -70,18 +66,14 @@ export const Accordion: React.FC = props => { - + Billing Address - + - + @@ -109,14 +101,14 @@ export const Accordion: React.FC = props => { - + Shipping Address - + - + @@ -142,7 +134,7 @@ export const Accordion: React.FC = props => { - + ); }; diff --git a/src/accordion/stories/AccordionStyled.stories.tsx b/src/accordion/stories/AccordionStyled.stories.tsx index eb1c8ab8b..7b785c286 100644 --- a/src/accordion/stories/AccordionStyled.stories.tsx +++ b/src/accordion/stories/AccordionStyled.stories.tsx @@ -1,49 +1,52 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; +import { createPreviewTabs } from "../../../.storybook/utils"; import css from "./templates/AccordionStyledCss"; import js from "./templates/AccordionStyledJsx"; import ts from "./templates/AccordionStyledTsx"; -import { Accordion } from "./AccordionStyled.component"; +import { AccordionStyled } from "./AccordionStyled.component"; import "./AccordionStyled.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: Accordion, + component: AccordionStyled, title: "Accordion/Styled", parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css }), }, - argTypes: createControls({ - ignore: [ - "selectedId", - "wrap", - "baseId", - "unstable_virtual", - "rtl", - "orientation", - "currentId", - "shift", - "unstable_includesBaseElement", - "onSelectedIdChange", - "shouldUpdate", - ], - }), } as Meta; -export const Default: Story = args => ; +export const Default: Story = {}; + +export const DefaultFirstIdSelected: Story = { + args: { shouldSelectFirstId: true }, +}; + +export const DefaultSelected: Story = { + args: { defaultSelectedId: "accordion2" }, +}; + +export const SelectOnMove: Story = { + args: { selectOnMove: true }, +}; -export const DefaultSelected = Default.bind({}); -DefaultSelected.args = { defaultSelectedId: "accordion2" }; +export const NoLoop: Story = { + args: { focusLoop: false }, +}; -export const AutoSelect = Default.bind({}); -AutoSelect.args = { manual: false }; +export const AllowToggle: Story = { + args: { allowToggle: true }, +}; -export const Loop = Default.bind({}); -Loop.args = { loop: true }; +export const DefaultFirstIdToggle: Story = { + args: { shouldSelectFirstId: true, allowToggle: true }, +}; -export const AllowToggle = Default.bind({}); -AllowToggle.args = { allowToggle: true }; +export const SelectOnMoveToggle: Story = { + args: { selectOnMove: true, allowToggle: true }, +}; diff --git a/src/accordion/stories/AccordionTutorial.component.tsx b/src/accordion/stories/AccordionTutorial.component.tsx deleted file mode 100644 index e9d0607b8..000000000 --- a/src/accordion/stories/AccordionTutorial.component.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import * as React from "react"; - -import { - Accordion as RenderlesskitAccordion, - AccordionInitialState, - AccordionPanel, - AccordionTrigger, - useAccordionState, -} from "../../index"; - -export const Accordion: React.FC = props => { - const initialProps = { - defaultSelectedId: "accordion3", - manual: true, - loop: true, - allowToggle: true, - }; - - const state = useAccordionState(initialProps || props); - - const [text, setText] = React.useState("Start Tutorial"); - - let stateRef = React.useRef(state); - stateRef.current = state; - - const runTutorial = async () => { - stateRef.current.first(); - stateRef.current.currentId != null && - stateRef.current.setSelectedId(stateRef.current.currentId); - setText("Moved to First Accordion & opened it"); - await sleep(3000); - - stateRef.current.currentId != null && stateRef.current.select("accordion3"); - setText("Selected Accordion 3"); - await sleep(3000); - - stateRef.current.next(); - stateRef.current.currentId != null && - stateRef.current.setSelectedId(stateRef.current.currentId); - setText("Moved to Next Accordion & opened it"); - await sleep(3000); - - stateRef.current.previous(); - setText("Moved to Previous Accordion"); - await sleep(1500); - - stateRef.current.previous(); - stateRef.current.currentId != null && - stateRef.current.setSelectedId(stateRef.current.currentId); - setText("Moved to Previous Accordion once more & opened it"); - await sleep(3000); - - stateRef.current.currentId != null && stateRef.current.select("accordion6"); - setText("Selected Accordion 6"); - await sleep(3000); - }; - - return ( - - - {text} - - - - Trigger 1 - - Panel 1 - - Trigger 2 - - Panel 2 - - - Trigger 3 - - - Panel 3 - - Trigger 4 - - Panel 4 - - - Trigger 5 - - - Panel 5 - - - Trigger 6 - - - Panel 6 - - - ); -}; - -export default Accordion; - -function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/src/accordion/stories/AccordionTutorial.stories.tsx b/src/accordion/stories/AccordionTutorial.stories.tsx deleted file mode 100644 index 3bed700f2..000000000 --- a/src/accordion/stories/AccordionTutorial.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; - -import js from "./templates/AccordionTutorialJsx"; -import ts from "./templates/AccordionTutorialTsx"; -import Accordion from "./AccordionTutorial.component"; - -export default { - component: Accordion, - title: "Accordion/Tutorial", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts }), - }, - argTypes: createControls({ - ignore: [ - "selectedId", - "wrap", - "baseId", - "unstable_virtual", - "rtl", - "orientation", - "currentId", - "shift", - "unstable_includesBaseElement", - "onSelectedIdChange", - "shouldUpdate", - ], - }), -} as Meta; - -export const Default: Story = args => ; diff --git a/src/breadcrumbs/BreadcrumbLink.ts b/src/breadcrumbs/BreadcrumbLink.ts deleted file mode 100644 index 99620a8fd..000000000 --- a/src/breadcrumbs/BreadcrumbLink.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { LinkHTMLProps, LinkOptions, useLink } from "../link"; -import { createComponent, createHook } from "../system"; - -import { BREADCRUMB_LINK_KEYS } from "./__keys"; - -export const useBreadcrumbLink = createHook< - BreadcrumbLinkOptions, - BreadcrumbLinkHTMLProps ->({ - name: "BreadcrumbLink", - compose: useLink, - keys: BREADCRUMB_LINK_KEYS, - - useProps({ isCurrent }, htmlProps) { - return { "aria-current": isCurrent && "page", ...htmlProps }; - }, -}); - -export const BreadcrumbLink = createComponent({ - as: "a", - memo: true, - useHook: useBreadcrumbLink, -}); - -export type BreadcrumbLinkOptions = { - /** - * If true, sets `aria-current: "page"` - */ - isCurrent?: boolean; -} & LinkOptions; - -export type BreadcrumbLinkHTMLProps = LinkHTMLProps; - -export type BreadcrumbLinkProps = BreadcrumbLinkOptions & - BreadcrumbLinkHTMLProps; diff --git a/src/breadcrumbs/Breadcrumbs.ts b/src/breadcrumbs/Breadcrumbs.ts deleted file mode 100644 index f533e53d1..000000000 --- a/src/breadcrumbs/Breadcrumbs.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createComponent, createHook, useCreateElement } from "reakit-system"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { useWarning } from "reakit-warning"; - -export const useBreadcrumbs = createHook< - BreadcrumbsOptions, - BreadcrumbsHTMLProps ->({ - name: "Breadcrumb", - compose: useRole, -}); - -export const Breadcrumbs = createComponent({ - as: "nav", - memo: true, - useHook: useBreadcrumbs, - useCreateElement: (type, props, children) => { - useWarning( - !props["aria-label"] && !props["aria-labelledby"], - "You should provide either `aria-label` or `aria-labelledby` props.", - "See https://www.w3.org/TR/wai-aria-practices-1.1/#wai-aria-roles-states-and-properties-2", - ); - return useCreateElement(type, props, children); - }, -}); - -export type BreadcrumbsOptions = RoleOptions; - -export type BreadcrumbsHTMLProps = RoleHTMLProps; - -export type BreadcrumbProps = BreadcrumbsOptions & BreadcrumbsHTMLProps; diff --git a/src/breadcrumbs/__keys.ts b/src/breadcrumbs/__keys.ts deleted file mode 100644 index a554edec2..000000000 --- a/src/breadcrumbs/__keys.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Automatically generated -export const BREADCRUMB_LINK_KEYS = ["isCurrent"] as const; -export const BREADCRUMBS_KEYS = [] as const; diff --git a/src/breadcrumbs/__tests__/Breadcrumbs.test.tsx b/src/breadcrumbs/__tests__/Breadcrumbs.test.tsx deleted file mode 100644 index 619841218..000000000 --- a/src/breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; -import { axe, render } from "reakit-test-utils"; - -import { BreadcrumbLink, Breadcrumbs } from "../index"; - -const BreadcrumbComp = () => { - return ( - - - - - WAI-ARIA Authoring Practices 1.1 - - - - - Design Patterns - - - - - Breadcrumb Pattern - - - - - Breadcrumb Example - - - - - ); -}; - -describe("Breadcrumb", () => { - it("should render correctly", () => { - const { asFragment } = render(); - - expect(asFragment()).toMatchSnapshot(); - }); - - test("Breadcrumb renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap b/src/breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap deleted file mode 100644 index 91e68d16a..000000000 --- a/src/breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Breadcrumb should render correctly 1`] = ` - - - - - - WAI-ARIA Authoring Practices 1.1 - - - - - Design Patterns - - - - - Breadcrumb Pattern - - - - - Breadcrumb Example - - - - - -`; diff --git a/src/breadcrumbs/breadcrumb-link.ts b/src/breadcrumbs/breadcrumb-link.ts new file mode 100644 index 000000000..b0cc4c5c4 --- /dev/null +++ b/src/breadcrumbs/breadcrumb-link.ts @@ -0,0 +1,39 @@ +import { CommandOptions } from "ariakit"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Props } from "ariakit-utils/types"; + +import { useLink } from "../link"; + +export const useBreadcrumbLink = createHook( + ({ isCurrentPage, ...props }) => { + props = { + "aria-current": isCurrentPage && "page", + ...props, + }; + + props = useLink(props); + + return props; + }, +); + +export const BreadcrumbLink = createComponent(props => { + const htmlProps = useBreadcrumbLink(props); + + return createElement("a", htmlProps); +}); + +export type BreadcrumbLinkOptions = CommandOptions & { + /** + * If true, sets `aria-current: "page"` + */ + isCurrentPage?: boolean; +}; + +export type BreadcrumbLinkProps = Props< + BreadcrumbLinkOptions +>; diff --git a/src/breadcrumbs/breadcrumbs-base.ts b/src/breadcrumbs/breadcrumbs-base.ts new file mode 100644 index 000000000..17af2c3a7 --- /dev/null +++ b/src/breadcrumbs/breadcrumbs-base.ts @@ -0,0 +1,27 @@ +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +export const useBreadcrumbs = createHook(({ ...props }) => { + props = { + "aria-label": "breadcrumbs", + ...props, + }; + + return props; +}); + +export const Breadcrumbs = createComponent(props => { + const htmlProps = useBreadcrumbs(props); + + return createElement("nav", htmlProps); +}); + +export type BreadcrumbsOptions = Options & {}; + +export type BreadcrumbsProps = Props< + BreadcrumbsOptions +>; diff --git a/src/breadcrumbs/index.ts b/src/breadcrumbs/index.ts index ce9ce43cc..1797d71ad 100644 --- a/src/breadcrumbs/index.ts +++ b/src/breadcrumbs/index.ts @@ -1,3 +1,2 @@ -export * from "./__keys"; -export * from "./BreadcrumbLink"; -export * from "./Breadcrumbs"; +export * from "./breadcrumb-link"; +export * from "./breadcrumbs-base"; diff --git a/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx b/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx index 54a3984cf..5a88c8647 100644 --- a/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx +++ b/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx @@ -1,13 +1,12 @@ import * as React from "react"; -import { - BreadcrumbLink, - Breadcrumbs as RenderlesskitBreadcrumbs, -} from "../../index"; +import { BreadcrumbLink, Breadcrumbs, BreadcrumbsProps } from "../../index"; -export const Breadcrumbs = () => { +export type BreadcrumbsBasicProps = BreadcrumbsProps & {}; + +export const BreadcrumbsBasic: React.FC = props => { return ( - + @@ -21,7 +20,7 @@ export const Breadcrumbs = () => { Breadcrumb Pattern @@ -33,8 +32,8 @@ export const Breadcrumbs = () => { - + ); }; -export default Breadcrumbs; +export default BreadcrumbsBasic; diff --git a/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx b/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx index 27a4cfbbf..9fffe76a2 100644 --- a/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx +++ b/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx @@ -1,23 +1,23 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import css from "./templates/BreadcrumbsBasicCss"; import js from "./templates/BreadcrumbsBasicJsx"; import ts from "./templates/BreadcrumbsBasicTsx"; -import Breadcrumbs from "./BreadcrumbsBasic.component"; +import { BreadcrumbsBasic } from "./BreadcrumbsBasic.component"; import "./BreadcrumbsBasic.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: Breadcrumbs, title: "Breadcrumbs/Basic", + component: BreadcrumbsBasic, parameters: { layout: "centered", - preview: createPreviewTabs({ js, ts, css }), - options: { showPanel: false }, + preview: createPreviewTabs({ js, ts }), }, } as Meta; -export const Default: Story = args => ; +export const Default: Story = {}; diff --git a/src/calendar/Calendar.ts b/src/calendar/Calendar.ts deleted file mode 100644 index 98ba93a9c..000000000 --- a/src/calendar/Calendar.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendar = createHook({ - name: "Calendar", - compose: useRole, - keys: CALENDAR_KEYS, - - useProps({ calendarId }, htmlProps) { - return { - role: "group", - "aria-labelledby": calendarId, - ...htmlProps, - }; - }, -}); - -export const Calendar = createComponent({ - as: "div", - memo: true, - useHook: useCalendar, -}); - -export type CalendarOptions = RoleOptions & - Pick; - -export type CalendarHTMLProps = RoleHTMLProps; - -export type CalendarProps = CalendarOptions & CalendarHTMLProps; diff --git a/src/calendar/CalendarButton.ts b/src/calendar/CalendarButton.ts deleted file mode 100644 index a74265945..000000000 --- a/src/calendar/CalendarButton.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_BUTTON_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendarButton = createHook< - CalendarButtonOptions, - CalendarButtonHTMLProps ->({ - name: "CalendarButton", - compose: useButton, - keys: CALENDAR_BUTTON_KEYS, - - useProps(options, { onClick: htmlOnClick, ...htmlProps }) { - const { - focusNextMonth, - focusPreviousMonth, - focusPreviousYear, - focusNextYear, - goto, - } = options; - - const HANDLER_TYPES = { - nextMonth: { - handler: focusNextMonth, - ariaLabel: "Next Month", - }, - previousMonth: { - handler: focusPreviousMonth, - ariaLabel: "Previous Month", - }, - nextYear: { - handler: focusNextYear, - ariaLabel: "Next Year", - }, - previousYear: { - handler: focusPreviousYear, - ariaLabel: "Previous Year", - }, - }; - - return { - "aria-label": HANDLER_TYPES[goto]?.ariaLabel, - onClick: callAllHandlers(htmlOnClick, HANDLER_TYPES[goto]?.handler), - ...htmlProps, - }; - }, -}); - -export const CalendarButton = createComponent({ - as: "button", - memo: true, - useHook: useCalendarButton, -}); - -export type CalendarButtonOptions = ButtonOptions & - Pick< - CalendarStateReturn, - | "focusNextMonth" - | "focusPreviousMonth" - | "focusPreviousYear" - | "focusNextYear" - > & { - goto: CalendarGoto; - }; - -export type CalendarButtonHTMLProps = ButtonHTMLProps; - -export type CalendarButtonProps = CalendarButtonOptions & - CalendarButtonHTMLProps; - -export type CalendarGoto = - | "nextMonth" - | "previousMonth" - | "nextYear" - | "previousYear"; diff --git a/src/calendar/CalendarCell.ts b/src/calendar/CalendarCell.ts deleted file mode 100644 index 863ea2bbc..000000000 --- a/src/calendar/CalendarCell.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { useCallback } from "react"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; -import { - ariaAttr, - dataAttr, - getDaysInMonth, - isSameDay, - isWeekend, -} from "../utils"; - -import { CALENDAR_CELL_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; -import { RangeCalendarStateReturn } from "./RangeCalendarState"; - -export const useCalendarCell = createHook< - CalendarCellOptions, - CalendarCellHTMLProps ->({ - name: "CalendarCell", - compose: useRole, - keys: CALENDAR_CELL_KEYS, - - useProps(options, { onMouseEnter: htmlOnMouseEnter, ...htmlProps }) { - const { isDisabled, highlightDate, date } = options; - const onMouseEnter = useCallback(() => { - if (isDisabled) return; - - highlightDate?.(date); - }, [date, highlightDate, isDisabled]); - - return { - role: "gridcell", - "data-weekend": dataAttr(isWeekend(date)), - onMouseEnter: options.isRangeCalendar - ? callAllHandlers(htmlOnMouseEnter, onMouseEnter) - : htmlOnMouseEnter, - ...getCalendarCellProps(options), - ...htmlProps, - }; - }, -}); - -export const CalendarCell = createComponent({ - as: "div", - memo: true, - useHook: useCalendarCell, -}); - -const getCalendarCellProps = (options: CalendarCellOptions) => { - const { date, dateValue, highlightedRange, currentMonth } = options; - - if (options.isRangeCalendar) { - const isSelected = highlightedRange - ? date >= highlightedRange.start && date <= highlightedRange.end - : false; - - const isRangeStart = isSelected && date.getDate() === 1; - const isRangeEnd = - isSelected && date.getDate() === getDaysInMonth(currentMonth); - const isSelectionStart = highlightedRange - ? isSameDay(date, highlightedRange.start) - : false; - const isSelectionEnd = highlightedRange - ? isSameDay(date, highlightedRange.end) - : false; - - return { - "aria-selected": ariaAttr(isSelected), - "data-is-range-selection": dataAttr(isSelected), - "data-is-range-end": dataAttr(isRangeEnd), - "data-is-range-start": dataAttr(isRangeStart), - "data-is-selection-end": dataAttr(isSelectionEnd), - "data-is-selection-start": dataAttr(isSelectionStart), - }; - } - - const isSelected = dateValue ? isSameDay(date, dateValue) : false; - - return { - "aria-selected": ariaAttr(isSelected), - }; -}; - -export type CalendarCellOptions = RoleOptions & - Pick< - CalendarStateReturn, - "dateValue" | "isDisabled" | "currentMonth" | "isRangeCalendar" - > & - Partial< - Pick - > & { - date: Date; - }; - -export type CalendarCellHTMLProps = RoleHTMLProps; - -export type CalendarCellProps = CalendarCellOptions & CalendarCellHTMLProps; diff --git a/src/calendar/CalendarCellButton.ts b/src/calendar/CalendarCellButton.ts deleted file mode 100644 index 0caa8aa42..000000000 --- a/src/calendar/CalendarCellButton.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import * as React from "react"; -import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; -import { ensureFocus, useForkRef } from "reakit-utils"; -import { callAllHandlers } from "@chakra-ui/utils"; -import { useDateFormatter } from "@react-aria/i18n"; - -import { createComponent, createHook } from "../system"; -import { isSameDay } from "../utils"; - -import { CALENDAR_CELL_BUTTON_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; -import { RangeCalendarStateReturn } from "./RangeCalendarState"; - -export const useCalendarCellButton = createHook< - CalendarCellButtonOptions, - CalendarCellButtonHTMLProps ->({ - name: "CalendarCellButton", - compose: useButton, - keys: CALENDAR_CELL_BUTTON_KEYS, - - useOptions(options, { disabled }) { - const { - isDisabled: isDisabledOption, - date, - month, - isInvalidDateRange, - } = options; - const isCurrentMonth = date.getMonth() === month; - const isDisabled = - isDisabledOption || !isCurrentMonth || isInvalidDateRange(date); - const truelyDisabled = disabled || isDisabled; - - return { disabled: truelyDisabled, ...options }; - }, - - useProps( - options, - { onFocus: htmlOnFocus, onClick: htmlOnClick, ref: htmlRef, ...htmlProps }, - ) { - const { - date, - disabled, - dateValue, - selectDate, - anchorDate, - focusedDate, - isDisabled, - setFocusedDate, - isFocused: isFocusedOption, - } = options; - - const ref = React.useRef(null); - const isSelected = dateValue ? isSameDay(date, dateValue) : false; - const isFocused = - isFocusedOption && focusedDate && isSameDay(date, focusedDate); - const isToday = isSameDay(date, new Date()); - - // Focus the button in the DOM when the state updates. - React.useEffect(() => { - if (isFocused && ref.current) { - ensureFocus(ref.current); - } - }, [date, focusedDate, isFocused, ref]); - - const onClick = React.useCallback(() => { - if (disabled) return; - - selectDate(date); - setFocusedDate(date); - }, [date, disabled, selectDate, setFocusedDate]); - - const onFocus = React.useCallback(() => { - if (disabled) return; - - setFocusedDate(date); - }, [date, disabled, setFocusedDate]); - - const dateFormatter = useDateFormatter({ - weekday: "long", - day: "numeric", - month: "long", - year: "numeric", - }); - - // aria-label should be localize Day of week, Month, Day and Year without Time. - function getAriaLabel() { - let ariaLabel = dateFormatter.format(date); - const isTodayLabel = isToday ? "Today, " : ""; - const isSelctedLabel = isSelected ? " selected" : ""; - ariaLabel = `${isTodayLabel}${ariaLabel}${isSelctedLabel}`; - - // When a cell is focused and this is a range calendar, add a prompt to help - // screenreader users know that they are in a range selection mode. - if (options.isRangeCalendar && isFocused && !isDisabled) { - let rangeSelectionPrompt = ""; - - // If selection has started add "click to finish selecting range" - if (anchorDate) { - rangeSelectionPrompt = "click to finish selecting range"; - // Otherwise, add "click to start selecting range" prompt - } else { - rangeSelectionPrompt = "click to start selecting range"; - } - - // Append to aria-label - if (rangeSelectionPrompt) { - ariaLabel = `${ariaLabel} (${rangeSelectionPrompt})`; - } - } - - return ariaLabel; - } - - return { - children: useDateFormatter({ day: "numeric" }).format(date), - "aria-label": getAriaLabel(), - tabIndex: !disabled ? (isSameDay(date, focusedDate) ? 0 : -1) : undefined, - ref: useForkRef(ref, htmlRef), - onClick: callAllHandlers(htmlOnClick, onClick), - onFocus: callAllHandlers(htmlOnFocus, onFocus), - ...htmlProps, - }; - }, -}); - -export const CalendarCellButton = createComponent({ - as: "span", - memo: true, - useHook: useCalendarCellButton, -}); - -export type CalendarCellButtonOptions = ButtonOptions & - Partial> & - Pick< - CalendarStateReturn, - | "focusedDate" - | "selectDate" - | "setFocusedDate" - | "isDisabled" - | "month" - | "dateValue" - | "isFocused" - | "isRangeCalendar" - | "isInvalidDateRange" - > & { - date: Date; - }; - -export type CalendarCellButtonHTMLProps = ButtonHTMLProps; - -export type CalendarCellButtonProps = CalendarCellButtonOptions & - CalendarCellButtonHTMLProps; diff --git a/src/calendar/CalendarGrid.ts b/src/calendar/CalendarGrid.ts deleted file mode 100644 index 7658fc400..000000000 --- a/src/calendar/CalendarGrid.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useWeekStart](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/calendar/src/useWeekStart.ts) - * to work with Reakit System - */ -import { KeyboardEvent, useRef } from "react"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { createOnKeyDown, useForkRef } from "reakit-utils"; -import { callAllHandlers } from "@chakra-ui/utils"; -import { chain } from "@react-aria/utils"; - -import { createComponent, createHook } from "../system"; -import { ariaAttr } from "../utils"; - -import { CALENDAR_GRID_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; -import { RangeCalendarStateReturn } from "./RangeCalendarState"; - -export const useCalendarGrid = createHook< - CalendarGridOptions, - CalendarGridHTMLProps ->({ - name: "CalendarGrid", - compose: useRole, - keys: CALENDAR_GRID_KEYS, - - useProps( - options, - { - ref: htmlRef, - onKeyDown: htmlOnKeyDown, - onBlur: htmlOnFocus, - onBlur: htmlOnBlur, - ...htmlProps - }, - ) { - const { - isReadOnly, - isDisabled, - setFocused, - selectFocusedDate, - focusPreviousYear, - focusPreviousMonth, - focusNextYear, - focusNextMonth, - focusEndOfMonth, - focusStartOfMonth, - focusNextDay, - focusPreviousDay, - focusNextWeek, - focusPreviousWeek, - calendarId, - setAnchorDate, - } = options; - const ref = useRef(null); - - const onKeyDown = createOnKeyDown({ - onKeyDown: htmlOnKeyDown, - preventDefault: true, - keyMap: (event: KeyboardEvent) => { - const shift = event.shiftKey; - - return { - " ": selectFocusedDate, - Enter: selectFocusedDate, - End: focusEndOfMonth, - Home: focusStartOfMonth, - ArrowLeft: focusPreviousDay, - ArrowUp: focusPreviousWeek, - ArrowRight: focusNextDay, - ArrowDown: focusNextWeek, - PageUp: () => { - shift ? focusPreviousYear() : focusPreviousMonth(); - }, - PageDown: () => { - shift ? focusNextYear() : focusNextMonth(); - }, - }; - }, - }); - - let rangeCalendarProps = {}; - - if (options.isRangeCalendar) { - const onRangeKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case "Escape": - // Cancel the selection. - setAnchorDate?.(null); - break; - } - }; - - rangeCalendarProps = { - "aria-multiselectable": true, - onKeyDown: callAllHandlers( - htmlOnKeyDown, - chain(onKeyDown, onRangeKeyDown), - ), - }; - } - - return { - ref: useForkRef(ref, htmlRef), - role: "grid", - "aria-labelledby": calendarId, - "aria-readonly": ariaAttr(isReadOnly), - "aria-disabled": ariaAttr(isDisabled), - onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), - onFocus: callAllHandlers(htmlOnFocus, () => setFocused(true)), - onBlur: callAllHandlers(htmlOnBlur, () => setFocused(false)), - ...rangeCalendarProps, - ...htmlProps, - }; - }, -}); - -export const CalendarGrid = createComponent({ - as: "div", - memo: true, - useHook: useCalendarGrid, -}); - -export type CalendarGridOptions = RoleOptions & - Pick< - CalendarStateReturn, - | "calendarId" - | "isReadOnly" - | "isDisabled" - | "setFocused" - | "selectFocusedDate" - | "focusPreviousYear" - | "focusPreviousMonth" - | "focusNextYear" - | "focusNextMonth" - | "focusEndOfMonth" - | "focusStartOfMonth" - | "focusNextDay" - | "focusPreviousDay" - | "focusNextWeek" - | "focusPreviousWeek" - | "isRangeCalendar" - > & - Partial>; - -export type CalendarGridHTMLProps = RoleHTMLProps; - -export type CalendarGridProps = CalendarGridOptions & CalendarGridHTMLProps; diff --git a/src/calendar/CalendarHeader.ts b/src/calendar/CalendarHeader.ts deleted file mode 100644 index 071f959ee..000000000 --- a/src/calendar/CalendarHeader.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { useDateFormatter } from "@react-aria/i18n"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_HEADER_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendarHeader = createHook< - CalendarHeaderOptions, - CalendarHeaderHTMLProps ->({ - name: "CalendarHeader", - compose: useRole, - keys: CALENDAR_HEADER_KEYS, - - useProps( - { format = { month: "long", year: "numeric" }, currentMonth, calendarId }, - htmlProps, - ) { - return { - id: calendarId, - children: useDateFormatter(format).format(currentMonth), - "aria-live": "polite", - ...htmlProps, - }; - }, -}); - -export const CalendarHeader = createComponent({ - as: "h2", - memo: true, - useHook: useCalendarHeader, -}); - -export type CalendarHeaderOptions = RoleOptions & - Pick & { - format?: Intl.DateTimeFormatOptions; - }; - -export type CalendarHeaderHTMLProps = RoleHTMLProps; - -export type CalendarHeaderProps = CalendarHeaderOptions & - CalendarHeaderHTMLProps; diff --git a/src/calendar/CalendarState.ts b/src/calendar/CalendarState.ts deleted file mode 100644 index 22e2a14b2..000000000 --- a/src/calendar/CalendarState.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useCalendarState](https://github.com/adobe/react-spectrum/tree/main/packages/%40react-stately/calendar) - * to work with Reakit System - */ - -import * as React from "react"; -import { unstable_useId as useId } from "reakit"; -import { useUpdateEffect } from "@chakra-ui/hooks"; -import { useDateFormatter } from "@react-aria/i18n"; -import { InputBase } from "@react-types/shared"; - -import { - addDays, - addMonths, - addWeeks, - addYears, - endOfMonth, - getDaysInMonth, - isSameMonth, - startOfDay, - startOfMonth, - subDays, - subMonths, - subWeeks, - subYears, - toUTCString, - useControllableState, -} from "../utils"; -import { announce } from "../utils/LiveAnnouncer"; - -import { generateDaysInMonthArray, useWeekDays, useWeekStart } from "./helpers"; - -export function useCalendarState( - props: CalendarInitialState = {}, -): CalendarStateReturn { - const { - value: initialValue, - defaultValue = toUTCString(new Date()), - onChange, - minValue, - maxValue, - isDisabled = false, - isReadOnly = false, - autoFocus = false, - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const date = React.useMemo(() => new Date(value), [value]); - const minDateValue = React.useMemo( - () => (minValue ? new Date(minValue) : new Date(-864e13)), - [minValue], - ); - const maxDateValue = React.useMemo( - () => (maxValue ? new Date(maxValue) : new Date(864e13)), - [maxValue], - ); - const [currentMonth, setCurrentMonth] = React.useState(date); - const [focusedDate, setFocusedDate] = React.useState(date); - const [isFocused, setFocused] = React.useState(autoFocus); - const month = currentMonth.getMonth(); - const year = currentMonth.getFullYear(); - const weekStart = useWeekStart(); - const weekDays = useWeekDays(weekStart); - - let monthStartsAt = (startOfMonth(currentMonth).getDay() - weekStart) % 7; - if (monthStartsAt < 0) { - monthStartsAt += 7; - } - - const days = getDaysInMonth(currentMonth); - const weeksInMonth = Math.ceil((monthStartsAt + days) / 7); - - // Get 2D Date arrays in 7 days a week format - const daysInMonth = React.useMemo( - () => generateDaysInMonthArray(month, monthStartsAt, weeksInMonth, year), - [month, monthStartsAt, weeksInMonth, year], - ); - - function isInvalidDateRange(value: Date) { - const min = new Date(minDateValue); - const max = new Date(maxDateValue); - - return value < min || value > max; - } - - // Sets focus to a specific cell date - function focusCell(date: Date) { - if (isInvalidDateRange(date)) return; - - if (!isSameMonth(date, currentMonth)) { - setCurrentMonth(startOfMonth(date)); - } - - setFocusedDate(date); - } - - const dateFormatter = useDateFormatter({ dateStyle: "full" }); - - const announceSelectedDate = React.useCallback( - (value: Date) => { - if (!value) return; - - announce(`Selected Date: ${dateFormatter.format(value)}`); - }, - [dateFormatter], - ); - - const setDate = React.useCallback( - (value: Date) => { - if (!isDisabled && !isReadOnly) { - setValue(toUTCString(value)); - announceSelectedDate(value); - } - }, - [announceSelectedDate, isDisabled, isReadOnly, setValue], - ); - - // TODO - // This runs only once when the component is mounted - // Controlled state doesn't change the claender position - // React.useEffect(() => { - // const clampedDate = clamp(date, { - // start: minDateValue, - // end: maxDateValue, - // }); - // setDate(clampedDate); - // setCurrentMonth(clampedDate); - // setFocusedDate(clampedDate); - // }, [date, maxDateValue, minDateValue, setDate]); - - const monthFormatter = useDateFormatter({ month: "long", year: "numeric" }); - - // Announce when the current month changes - useUpdateEffect(() => { - // announce the new month with a change from the Previous or Next button - if (!isFocused) { - announce(monthFormatter.format(currentMonth)); - } - // handle an update to the current month from the Previous or Next button - // rather than move focus, we announce the new month value - }, [currentMonth]); - - const { id: calendarId } = useId({ id: props.id, baseId: "calendar" }); - - return { - dateValue: date, - setDateValue: setDate, - calendarId, - month, - year, - weekStart, - weekDays, - daysInMonth, - isDisabled, - isFocused, - isReadOnly, - setFocused, - currentMonth, - setCurrentMonth, - focusedDate, - focusCell, - setFocusedDate, - focusNextDay() { - focusCell(addDays(focusedDate, 1)); - }, - focusPreviousDay() { - focusCell(subDays(focusedDate, 1)); - }, - focusNextWeek() { - focusCell(addWeeks(focusedDate, 1)); - }, - focusPreviousWeek() { - focusCell(subWeeks(focusedDate, 1)); - }, - focusNextMonth() { - focusCell(addMonths(focusedDate, 1)); - }, - focusPreviousMonth() { - focusCell(subMonths(focusedDate, 1)); - }, - focusStartOfMonth() { - focusCell(startOfMonth(focusedDate)); - }, - focusEndOfMonth() { - focusCell(endOfMonth(startOfDay(focusedDate))); - }, - focusNextYear() { - focusCell(addYears(focusedDate, 1)); - }, - focusPreviousYear() { - focusCell(subYears(focusedDate, 1)); - }, - selectFocusedDate() { - setDate(focusedDate); - }, - selectDate(date: Date) { - setDate(date); - }, - isInvalidDateRange, - isRangeCalendar: false, - }; -} - -export type CalendarState = { - /** - * Id for the Calendar Header - */ - calendarId: string | undefined; - /** - * Selected Date value - */ - dateValue: Date; - /** - * Month of the current date value - */ - month: number; - /** - * Year of the current date value - */ - year: number; - /** - * Start of the week for the current date value - */ - weekStart: number; - /** - * Generated week days for CalendarWeekTitle based on weekStart - */ - weekDays: { - title: string; - abbr: string; - }[]; - /** - * Generated days in the current month - */ - daysInMonth: Date[][]; - /** - * `true` if the calendar is disabled - */ - isDisabled: boolean; - /** - * `true` if the calendar is focused - */ - isFocused: boolean; - /** - * `true` if the calendar is only readonly - */ - isReadOnly: boolean; - /** - * Month of the current Date - */ - currentMonth: Date; - /** - * Date value that is currently focused - */ - focusedDate: Date; - /** - * Informs if the given date is within the min & max date. - */ - isInvalidDateRange: (value: Date) => boolean; - /** - * `true` if the calendar is used as RangeCalendar - */ - isRangeCalendar: boolean; -}; - -export type CalendarActions = { - /** - * Sets `isFocused` - */ - setFocused: React.Dispatch>; - /** - * Sets `currentMonth` - */ - setCurrentMonth: React.Dispatch>; - /** - * Sets `focusedDate` - */ - setFocusedDate: React.Dispatch>; - /** - * Sets `dateValue` - */ - setDateValue: (value: Date) => void; - /** - * Focus the cell of the specified date - */ - focusCell: (value: Date) => void; - /** - * Focus the cell next to the current date - */ - focusNextDay: () => void; - /** - * Focus the cell prev to the current date - */ - focusPreviousDay: () => void; - /** - * Focus the cell one week next to the current date - */ - focusNextWeek: () => void; - /** - * Focus the cell one week prev to the current date - */ - focusPreviousWeek: () => void; - /** - * Focus the cell one month next to the current date - */ - focusNextMonth: () => void; - /** - * Focus the cell one month prev to the current date - */ - focusPreviousMonth: () => void; - /** - * Focus the cell of the first day of the month - */ - focusStartOfMonth: () => void; - /** - * Focus the cell of the last day of the month - */ - focusEndOfMonth: () => void; - /** - * Focus the cell of the date one year from the current date - */ - focusNextYear: () => void; - /** - * Focus the cell of the date one year before the current date - */ - focusPreviousYear: () => void; - /** - * Selects the `focusedDate` - */ - selectFocusedDate: () => void; - /** - * sets `dateValue` - */ - selectDate: (value: Date) => void; -}; - -type ValueBase = { - /** The current date (controlled). */ - value?: string; - /** The default date (uncontrolled). */ - defaultValue?: string; - /** Handler that is called when the date changes. */ - onChange?: (value: string) => void; -}; - -type RangeValueMinMax = { - /** The lowest date allowed. */ - minValue?: string; - /** The highest date allowed. */ - maxValue?: string; -}; - -export type CalendarInitialState = ValueBase & - RangeValueMinMax & - InputBase & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - /** - * Id for the calendar grid - */ - id?: string; - }; - -export type CalendarStateReturn = CalendarState & CalendarActions; diff --git a/src/calendar/CalendarWeekTitle.ts b/src/calendar/CalendarWeekTitle.ts deleted file mode 100644 index 8276c7750..000000000 --- a/src/calendar/CalendarWeekTitle.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_WEEK_TITLE_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendarWeekTitle = createHook< - CalendarWeekTitleOptions, - CalendarWeekTitleHTMLProps ->({ - name: "CalendarWeekTitle", - compose: useRole, - keys: CALENDAR_WEEK_TITLE_KEYS, - - useProps({ dayIndex, weekDays }, htmlProps) { - return { - "aria-label": weekDays[dayIndex]?.title, - ...htmlProps, - }; - }, -}); - -export const CalendarWeekTitle = createComponent({ - as: "div", - memo: true, - useHook: useCalendarWeekTitle, -}); - -export type CalendarWeekTitleOptions = RoleOptions & - Pick & { - dayIndex: number; - }; - -export type CalendarWeekTitleHTMLProps = RoleHTMLProps; - -export type CalendarWeekTitleProps = CalendarWeekTitleOptions & - CalendarWeekTitleHTMLProps; diff --git a/src/calendar/RangeCalendarState.ts b/src/calendar/RangeCalendarState.ts deleted file mode 100644 index 60bce086c..000000000 --- a/src/calendar/RangeCalendarState.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useRangeCalendar](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useRangeCalendar.ts) - * to work with Reakit System - */ -import * as React from "react"; -import { useDateFormatter } from "@react-aria/i18n"; -import { InputBase, RangeValue } from "@react-types/shared"; - -import { - addDays, - isSameDay, - toUTCRangeString, - toUTCString, - useControllableState, -} from "../utils"; -import { announce } from "../utils/LiveAnnouncer"; - -import { - CalendarActions, - CalendarState, - useCalendarState, -} from "./CalendarState"; -import { makeRange } from "./helpers"; - -export function useRangeCalendarState( - props: RangeCalendarInitialState = {}, -): RangeCalendarStateReturn { - const { - value: initialValue, - defaultValue = { - start: toUTCString(new Date()), - end: toUTCString(addDays(new Date(), 1)), - }, - onChange, - ...calendarProps - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const dateRange: RangeValue = React.useMemo( - () => ({ - start: new Date(value.start), - end: new Date(value.end), - }), - [value.end, value.start], - ); - const [anchorDate, setAnchorDate] = React.useState(null); - const [lastSelectedDate, setLastSelectedDate] = React.useState( - dateRange.end, - ); - const calendar = useCalendarState({ - ...calendarProps, - value: toUTCString(lastSelectedDate), - }); - - const highlightedRange = anchorDate - ? makeRange(anchorDate, calendar.focusedDate) - : value && dateRange && makeRange(dateRange.start, dateRange.end); - - const dateFormatter = useDateFormatter({ dateStyle: "full" }); - - const announceRange = React.useCallback(() => { - if (!highlightedRange) return; - - if (isSameDay(highlightedRange.start, highlightedRange.end)) { - announce( - `Selected range, from ${dateFormatter.format( - highlightedRange.start, - )} to ${dateFormatter.format(highlightedRange.start)}`, - ); - } else { - announce( - `Selected range from ${dateFormatter.format( - highlightedRange.start, - )} to ${dateFormatter.format(highlightedRange.start)}`, - ); - } - }, [dateFormatter, highlightedRange]); - - const selectDate = React.useCallback( - (date: Date) => { - if (props.isReadOnly) return; - - setLastSelectedDate(date); - if (!anchorDate) { - setAnchorDate(date); - announce(`Starting range from ${dateFormatter.format(date)}`); - } else { - setValue(toUTCRangeString(makeRange(anchorDate, date))); - announceRange(); - setAnchorDate(null); - } - }, - [anchorDate, announceRange, dateFormatter, props.isReadOnly, setValue], - ); - - const setDateValue = React.useCallback( - (value: RangeValue) => { - setValue(toUTCRangeString(value)); - }, - [setValue], - ); - - return { - ...calendar, - dateRangeValue: dateRange, - setDateRangeValue: setDateValue, - anchorDate, - setAnchorDate, - highlightedRange, - selectDate, - selectFocusedDate() { - selectDate(calendar.focusedDate); - }, - highlightDate(date: Date) { - if (!anchorDate) return; - calendar.setFocusedDate(date); - }, - isRangeCalendar: true, - }; -} - -export type RangeCalendarState = CalendarState & { - dateRangeValue: RangeValue | null; - anchorDate: Date | null; - highlightedRange: RangeValue | null; - isRangeCalendar: boolean; -}; - -export type RangeCalendarActions = CalendarActions & { - setDateRangeValue: (value: RangeValue) => void; - setAnchorDate: React.Dispatch>; - selectDate: (date: Date) => void; - selectFocusedDate: () => void; - highlightDate: (date: Date) => void; -}; - -type Range = { - /** The start value of the range. */ - start: string; - /** The end value of the range. */ - end: string; -}; - -type RangeValueBase = { - /** The current value (controlled). */ - value?: Range; - /** The default value (uncontrolled). */ - defaultValue?: Range; - /** Handler that is called when the value changes. */ - onChange?: (value: Range) => void; -}; - -type RangeValueMinMax = { - /** The smallest value allowed. */ - minValue?: string; - /** The largest value allowed. */ - maxValue?: string; -}; - -export type RangeCalendarInitialState = RangeValueBase & - RangeValueMinMax & - InputBase & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - /** - * Id for the calendar grid - */ - id?: string; - }; - -export type RangeCalendarStateReturn = RangeCalendarState & - RangeCalendarActions; diff --git a/src/calendar/__keys.ts b/src/calendar/__keys.ts deleted file mode 100644 index 0666156d3..000000000 --- a/src/calendar/__keys.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Automatically generated -export const USE_CALENDAR_STATE_KEYS = [ - "value", - "defaultValue", - "onChange", - "minValue", - "maxValue", - "isDisabled", - "isReadOnly", - "autoFocus", - "id", -] as const; -export const CALENDAR_STATE_KEYS = [ - "calendarId", - "dateValue", - "month", - "year", - "weekStart", - "weekDays", - "daysInMonth", - "isDisabled", - "isFocused", - "isReadOnly", - "currentMonth", - "focusedDate", - "isInvalidDateRange", - "isRangeCalendar", - "setFocused", - "setCurrentMonth", - "setFocusedDate", - "setDateValue", - "focusCell", - "focusNextDay", - "focusPreviousDay", - "focusNextWeek", - "focusPreviousWeek", - "focusNextMonth", - "focusPreviousMonth", - "focusStartOfMonth", - "focusEndOfMonth", - "focusNextYear", - "focusPreviousYear", - "selectFocusedDate", - "selectDate", -] as const; -export const USE_RANGE_CALENDAR_STATE_KEYS = USE_CALENDAR_STATE_KEYS; -export const RANGE_CALENDAR_STATE_KEYS = [ - ...CALENDAR_STATE_KEYS, - "dateRangeValue", - "anchorDate", - "highlightedRange", - "setDateRangeValue", - "setAnchorDate", - "highlightDate", -] as const; -export const CALENDAR_KEYS = RANGE_CALENDAR_STATE_KEYS; -export const CALENDAR_BUTTON_KEYS = [...CALENDAR_KEYS, "goto"] as const; -export const CALENDAR_CELL_KEYS = [...CALENDAR_KEYS, "date"] as const; -export const CALENDAR_CELL_BUTTON_KEYS = CALENDAR_CELL_KEYS; -export const CALENDAR_GRID_KEYS = CALENDAR_KEYS; -export const CALENDAR_HEADER_KEYS = [...CALENDAR_GRID_KEYS, "format"] as const; -export const CALENDAR_WEEK_TITLE_KEYS = [ - ...CALENDAR_GRID_KEYS, - "dayIndex", -] as const; diff --git a/src/calendar/__tests__/Calendar.test.tsx b/src/calendar/__tests__/Calendar.test.tsx deleted file mode 100644 index 84d2c6e8f..000000000 --- a/src/calendar/__tests__/Calendar.test.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/* eslint-disable testing-library/prefer-explicit-assert */ -import * as React from "react"; -import { axe, press, render, screen } from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; -import MockDate from "mockdate"; - -import { repeat } from "../../utils/test-utils"; -import { - Calendar as CalendarWrapper, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarInitialState, - CalendarWeekTitle, - useCalendarState, -} from "../index"; - -export const CalendarComp: React.FC = props => { - const state = useCalendarState(props); - - return ( - - - - previous year - - - previous month - - - - next month - - - next year - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week: any[], weekIndex: React.Key) => ( - - {week.map((day: Date, dayIndex: React.Key) => ( - - - - ))} - - ))} - - - - ); -}; - -beforeEach(() => { - // You SHALL Freeze 🧙 - MockDate.set(new Date(2020, 9, 29)); -}); - -afterEach(() => { - cleanup(); - MockDate.reset(); -}); - -describe("Calendar", () => { - it("should render correctly", () => { - const { getByTestId: testId } = render(); - - expect(testId("testid-weekDays").children).toHaveLength(7); - expect(testId("testid-current-year")).toHaveTextContent(/^october 2020$/i); - }); - - it("should have proper calendar header keyboard navigation", () => { - render(); - - const currentYear = screen.getByTestId("testid-current-year"); - const { getByText: text } = screen; - - expect(currentYear).toHaveTextContent(/^october 2020$/i); - press.Tab(); - press.Enter(); - expect(text(/previous year/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^october 2019$/i); - - press.Tab(); - press.Enter(); - expect(text(/previous month/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^september 2019$/i); - - press.Tab(); - press.Enter(); - expect(text(/next month/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^october 2019$/i); - - press.Tab(); - press.Enter(); - expect(text(/next year/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^october 2020$/i); - }); - - it("should proper grid navigation", () => { - render(); - const currentYear = screen.getByTestId("testid-current-year"); - - const { getByLabelText: label } = screen; - - expect(currentYear).toHaveTextContent(/^october 2020$/i); - - // Tab to go inside the calendar dates - repeat(press.Tab, 5); - expect(label(/wednesday, october 7, 2020 selected/i)).toHaveFocus(); - - // Let's navigate to 30 - repeat(press.ArrowDown, 2); - repeat(press.ArrowRight, 2); - press.ArrowDown(); - - expect(label(/^friday, october 30, 2020$/i)).toHaveFocus(); - - // Let's go to next month - press.ArrowDown(); - expect(label(/^friday, november 6, 2020$/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^november 2020$/i); - - // Grid navigation pageup/down - press.PageUp(); - expect(currentYear).toHaveTextContent(/^october 2020$/i); - - press.PageUp(null, { shiftKey: true }); - expect(currentYear).toHaveTextContent(/^october 2019$/i); - }); - - test("should have min/max values", () => { - render( - , - ); - const { getByLabelText: label } = screen; - - // Tab to go inside the calendar dates - repeat(press.Tab, 5); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - // try to go outside the min max value - repeat(press.ArrowUp, 4); - expect(label(/^saturday, october 31, 2020$/i)).toHaveFocus(); - - repeat(press.ArrowDown, 3); - expect(label(/^saturday, november 14, 2020$/i)).toHaveFocus(); - }); - - test("should be able to go to prev/next month when min/max values are set", () => { - render( - , - ); - const { getByLabelText: label } = screen; - - // Tab to go inside the calendar dates - repeat(press.Tab, 5); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - // Should not be able to go to next/prev months - press.PageUp(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageDown(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - // Should not be able to go to next/prev years - press.PageDown(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - }); - - test("should not be able to go to prev/next year when min/max values are set", () => { - render( - , - ); - - const { getByLabelText: label } = screen; - - repeat(press.Tab, 5); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageDown(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageDown(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - }); - - it("should have proper aria-label for calendar cell button", () => { - MockDate.set("2020-11-07"); - render(); - - screen.getByRole("button", { - name: /^today, saturday, november 7, 2020 selected$/i, - }); - - repeat(press.Tab, 5); - press.ArrowRight(); - press.Enter(); - screen.getByRole("button", { - name: /sunday, november 8, 2020 selected/i, - }); - - repeat(press.ArrowLeft, 2); - press.Enter(); - screen.getByRole("button", { - name: /friday, november 6, 2020 selected/i, - }); - - MockDate.reset(); - }); - - test("Calendar renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/calendar/__tests__/RangeCalendar.test.tsx b/src/calendar/__tests__/RangeCalendar.test.tsx deleted file mode 100644 index a703d0b16..000000000 --- a/src/calendar/__tests__/RangeCalendar.test.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import * as React from "react"; -import { axe, press, render } from "reakit-test-utils"; -import { cleanup, screen } from "@testing-library/react"; -import MockDate from "mockdate"; - -import { announce, destroyAnnouncer } from "../../utils/LiveAnnouncer"; -import { - isEndSelection, - isStartSelection, - repeat, -} from "../../utils/test-utils"; -import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - RangeCalendarInitialState, - useRangeCalendarState, -} from "../index"; - -jest.mock("../../utils/LiveAnnouncer"); - -afterEach(cleanup); - -beforeEach(() => { - destroyAnnouncer(); -}); - -const RangeCalendarComp: React.FC = props => { - const state = useRangeCalendarState(props); - - return ( - - - - previous year - - - previous month - - - - next month - - - next year - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - - ); -}; - -describe("RangeCalendar", () => { - it("should render correctly", () => { - const { getByTestId: testId } = render( - , - ); - - expect(testId("testid-week-days").children).toHaveLength(7); - expect(testId("testid-current-year")).toHaveTextContent("October 2020"); - }); - - it("should have proper initial start and end ranges", () => { - const { baseElement } = render( - , - ); - - const start = baseElement.querySelector("[data-is-selection-start]"); - // If anyone is reading this code from future - // Note that this will fail again on 15th october 2050. - const anyMiddleDate = screen.getByLabelText(/Saturday, October 15, 2050/); - const end = baseElement.querySelector("[data-is-selection-end]"); - - expect(start).toHaveTextContent("7"); - expect(anyMiddleDate.parentElement).toHaveAttribute( - "data-is-range-selection", - ); - expect(end).toHaveTextContent("30"); - }); - - it("should announce selected range after finishing selection", () => { - const { getByLabelText: label } = render( - , - ); - - repeat(press.Tab, 5); - expect(label(/Wednesday, October 30, 2019 selected/)).toHaveFocus(); - - press.ArrowUp(); - press.ArrowRight(); - press.Enter(label(/Thursday, October 24, 2019/)); - - expect(announce).toHaveBeenLastCalledWith( - "Starting range from Thursday, October 24, 2019", - ); - - press.ArrowRight(); - press.Enter(label(/Friday, October 25, 2019/)); - - expect(announce).toHaveBeenLastCalledWith( - "Selected range from Thursday, October 24, 2019 to Thursday, October 24, 2019", - ); - expect(announce).toHaveBeenCalledTimes(2); - }); - - it("should be able to select ranges with keyboard navigation", () => { - MockDate.set("2020-10-07"); - const { baseElement } = render( - , - ); - - expect(screen.getByTestId("testid-current-year")).toHaveTextContent( - /October 2020/i, - ); - - repeat(press.Tab, 5); - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 selected \(click to start selecting range\)$/, - ), - ).toHaveFocus(); - - press.ArrowUp(); // go to down just for some variety - press.Enter(); // start the selection, currently the start and end should be the same date - expect( - baseElement.querySelector("[data-is-selection-start]"), - ).toHaveTextContent("23"); - press.ArrowDown(); - expect( - baseElement.querySelector("[data-is-selection-end]"), - ).toHaveTextContent("30"); - - // finish the selection - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 \(click to finish selecting range\)$/, - ), - ).toHaveFocus(); - - // check if the selection is actually finished or not - press.Enter(); - const selectedDate = screen.getByLabelText( - /^Friday, October 30, 2020 selected \(click to start selecting range\)$/, - ); - expect(selectedDate).toHaveFocus(); - expect(selectedDate?.parentElement).toHaveAttribute( - "data-is-range-selection", - ); - - press.ArrowRight(); - const nextDate = screen.getByLabelText( - /^Saturday, October 31, 2020 \(click to start selecting range\)$/, - ); - expect(nextDate).toHaveFocus(); - expect(nextDate?.parentElement).not.toHaveAttribute( - "data-is-range-selection", - ); - - // Verify selection ranges - const end = baseElement.querySelector("[data-is-selection-end]"); - expect(end).toHaveTextContent("30"); - - const start = baseElement.querySelector("[data-is-selection-start]"); - expect(start).toHaveTextContent("23"); - }); - - it("should be able to cancel selection", () => { - render( - , - ); - - expect(screen.getByTestId("testid-current-year")).toHaveTextContent( - /October 2020/i, - ); - - repeat(press.Tab, 5); - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 selected \(click to start selecting range\)$/, - ), - ).toHaveFocus(); - - press.ArrowUp(); - press.Enter(); // start the selection - - // Now we choose the end date, let's choose 30 - press.ArrowDown(); - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 \(click to finish selecting range\)$/i, - ), - ).toHaveFocus(); - - press.Escape(); - isStartSelection(screen.getByLabelText(/Wednesday, October 7, 2020/)); - isEndSelection(screen.getByLabelText(/Friday, October 30, 2020/)); - }); - - test("RangeCalendar renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/calendar/__tests__/__snapshots__/utils.test.tsx.snap b/src/calendar/__tests__/__snapshots__/utils.test.tsx.snap deleted file mode 100644 index b503da53a..000000000 --- a/src/calendar/__tests__/__snapshots__/utils.test.tsx.snap +++ /dev/null @@ -1,135 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Calendar Utils generateDaysInMonthArray 1`] = ` -Array [ - Array [ - 2020-02-01T00:00:00.000Z, - 2020-02-02T00:00:00.000Z, - 2020-02-03T00:00:00.000Z, - 2020-02-04T00:00:00.000Z, - 2020-02-05T00:00:00.000Z, - 2020-02-06T00:00:00.000Z, - 2020-02-07T00:00:00.000Z, - ], - Array [ - 2020-02-08T00:00:00.000Z, - 2020-02-09T00:00:00.000Z, - 2020-02-10T00:00:00.000Z, - 2020-02-11T00:00:00.000Z, - 2020-02-12T00:00:00.000Z, - 2020-02-13T00:00:00.000Z, - 2020-02-14T00:00:00.000Z, - ], - Array [ - 2020-02-15T00:00:00.000Z, - 2020-02-16T00:00:00.000Z, - 2020-02-17T00:00:00.000Z, - 2020-02-18T00:00:00.000Z, - 2020-02-19T00:00:00.000Z, - 2020-02-20T00:00:00.000Z, - 2020-02-21T00:00:00.000Z, - ], - Array [ - 2020-02-22T00:00:00.000Z, - 2020-02-23T00:00:00.000Z, - 2020-02-24T00:00:00.000Z, - 2020-02-25T00:00:00.000Z, - 2020-02-26T00:00:00.000Z, - 2020-02-27T00:00:00.000Z, - 2020-02-28T00:00:00.000Z, - ], - Array [ - 2020-02-29T00:00:00.000Z, - 2020-03-01T00:00:00.000Z, - 2020-03-02T00:00:00.000Z, - 2020-03-03T00:00:00.000Z, - 2020-03-04T00:00:00.000Z, - 2020-03-05T00:00:00.000Z, - 2020-03-06T00:00:00.000Z, - ], - Array [ - 2020-03-07T00:00:00.000Z, - 2020-03-08T00:00:00.000Z, - 2020-03-09T00:00:00.000Z, - 2020-03-10T00:00:00.000Z, - 2020-03-11T00:00:00.000Z, - 2020-03-12T00:00:00.000Z, - 2020-03-13T00:00:00.000Z, - ], - Array [ - 2020-03-14T00:00:00.000Z, - 2020-03-15T00:00:00.000Z, - 2020-03-16T00:00:00.000Z, - 2020-03-17T00:00:00.000Z, - 2020-03-18T00:00:00.000Z, - 2020-03-19T00:00:00.000Z, - 2020-03-20T00:00:00.000Z, - ], -] -`; - -exports[`Calendar Utils useWeekDays 1`] = ` -Array [ - Object { - "abbr": "Sun", - "title": "Sunday", - }, - Object { - "abbr": "Mon", - "title": "Monday", - }, - Object { - "abbr": "Tue", - "title": "Tuesday", - }, - Object { - "abbr": "Wed", - "title": "Wednesday", - }, - Object { - "abbr": "Thu", - "title": "Thursday", - }, - Object { - "abbr": "Fri", - "title": "Friday", - }, - Object { - "abbr": "Sat", - "title": "Saturday", - }, -] -`; - -exports[`Calendar Utils useWeekDays 2`] = ` -Array [ - Object { - "abbr": "Tue", - "title": "Tuesday", - }, - Object { - "abbr": "Wed", - "title": "Wednesday", - }, - Object { - "abbr": "Thu", - "title": "Thursday", - }, - Object { - "abbr": "Fri", - "title": "Friday", - }, - Object { - "abbr": "Sat", - "title": "Saturday", - }, - Object { - "abbr": "Sun", - "title": "Sunday", - }, - Object { - "abbr": "Mon", - "title": "Monday", - }, -] -`; diff --git a/src/calendar/__tests__/utils.test.tsx b/src/calendar/__tests__/utils.test.tsx deleted file mode 100644 index 963a84f5a..000000000 --- a/src/calendar/__tests__/utils.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; -import MockDate from "mockdate"; - -import { generateDaysInMonthArray, makeRange, useWeekDays } from "../helpers"; - -describe("Calendar Utils", () => { - test("makeRange", () => { - const range = makeRange( - new Date(1999, 4, 4, 0, 0), - new Date(2020, 4, 4, 0, 0), - ); - expect(range.start).toMatchInlineSnapshot(`1999-05-03T18:30:00.000Z`); - expect(range.end).toMatchInlineSnapshot(`2020-05-03T18:30:00.000Z`); - }); - - test("useWeekDays", () => { - // MIND THE BLOCK SCOPE! - { - const { - result: { current }, - } = renderHook(() => useWeekDays(0)); - - expect(current).toMatchSnapshot(); - } - { - const { - result: { current }, - } = renderHook(() => useWeekDays(2)); - - expect(current).toMatchSnapshot(); - } - }); - - test("generateDaysInMonthArray", () => { - MockDate.set(new Date("2020-02-01T11:30:00.000Z")); - const days = generateDaysInMonthArray(1, 0, 7, 2020); - - expect(days).toMatchSnapshot(); - - MockDate.reset(); - }); -}); diff --git a/src/calendar/calendar-base-state.ts b/src/calendar/calendar-base-state.ts new file mode 100644 index 000000000..e2fe6a081 --- /dev/null +++ b/src/calendar/calendar-base-state.ts @@ -0,0 +1,32 @@ +import { Calendar, DateDuration } from "@internationalized/date"; +import { CalendarState, useCalendarState } from "@react-stately/calendar"; +import { CalendarProps, DateValue } from "@react-types/calendar"; + +export function useCalendarBaseState( + props: CalendarBaseStateProps, +): CalendarBaseState { + const state = useCalendarState(props); + + return state; +} + +export type CalendarBaseState = CalendarState; + +export type CalendarBaseStateProps = CalendarProps & { + /** The locale to display and edit the value according to. */ + locale: string; + /** + * A function that creates a [Calendar](../internationalized/date/Calendar.html) + * object for a given calendar identifier. Such a function may be imported from the + * `@internationalized/date` package, or manually implemented to include support for + * only certain calendars. + */ + createCalendar: (name: string) => Calendar; + /** + * The amount of days that will be displayed at once. This affects how pagination works. + * @default {months: 1} + */ + visibleDuration?: DateDuration; + /** Determines how to align the initial selection relative to the visible date range. */ + selectionAlignment?: "start" | "center" | "end"; +}; diff --git a/src/calendar/calendar-base.ts b/src/calendar/calendar-base.ts new file mode 100644 index 000000000..68d27b985 --- /dev/null +++ b/src/calendar/calendar-base.ts @@ -0,0 +1,32 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; + +export const useCalendar = createHook( + ({ state, ...props }) => { + props = mergeProps(state.calendarProps, props); + + return props; + }, +); + +export const Calendar = createComponent(props => { + const htmlProps = useCalendar(props); + + return createElement("div", htmlProps); +}); + +export type CalendarOptions = Options & { + /** + * Object returned by the `useCalendarState` hook. + */ + state: CalendarState; +}; + +export type CalendarProps = Props>; diff --git a/src/calendar/calendar-cell-button.ts b/src/calendar/calendar-cell-button.ts new file mode 100644 index 000000000..a7357e269 --- /dev/null +++ b/src/calendar/calendar-cell-button.ts @@ -0,0 +1,38 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarCellState } from "./calendar-cell-state"; + +export const useCalendarCellButton = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.buttonProps, props); + + return props; + }, +); + +export const CalendarCellButton = createComponent( + props => { + const htmlProps = useCalendarCellButton(props); + + return createElement("span", htmlProps); + }, +); + +export type CalendarCellButtonOptions = Options & { + /** + * Object returned by the `useCalendarCellButtonState` hook. + */ + state: CalendarCellState; +}; + +export type CalendarCellButtonProps = Props< + CalendarCellButtonOptions +>; diff --git a/src/calendar/calendar-cell-state.ts b/src/calendar/calendar-cell-state.ts new file mode 100644 index 000000000..df48eb531 --- /dev/null +++ b/src/calendar/calendar-cell-state.ts @@ -0,0 +1,75 @@ +import { HTMLAttributes, RefObject, useRef } from "react"; +import { CalendarDate } from "@internationalized/date"; +import { useCalendarCell } from "@react-aria/calendar"; + +import { CalendarBaseState } from "./calendar-base-state"; +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export function useCalendarCellState({ + state, + ...props +}: CalendarCellStateProps): CalendarCellState { + const ref = useRef(null); + const calendarCellProps = useCalendarCell(props, state, ref); + + return { ...calendarCellProps, ref, baseState: state, date: props.date }; +} + +export type CalendarCellState = { + /** Props for the grid cell element (e.g. ``). */ + cellProps: HTMLAttributes; + /** Props for the button element within the cell. */ + buttonProps: HTMLAttributes; + /** Whether the cell is currently being pressed. */ + isPressed: boolean; + /** Whether the cell is selected. */ + isSelected: boolean; + /** Whether the cell is focused. */ + isFocused: boolean; + /** + * Whether the cell is disabled, according to the calendar's `minValue`, `maxValue`, and `isDisabled` props. + * Disabled dates are not focusable, and cannot be selected by the user. They are typically + * displayed with a dimmed appearance. + */ + isDisabled: boolean; + /** + * Whether the cell is unavailable, according to the calendar's `isDateUnavailable` prop. Unavailable dates remain + * focusable, but cannot be selected by the user. They should be displayed with a visual affordance to indicate they + * are unavailable, such as a different color or a strikethrough. + * + * Note that because they are focusable, unavailable dates must meet a 4.5:1 color contrast ratio, + * [as defined by WCAG](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html). + */ + isUnavailable: boolean; + /** + * Whether the cell is outside the visible range of the calendar. + * For example, dates before the first day of a month in the same week. + */ + isOutsideVisibleRange: boolean; + /** The day number formatted according to the current locale. */ + formattedDate: string; + /** + * Reference for the button element within the cell inside the table + */ + ref: RefObject; + /** + * Object returned by the `useSliderState` hook. + */ + baseState: CalendarBaseState | RangeCalendarBaseState; + /** The date that this cell represents. */ + date: CalendarDate; +}; + +export type CalendarCellStateProps = { + /** The date that this cell represents. */ + date: CalendarDate; + /** + * Whether the cell is disabled. By default, this is determined by the + * Calendar's `minValue`, `maxValue`, and `isDisabled` props. + */ + isDisabled?: boolean; + /** + * Object returned by the `useSliderState` hook. + */ + state: CalendarBaseState | RangeCalendarBaseState; +}; diff --git a/src/calendar/calendar-cell.ts b/src/calendar/calendar-cell.ts new file mode 100644 index 000000000..ad1d14683 --- /dev/null +++ b/src/calendar/calendar-cell.ts @@ -0,0 +1,82 @@ +import { ariaAttr } from "@chakra-ui/utils"; +import { getDayOfWeek, isSameDay } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarCellState } from "./calendar-cell-state"; +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export const useCalendarCell = createHook( + ({ state, ...props }) => { + const { baseState } = state; + + const isLastSelectedBeforeDisabled = + !state.isDisabled && + baseState.isCellUnavailable(state.date.add({ days: 1 })); + const isFirstSelectedAfterDisabled = + !state.isDisabled && + baseState.isCellUnavailable(state.date.subtract({ days: 1 })); + let highlightedRange = + "highlightedRange" in baseState && + (baseState as RangeCalendarBaseState).highlightedRange; + let isSelectionStart = + state.isSelected && + highlightedRange && + isSameDay(state.date, highlightedRange.start); + let isSelectionEnd = + state.isSelected && + highlightedRange && + isSameDay(state.date, highlightedRange.end); + const { locale } = useLocale(); + const dayOfWeek = getDayOfWeek(state.date, locale); + let isRangeStart = + state.isSelected && + (isFirstSelectedAfterDisabled || dayOfWeek === 0 || state.date.day === 1); + const isRangeEnd = + state.isSelected && + (isLastSelectedBeforeDisabled || + dayOfWeek === 6 || + state.date.day === + baseState.visibleRange.start.calendar.getDaysInMonth( + baseState.visibleRange.start, + )); + + props = { + "data-is-range-selection": ariaAttr( + state.isSelected && "highlightedRange" in baseState, + ), + "data-is-range-end": ariaAttr(isRangeEnd), + "data-is-range-start": ariaAttr(isRangeStart), + "data-is-selection-end": ariaAttr(isSelectionEnd), + "data-is-selection-start": ariaAttr(isSelectionStart), + ...props, + }; + + props = mergeProps(state.cellProps, props); + + return props; + }, +); + +export const CalendarCell = createComponent(props => { + const htmlProps = useCalendarCell(props); + + return createElement("td", htmlProps); +}); + +export type CalendarCellOptions = Options & { + /** + * Object returned by the `useCalendarCellState` hook. + */ + state: CalendarCellState; +}; + +export type CalendarCellProps = Props< + CalendarCellOptions +>; diff --git a/src/calendar/calendar-grid-state.ts b/src/calendar/calendar-grid-state.ts new file mode 100644 index 000000000..6ef75bbe7 --- /dev/null +++ b/src/calendar/calendar-grid-state.ts @@ -0,0 +1,35 @@ +import { CalendarDate } from "@internationalized/date"; +import { CalendarGridAria, useCalendarGrid } from "@react-aria/calendar"; + +import { CalendarBaseState } from "./calendar-base-state"; +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export function useCalendarGridState({ + state, + ...props +}: CalendarGridStateProps): CalendarGridState { + const calendarGridProps = useCalendarGrid(props, state); + + return calendarGridProps; +} + +export type CalendarGridState = CalendarGridAria; + +export type CalendarGridStateProps = { + /** + * The first date displayed in the calendar grid. + * Defaults to the first visible date in the calendar. + * Override this to display multiple date grids in a calendar. + */ + startDate?: CalendarDate; + /** + * The last date displayed in the calendar grid. + * Defaults to the last visible date in the calendar. + * Override this to display multiple date grids in a calendar. + */ + endDate?: CalendarDate; + /** + * Object returned by the `useSliderState` hook. + */ + state: CalendarBaseState | RangeCalendarBaseState; +}; diff --git a/src/calendar/calendar-grid.ts b/src/calendar/calendar-grid.ts new file mode 100644 index 000000000..b0a2ecbf3 --- /dev/null +++ b/src/calendar/calendar-grid.ts @@ -0,0 +1,34 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarGridState } from "./calendar-grid-state"; + +export const useCalendarGrid = createHook( + ({ state, ...props }) => { + props = mergeProps(state.gridProps, props); + + return props; + }, +); + +export const CalendarGrid = createComponent(props => { + const htmlProps = useCalendarGrid(props); + + return createElement("table", htmlProps); +}); + +export type CalendarGridOptions = Options & { + /** + * Object returned by the `useCalendarGridState` hook. + */ + state: CalendarGridState; +}; + +export type CalendarGridProps = Props< + CalendarGridOptions +>; diff --git a/src/calendar/calendar-next-button.ts b/src/calendar/calendar-next-button.ts new file mode 100644 index 000000000..50da24998 --- /dev/null +++ b/src/calendar/calendar-next-button.ts @@ -0,0 +1,43 @@ +import { useRef } from "react"; +import { useButton } from "@react-aria/button"; +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; +import { RangeCalendarState } from "./range-calendar-state"; + +export const useCalendarNextButton = createHook( + ({ state, ...props }) => { + const ref = useRef(null); + props = { ...props, ref: useForkRef(ref, props.ref) }; + const { buttonProps } = useButton(state.nextButtonProps, ref); + props = mergeProps(buttonProps, props); + + return props; + }, +); + +export const CalendarNextButton = createComponent( + props => { + const htmlProps = useCalendarNextButton(props); + + return createElement("button", htmlProps); + }, +); + +export type CalendarNextButtonOptions = Options & { + /** + * Object returned by the `useCalendarNextButtonState` hook. + */ + state: CalendarState | RangeCalendarState; +}; + +export type CalendarNextButtonProps = Props< + CalendarNextButtonOptions +>; diff --git a/src/calendar/calendar-prev-button.ts b/src/calendar/calendar-prev-button.ts new file mode 100644 index 000000000..fe999f3d1 --- /dev/null +++ b/src/calendar/calendar-prev-button.ts @@ -0,0 +1,42 @@ +import { useRef } from "react"; +import { useButton } from "@react-aria/button"; +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; +import { RangeCalendarState } from "./range-calendar-state"; + +export const useCalendarPreviousButton = + createHook(({ state, ...props }) => { + const ref = useRef(null); + props = { ...props, ref: useForkRef(ref, props.ref) }; + const { buttonProps } = useButton(state.prevButtonProps, ref); + props = mergeProps(buttonProps, props); + + return props; + }); + +export const CalendarPreviousButton = + createComponent(props => { + const htmlProps = useCalendarPreviousButton(props); + + return createElement("button", htmlProps); + }); + +export type CalendarPreviousButtonOptions = + Options & { + /** + * Object returned by the `useCalendarPreviousButtonState` hook. + */ + state: CalendarState | RangeCalendarState; + }; + +export type CalendarPreviousButtonProps = Props< + CalendarPreviousButtonOptions +>; diff --git a/src/calendar/calendar-state.ts b/src/calendar/calendar-state.ts new file mode 100644 index 000000000..e87001c06 --- /dev/null +++ b/src/calendar/calendar-state.ts @@ -0,0 +1,33 @@ +import { HTMLAttributes } from "react"; +import { useCalendar } from "@react-aria/calendar"; +import { AriaButtonProps } from "@react-types/button"; +import { CalendarProps, DateValue } from "@react-types/calendar"; + +import { CalendarBaseState } from "./calendar-base-state"; + +export function useCalendarState({ + state, + ...props +}: CalendarStateProps): CalendarState { + const calendarProps = useCalendar(props, state); + + return calendarProps; +} + +export type CalendarState = { + /** Props for the calendar grouping element. */ + calendarProps: HTMLAttributes; + /** Props for the next button. */ + nextButtonProps: AriaButtonProps; + /** Props for the previous button. */ + prevButtonProps: AriaButtonProps; + /** A description of the visible date range, for use in the calendar title. */ + title: string; +}; + +export type CalendarStateProps = CalendarProps & { + /** + * Object returned by the `useSliderState` hook. + */ + state: CalendarBaseState; +}; diff --git a/src/calendar/calendar-title.ts b/src/calendar/calendar-title.ts new file mode 100644 index 000000000..780c570f5 --- /dev/null +++ b/src/calendar/calendar-title.ts @@ -0,0 +1,33 @@ +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; + +export const useCalendarTitle = createHook( + ({ state, ...props }) => { + props = { children: state.title, ...props }; + + return props; + }, +); + +export const CalendarTitle = createComponent(props => { + const htmlProps = useCalendarTitle(props); + + return createElement("h2", htmlProps); +}); + +export type CalendarTitleOptions = Options & { + /** + * Object returned by the `useCalendarTitleState` hook. + */ + state: CalendarState; +}; + +export type CalendarTitleProps = Props< + CalendarTitleOptions +>; diff --git a/src/calendar/helpers/index.ts b/src/calendar/helpers/index.ts deleted file mode 100644 index e8c125f9a..000000000 --- a/src/calendar/helpers/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * for these utils inspiration - */ -import { useDateFormatter } from "@react-aria/i18n"; -import { RangeValue } from "@react-types/shared"; - -import { setDay, toUTCString } from "../../utils"; - -export function useWeekDays(weekStart: number) { - const dayFormatter = useDateFormatter({ weekday: "short" }); - const dayFormatterLong = useDateFormatter({ weekday: "long" }); - - return [0, 1, 2, 3, 4, 5, 6].map(index => { - const dateDay = setDay(Date.now(), (index + weekStart) % 7); - - const day = dayFormatter.format(dateDay); - const dayLong = dayFormatterLong.format(dateDay); - return { title: dayLong, abbr: day } as const; - }); -} - -export function generateDaysInMonthArray( - month: number, - monthStartsAt: number, - weeksInMonth: number, - year: number, -) { - return Array(weeksInMonth) - .fill(1) - .reduce((weeks: Date[][], _, weekIndex) => { - const daysInWeek = [0, 1, 2, 3, 4, 5, 6].reduce( - (days: Date[], dayIndex) => { - const day = weekIndex * 7 + dayIndex - monthStartsAt + 2; - const utcDate = toUTCString(new Date(year, month, day)); - const cellDate = new Date(utcDate); - - return [...days, cellDate]; - }, - [], - ); - - return [...weeks, daysInWeek]; - }, []); -} - -export function makeRange(start: Date, end: Date): RangeValue { - if (end < start) { - [start, end] = [end, start]; - } - - return { start, end }; -} - -export * from "./useWeekStart"; diff --git a/src/calendar/helpers/useWeekStart.ts b/src/calendar/helpers/useWeekStart.ts deleted file mode 100644 index e4ea8ceca..000000000 --- a/src/calendar/helpers/useWeekStart.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useWeekStart](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/calendar/src/useWeekStart.ts) - * to work with Reakit System - */ -import { useLocale } from "@react-aria/i18n"; - -// Data from https://github.com/unicode-cldr/cldr-core/blob/master/supplemental/weekData.json -// Locales starting on Sunday have been removed for compression. -const data: Record = { - "001": 1, - AD: 1, - AE: 6, - AF: 6, - AI: 1, - AL: 1, - AM: 1, - AN: 1, - AT: 1, - AX: 1, - AZ: 1, - BA: 1, - BE: 1, - BG: 1, - BH: 6, - BM: 1, - BN: 1, - BY: 1, - CH: 1, - CL: 1, - CM: 1, - CR: 1, - CY: 1, - CZ: 1, - DE: 1, - DJ: 6, - DK: 1, - DZ: 6, - EC: 1, - EE: 1, - EG: 6, - ES: 1, - FI: 1, - FJ: 1, - FO: 1, - FR: 1, - GB: 1, - GE: 1, - GF: 1, - GP: 1, - GR: 1, - HR: 1, - HU: 1, - IE: 1, - IQ: 6, - IR: 6, - IS: 1, - IT: 1, - JO: 6, - KG: 1, - KW: 6, - KZ: 1, - LB: 1, - LI: 1, - LK: 1, - LT: 1, - LU: 1, - LV: 1, - LY: 6, - MC: 1, - MD: 1, - ME: 1, - MK: 1, - MN: 1, - MQ: 1, - MV: 5, - MY: 1, - NL: 1, - NO: 1, - NZ: 1, - OM: 6, - PL: 1, - QA: 6, - RE: 1, - RO: 1, - RS: 1, - RU: 1, - SD: 6, - SE: 1, - SI: 1, - SK: 1, - SM: 1, - SY: 6, - TJ: 1, - TM: 1, - TR: 1, - UA: 1, - UY: 1, - UZ: 1, - VA: 1, - VN: 1, - XK: 1, -}; - -export function useWeekStart() { - const region = useRegion(); - return data[region] || 0; -} - -function useRegion(): string { - const { locale } = useLocale(); - - // If the Intl.Locale API is available, use it to get the region for the locale. - // @ts-ignore - if (Intl.Locale) { - // @ts-ignore - return new Intl.Locale(locale).maximize().region; - } - - // If not, just try splitting the string. - return locale.split("-")[1]; -} diff --git a/src/calendar/index.ts b/src/calendar/index.ts index d69459175..6fa20b6f3 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -1,10 +1,14 @@ -export * from "./__keys"; -export * from "./Calendar"; -export * from "./CalendarButton"; -export * from "./CalendarCell"; -export * from "./CalendarCellButton"; -export * from "./CalendarGrid"; -export * from "./CalendarHeader"; -export * from "./CalendarState"; -export * from "./CalendarWeekTitle"; -export * from "./RangeCalendarState"; +export * from "./calendar-base"; +export * from "./calendar-base-state"; +export * from "./calendar-cell"; +export * from "./calendar-cell-button"; +export * from "./calendar-cell-state"; +export * from "./calendar-grid"; +export * from "./calendar-grid-state"; +export * from "./calendar-next-button"; +export * from "./calendar-prev-button"; +export * from "./calendar-state"; +export * from "./calendar-title"; +export * from "./range-calendar"; +export * from "./range-calendar-base-state"; +export * from "./range-calendar-state"; diff --git a/src/calendar/range-calendar-base-state.ts b/src/calendar/range-calendar-base-state.ts new file mode 100644 index 000000000..bc627a3f4 --- /dev/null +++ b/src/calendar/range-calendar-base-state.ts @@ -0,0 +1,33 @@ +import { Calendar, DateDuration } from "@internationalized/date"; +import { + RangeCalendarState, + useRangeCalendarState, +} from "@react-stately/calendar"; +import { DateValue, RangeCalendarProps } from "@react-types/calendar"; + +export function useRangeCalendarBaseState( + props: RangeCalendarBaseStateProps, +): RangeCalendarBaseState { + const state = useRangeCalendarState(props); + + return state; +} + +export type RangeCalendarBaseState = RangeCalendarState; + +export type RangeCalendarBaseStateProps = RangeCalendarProps & { + /** The locale to display and edit the value according to. */ + locale: string; + /** + * A function that creates a [Calendar](../internationalized/date/Calendar.html) + * object for a given calendar identifier. Such a function may be imported from the + * `@internationalized/date` package, or manually implemented to include support for + * only certain calendars. + */ + createCalendar: (name: string) => Calendar; + /** + * The amount of days that will be displayed at once. This affects how pagination works. + * @default {months: 1} + */ + visibleDuration?: DateDuration; +}; diff --git a/src/calendar/range-calendar-state.ts b/src/calendar/range-calendar-state.ts new file mode 100644 index 000000000..3e392eff3 --- /dev/null +++ b/src/calendar/range-calendar-state.ts @@ -0,0 +1,38 @@ +import { HTMLAttributes, RefObject, useRef } from "react"; +import { useRangeCalendar } from "@react-aria/calendar"; +import { AriaButtonProps } from "@react-types/button"; +import { DateValue, RangeCalendarProps } from "@react-types/calendar"; + +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export function useRangeCalendarState({ + state, + ...props +}: RangeCalendarStateProps): RangeCalendarState { + const ref = useRef(null); + const calendarProps = useRangeCalendar(props, state, ref); + + return { ...calendarProps, ref }; +} + +export type RangeCalendarState = { + /** Props for the calendar grouping element. */ + calendarProps: HTMLAttributes; + /** Props for the next button. */ + nextButtonProps: AriaButtonProps; + /** Props for the previous button. */ + prevButtonProps: AriaButtonProps; + /** A description of the visible date range, for use in the calendar title. */ + title: string; + /** + * Reference for the calendar wrapper element within the cell inside the table + */ + ref: RefObject; +}; + +export type RangeCalendarStateProps = RangeCalendarProps & { + /** + * Object returned by the `useSliderState` hook. + */ + state: RangeCalendarBaseState; +}; diff --git a/src/calendar/range-calendar.ts b/src/calendar/range-calendar.ts new file mode 100644 index 000000000..caa2fbf37 --- /dev/null +++ b/src/calendar/range-calendar.ts @@ -0,0 +1,36 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { RangeCalendarState } from "./range-calendar-state"; + +export const useRangeCalendar = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.calendarProps, props); + + return props; + }, +); + +export const RangeCalendar = createComponent(props => { + const htmlProps = useRangeCalendar(props); + + return createElement("div", htmlProps); +}); + +export type RangeCalendarOptions = Options & { + /** + * Object returned by the `useRangeCalendarState` hook. + */ + state: RangeCalendarState; +}; + +export type RangeCalendarProps = Props< + RangeCalendarOptions +>; diff --git a/src/calendar/stories/CalendarBasic.component.tsx b/src/calendar/stories/CalendarBasic.component.tsx index e5a8604d3..a3dae5a56 100644 --- a/src/calendar/stories/CalendarBasic.component.tsx +++ b/src/calendar/stories/CalendarBasic.component.tsx @@ -1,77 +1,113 @@ import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; import { - Calendar as CalendarWrapper, - CalendarButton, + Calendar, + CalendarBaseStateProps, CalendarCell, CalendarCellButton, + CalendarCellStateProps, CalendarGrid, - CalendarHeader, - CalendarInitialState, - CalendarWeekTitle, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + useCalendarBaseState, + useCalendarCellState, + useCalendarGridState, useCalendarState, } from "../../index"; -import { - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarBasicProps = CalendarBaseStateProps & {}; -export const Calendar: React.FC = props => { - const state = useCalendarState(props); +export const CalendarBasic: React.FC = props => { + const state = useCalendarBaseState(props); + const calendar = useCalendarState({ ...props, state }); return ( - + - - - - + - - - + + + - - - - + + + + ); +}; + +export default CalendarBasic; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - + ))} + + ); }; -export default Calendar; +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarBasic.css b/src/calendar/stories/CalendarBasic.css index 32193ea4b..ff17a4c0a 100644 --- a/src/calendar/stories/CalendarBasic.css +++ b/src/calendar/stories/CalendarBasic.css @@ -38,30 +38,24 @@ border: 0; } -.calendar .prev-year, .calendar .prev-month, -.calendar .next-month, -.calendar .next-year { +.calendar .next-month { padding: 4px; width: 24px; height: 24px; color: #676d7e; } -.calendar .prev-year:focus, .calendar .prev-month:focus, -.calendar .next-month:focus, -.calendar .next-year:focus { +.calendar .next-month:focus { padding: 2px; border: 2px solid #676d7e; border-radius: 4px; outline: 0; } -.calendar .prev-year:hover, .calendar .prev-month:hover, -.calendar .next-month:hover, -.calendar .next-year:hover { +.calendar .next-month:hover { padding: 3px; border: 1px solid #676d7e; border-radius: 4px; @@ -89,7 +83,7 @@ text-align: center; } -.calendar .dates th abbr { +.calendar .dates th span { text-decoration: none; } @@ -113,7 +107,7 @@ user-select: none; } -.calendar .dates td[aria-selected] span { +.calendar .dates td[aria-selected="true"] span { border-radius: 50%; border: 2px dotted black; background-color: #fbfbff; diff --git a/src/calendar/stories/CalendarBasic.stories.tsx b/src/calendar/stories/CalendarBasic.stories.tsx index 1456163dc..6701de79a 100644 --- a/src/calendar/stories/CalendarBasic.stories.tsx +++ b/src/calendar/stories/CalendarBasic.stories.tsx @@ -1,66 +1,33 @@ import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { addMonths, addYears, subMonths, toUTCString } from "../../utils"; import css from "./templates/CalendarBasicCss"; import js from "./templates/CalendarBasicJsx"; import ts from "./templates/CalendarBasicTsx"; import jsUtils from "./templates/UtilsJsx"; import tsUtils from "./templates/UtilsTsx"; -import Calendar from "./CalendarBasic.component"; +import { CalendarBasic } from "./CalendarBasic.component"; import "./CalendarBasic.css"; +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + export default { - component: Calendar, title: "Calendar/Basic", - argTypes: { - defaultValue: { control: "date" }, - value: { control: "date" }, - minValue: { control: "date" }, - maxValue: { control: "date" }, - }, + component: CalendarBasic, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css, jsUtils, tsUtils }), }, } as Meta; -export const Default: Story = args => { - return ; -}; - -export const DefaultDate = Default.bind({}); -DefaultDate.args = { defaultValue: toUTCString(addYears(new Date(), 1)) }; - -export const MinMaxDate = Default.bind({}); -MinMaxDate.args = { - minValue: toUTCString(subMonths(new Date(), 1)), - maxValue: toUTCString(addMonths(new Date(), 1)), -}; - -export const IsDisabled = Default.bind({}); -IsDisabled.args = { defaultValue: toUTCString(new Date()), isDisabled: true }; - -export const IsReadonly = Default.bind({}); -IsReadonly.args = { defaultValue: toUTCString(new Date()), isReadOnly: true }; - -export const AutoFocus = Default.bind({}); -AutoFocus.args = { defaultValue: toUTCString(new Date()), autoFocus: true }; - -export const ControlledInput = () => { - const [value, setValue] = React.useState("2020-10-13"); +export const Default = () => { + let { locale } = useLocale(); - return ( - - setValue(e.target.value)} - value={value} - /> - - - ); + return ; }; diff --git a/src/calendar/stories/CalendarRange.component.tsx b/src/calendar/stories/CalendarRange.component.tsx index 7498e388c..246adc737 100644 --- a/src/calendar/stories/CalendarRange.component.tsx +++ b/src/calendar/stories/CalendarRange.component.tsx @@ -1,77 +1,113 @@ import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; import { - Calendar, - CalendarButton, CalendarCell, CalendarCellButton, + CalendarCellStateProps, CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - RangeCalendarInitialState, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + RangeCalendar, + RangeCalendarBaseStateProps, + useCalendarCellState, + useCalendarGridState, + useRangeCalendarBaseState, useRangeCalendarState, } from "../../index"; -import { - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarRangeProps = RangeCalendarBaseStateProps & {}; -export const RangeCalendar: React.FC = props => { - const state = useRangeCalendarState(props); +export const CalendarRange: React.FC = props => { + const state = useRangeCalendarBaseState(props); + const calendar = useRangeCalendarState({ ...props, state }); return ( - + - - - - + - - - + + + - - - - + + + + ); +}; + +export default CalendarRange; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - + ))} + + ); }; -export default RangeCalendar; +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarRange.css b/src/calendar/stories/CalendarRange.css index 706b15de9..935a89084 100644 --- a/src/calendar/stories/CalendarRange.css +++ b/src/calendar/stories/CalendarRange.css @@ -90,7 +90,7 @@ text-align: center; } -.calendar-range .dates th abbr { +.calendar-range .dates th span { text-decoration: none; } diff --git a/src/calendar/stories/CalendarRange.stories.tsx b/src/calendar/stories/CalendarRange.stories.tsx index 1711b52a8..102f97963 100644 --- a/src/calendar/stories/CalendarRange.stories.tsx +++ b/src/calendar/stories/CalendarRange.stories.tsx @@ -1,95 +1,33 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { addDays, addWeeks, subDays, subWeeks, toUTCString } from "../../utils"; import css from "./templates/CalendarRangeCss"; import js from "./templates/CalendarRangeJsx"; import ts from "./templates/CalendarRangeTsx"; import jsUtils from "./templates/UtilsJsx"; import tsUtils from "./templates/UtilsTsx"; -import RangeCalendar from "./CalendarRange.component"; +import { CalendarRange } from "./CalendarRange.component"; import "./CalendarRange.css"; +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + export default { - component: RangeCalendar, title: "Calendar/Range", - argTypes: { - minValue: { control: "date" }, - maxValue: { control: "date" }, - }, + component: CalendarRange, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css, jsUtils, tsUtils }), }, } as Meta; -export const Default: Story = args => { - return ; -}; - -export const DefaultValue = Default.bind({}); -DefaultValue.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, -}; - -export const MinMaxDate = Default.bind({}); -MinMaxDate.args = { - minValue: toUTCString(subWeeks(new Date(), 1)), - maxValue: toUTCString(addWeeks(new Date(), 1)), -}; - -export const Disabled = Default.bind({}); -Disabled.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - isDisabled: true, -}; - -export const Readonly = Default.bind({}); -Readonly.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - isReadOnly: true, -}; - -export const Autofocus = Default.bind({}); -Autofocus.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - autoFocus: true, -}; - -export const ControlledInput = () => { - const [start, setStart] = React.useState(toUTCString(subDays(new Date(), 1))); - const [end, setEnd] = React.useState(toUTCString(addDays(new Date(), 1))); +export const Default = () => { + let { locale } = useLocale(); - return ( - - setStart(e.target.value)} - value={start} - /> - setEnd(e.target.value)} value={end} /> - { - setStart(start); - setEnd(end); - }} - /> - - ); + return ; }; diff --git a/src/calendar/stories/CalendarRangeStyled.component.tsx b/src/calendar/stories/CalendarRangeStyled.component.tsx new file mode 100644 index 000000000..24d9d77fb --- /dev/null +++ b/src/calendar/stories/CalendarRangeStyled.component.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; + +import { + CalendarCell, + CalendarCellButton, + CalendarCellStateProps, + CalendarGrid, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + RangeCalendar, + RangeCalendarBaseStateProps, + useCalendarCellState, + useCalendarGridState, + useRangeCalendarBaseState, + useRangeCalendarState, +} from "../../index"; + +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarRangeStyledProps = RangeCalendarBaseStateProps & {}; + +export const CalendarRangeStyled: React.FC< + CalendarRangeStyledProps +> = props => { + const state = useRangeCalendarBaseState(props); + const calendar = useRangeCalendarState({ ...props, state }); + + return ( + + + + + + + + + + + + + ); +}; + +export default CalendarRangeStyled; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); + + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} + + ))} + + + ); +}; + +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarRangeStyled.stories.tsx b/src/calendar/stories/CalendarRangeStyled.stories.tsx new file mode 100644 index 000000000..09db680aa --- /dev/null +++ b/src/calendar/stories/CalendarRangeStyled.stories.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/CalendarRangeStyledJsx"; +import ts from "./templates/CalendarRangeStyledTsx"; +import jsUtils from "./templates/UtilsJsx"; +import tsUtils from "./templates/UtilsTsx"; +import { CalendarRangeStyled } from "./CalendarRangeStyled.component"; + +import "./tailwind.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "Calendar/RangeStyled", + component: CalendarRangeStyled, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, jsUtils, tsUtils }), + }, + decorators: [ + Story => { + document.body.id = "tailwind"; + return ; + }, + ], +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ( + + ); +}; diff --git a/src/calendar/stories/CalendarStyled.component.tsx b/src/calendar/stories/CalendarStyled.component.tsx new file mode 100644 index 000000000..e0f6e220a --- /dev/null +++ b/src/calendar/stories/CalendarStyled.component.tsx @@ -0,0 +1,128 @@ +import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; + +import { + Calendar, + CalendarBaseStateProps, + CalendarCell, + CalendarCellButton, + CalendarCellStateProps, + CalendarGrid, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + useCalendarBaseState, + useCalendarCellState, + useCalendarGridState, + useCalendarState, +} from "../../index"; + +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarStyledProps = CalendarBaseStateProps & {}; + +export const CalendarStyled: React.FC = props => { + const state = useCalendarBaseState(props); + const calendar = useCalendarState({ ...props, state }); + + return ( + + + + + + + + + + + + + ); +}; + +export default CalendarStyled; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); + + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} + + ))} + + + ); +}; + +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarStyled.stories.tsx b/src/calendar/stories/CalendarStyled.stories.tsx new file mode 100644 index 000000000..497ccf0ba --- /dev/null +++ b/src/calendar/stories/CalendarStyled.stories.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/CalendarStyledJsx"; +import ts from "./templates/CalendarStyledTsx"; +import jsUtils from "./templates/UtilsJsx"; +import tsUtils from "./templates/UtilsTsx"; +import { CalendarStyled } from "./CalendarStyled.component"; + +import "./tailwind.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "Calendar/Styled", + component: CalendarStyled, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, jsUtils, tsUtils }), + }, + decorators: [ + Story => { + document.body.id = "tailwind"; + return ; + }, + ], +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ; +}; diff --git a/src/calendar/stories/Utils.component.tsx b/src/calendar/stories/Utils.component.tsx index cb9286a68..b8b592850 100644 --- a/src/calendar/stories/Utils.component.tsx +++ b/src/calendar/stories/Utils.component.tsx @@ -1,24 +1,5 @@ import * as React from "react"; -export const DoubleChevronLeft = (props: React.SVGProps) => { - return ( - - - - ); -}; - export const ChevronLeft = (props: React.SVGProps) => { return ( ) => { export const ChevronRight = (props: React.SVGProps) => ( ); - -export const DoubleChevronRight = (props: React.SVGProps) => ( - -); diff --git a/src/calendar/stories/tailwind.css b/src/calendar/stories/tailwind.css new file mode 100644 index 000000000..a7de9a2d9 --- /dev/null +++ b/src/calendar/stories/tailwind.css @@ -0,0 +1,53 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .styled-datepicker .calendar__cell { + height: 32px; + width: 32px; + max-height: 32px; + max-width: 32px; + @apply text-sm text-center rounded-lg; + } + .styled-datepicker .calendar__cell[data-is-range-selection] { + @apply bg-blue-100 rounded-none text-gray-800 !important; + } + .styled-datepicker .calendar__cell[data-is-selection-start] { + @apply bg-blue-500 rounded-l-lg text-white !important; + } + .styled-datepicker .calendar__cell[data-is-selection-end] { + @apply bg-blue-500 rounded-r-lg text-white !important; + } + + .styled-datepicker .calendar__cell[data-is-range-selection]:focus-within { + @apply bg-blue-400 text-white !important; + } + .styled-datepicker .calendar__cell:focus-within { + @apply bg-gray-100; + } + + .styled-datepicker.calendar [data-weekend] { + @apply text-red-600; + } + + .styled-datepicker.calendar [aria-selected="true"] { + @apply text-white bg-blue-500; + } + + .styled-datepicker.calendar [aria-selected]:focus-within { + @apply bg-gray-100; + } + + .styled-datepicker.calendar [aria-selected="true"]:focus-within { + @apply text-white bg-blue-400; + } + + .styled-datepicker.calendar [aria-disabled="true"] { + @apply text-gray-500; + } + + .styled-datepicker.calendar span { + outline: none; + } +} diff --git a/src/checkbox/Checkbox.tsx b/src/checkbox/Checkbox.tsx deleted file mode 100644 index b097541df..000000000 --- a/src/checkbox/Checkbox.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import * as React from "react"; -import { ClickableHTMLProps, ClickableOptions, useClickable } from "reakit"; -import { removeIndexFromArray, useForkRef, useLiveRef } from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { createComponent, createHook } from "../system"; - -import { CHECKBOX_KEYS } from "./__keys"; -import { CheckboxStateReturn } from "./CheckboxState"; -import { fireChange, getChecked, useIndeterminateState } from "./helpers"; - -export type CheckboxOptions = ClickableOptions & - Pick, "state" | "setState"> & { - /** - * Checkbox's value is going to be used when multiple checkboxes share the - * same state. Checking a checkbox with value will add it to the state - * array. - */ - value?: string | number; - - /** - * Checkbox's checked state. If present, it's used instead of `state`. - */ - checked?: boolean; - }; - -export type CheckboxHTMLProps = ClickableHTMLProps & - React.InputHTMLAttributes & { - value?: string | number; - }; - -export type CheckboxProps = CheckboxOptions & CheckboxHTMLProps; - -export const useCheckbox = createHook({ - name: "Checkbox", - compose: useClickable, - keys: CHECKBOX_KEYS, - - useOptions(options, htmlProps) { - const { unstable_clickOnEnter = false, ...restOptions } = options; - const { value, checked } = htmlProps; - - return { - unstable_clickOnEnter, - value, - checked: getChecked({ checked, ...options }), - ...restOptions, - }; - }, - - useProps(options, htmlProps) { - const { state, setState, value, checked, disabled } = options; - const { - ref: htmlRef, - onChange: htmlOnChange, - onClick: htmlOnClick, - ...restHtmlProps - } = htmlProps; - const ref = React.useRef(null); - const [isNativeCheckbox, setIsNativeCheckbox] = React.useState(true); - const onChangeRef = useLiveRef(htmlOnChange); - const onClickRef = useLiveRef(htmlOnClick); - - React.useEffect(() => { - const element = ref.current; - - if (!element) { - warning( - true, - "Can't determine whether the element is a native checkbox because `ref` wasn't passed to the component", - ); - return; - } - - if (element.tagName !== "INPUT" || element.type !== "checkbox") { - setIsNativeCheckbox(false); - } - }, []); - - useIndeterminateState(ref, options); - - const onChange = React.useCallback( - (event: React.ChangeEvent) => { - const element = event.currentTarget; - - if (disabled) { - event.stopPropagation(); - event.preventDefault(); - - return; - } - - if (onChangeRef.current) { - // If component is NOT rendered as a native input, it will not have - // the `checked` property. So we assign it for consistency. - if (!isNativeCheckbox) { - element.checked = !element.checked; - } - - onChangeRef.current(event); - } - - if (!setState) return; - - if (typeof value === "undefined") { - setState(!checked); - } else { - const stateProp = Array.isArray(state) ? state : []; - const index = stateProp.indexOf(value); - - if (index === -1) { - setState([...stateProp, value]); - } else { - setState(removeIndexFromArray(stateProp, index)); - } - } - }, - [ - disabled, - onChangeRef, - setState, - value, - isNativeCheckbox, - checked, - state, - ], - ); - - const onClick = React.useCallback( - (event: React.MouseEvent) => { - onClickRef.current?.(event); - - if (event.defaultPrevented) return; - - if (isNativeCheckbox) return; - - fireChange(event.currentTarget, onChange); - }, - [isNativeCheckbox, onChange, onClickRef], - ); - - return { - ref: useForkRef(ref, htmlRef), - role: !isNativeCheckbox ? "checkbox" : undefined, - type: isNativeCheckbox ? "checkbox" : undefined, - value: isNativeCheckbox ? value : undefined, - checked: checked, - "aria-checked": state === "indeterminate" ? "mixed" : checked, - onChange, - onClick, - ...restHtmlProps, - }; - }, -}); - -export const Checkbox = createComponent({ - as: "input", - memo: true, - useHook: useCheckbox, -}); diff --git a/src/checkbox/CheckboxState.tsx b/src/checkbox/CheckboxState.tsx deleted file mode 100644 index 4ca481e9a..000000000 --- a/src/checkbox/CheckboxState.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useControllableState } from "../utils/index"; - -export type CheckboxState = { - /** - * Stores the state of the checkbox. - * If checkboxes that share this state have defined a `value` prop, it's - * going to be an array. - */ - state: boolean | "indeterminate" | Array; -}; - -export type CheckboxActions = { - /** - * Sets `state` for the checkbox. - */ - setState: React.Dispatch>; -}; - -export type CheckboxInitialState = { - /** - * Default State of the Checkbox for uncontrolled Checkbox. - * - * @default false - */ - defaultState?: CheckboxState["state"]; - - /** - * State of the Checkbox for controlled Checkbox.. - */ - state?: CheckboxState["state"]; - - /** - * OnChange callback for controlled Checkbox. - */ - onStateChange?: React.Dispatch>; -}; - -export type CheckboxStateReturn = CheckboxState & CheckboxActions; - -export function useCheckboxState( - props: CheckboxInitialState = {}, -): CheckboxStateReturn { - const { - // Default State should be false otherwise input state will be undefined - defaultState = false, - state: stateProp, - onStateChange, - } = props; - - const [state, setState] = useControllableState({ - defaultValue: defaultState, - value: stateProp, - onChange: onStateChange, - }); - - return { state, setState }; -} diff --git a/src/checkbox/__keys.ts b/src/checkbox/__keys.ts deleted file mode 100644 index 372208d29..000000000 --- a/src/checkbox/__keys.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Automatically generated -export const USE_CHECKBOX_STATE_KEYS = [ - "defaultState", - "state", - "onStateChange", -] as const; -export const CHECKBOX_STATE_KEYS = ["state", "setState"] as const; -export const CHECKBOX_KEYS = [ - ...CHECKBOX_STATE_KEYS, - "value", - "checked", -] as const; diff --git a/src/checkbox/helpers.tsx b/src/checkbox/helpers.tsx deleted file mode 100644 index 77b0a1ed6..000000000 --- a/src/checkbox/helpers.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react"; -import { createEvent } from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { CheckboxOptions } from "./Checkbox"; - -export function getChecked(options: CheckboxOptions) { - const { checked, value, state } = options; - if (typeof checked !== "undefined") return checked; - - if (typeof value === "undefined") return !!state; - - const stateProp = Array.isArray(state) ? state : []; - - return stateProp.indexOf(value) !== -1; -} - -export function fireChange( - element: HTMLElement, - onChange?: React.ChangeEventHandler, -) { - const event = createEvent(element, "change"); - - Object.defineProperties(event, { - type: { value: "change" }, - target: { value: element }, - currentTarget: { value: element }, - }); - - onChange?.(event as any); -} - -export function useIndeterminateState( - ref: React.RefObject, - options: CheckboxOptions, -) { - const { state } = options; - - React.useEffect(() => { - const element = ref.current; - - if (!element) { - warning( - state === "indeterminate", - "Can't set indeterminate state because `ref` wasn't passed to component.", - ); - return; - } - - if (state === "indeterminate") { - element.indeterminate = true; - } else if (element.indeterminate) { - element.indeterminate = false; - } - }, [state, ref]); -} diff --git a/src/checkbox/index.ts b/src/checkbox/index.ts deleted file mode 100644 index 95b2cc59a..000000000 --- a/src/checkbox/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./__keys"; -export * from "./Checkbox"; -export * from "./CheckboxState"; diff --git a/src/checkbox/stories/CheckboxBasic.component.tsx b/src/checkbox/stories/CheckboxBasic.component.tsx deleted file mode 100644 index f4e398794..000000000 --- a/src/checkbox/stories/CheckboxBasic.component.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from "react"; - -import { - Checkbox as RenderlesskitCheckbox, - CheckboxHTMLProps, - CheckboxInitialState, - splitStateProps, - USE_CHECKBOX_STATE_KEYS, - useCheckboxState, -} from "../../index"; - -export type CheckboxProps = CheckboxHTMLProps & CheckboxInitialState & {}; - -export const Checkbox: React.FC = props => { - const [stateProps, checkboxProps] = splitStateProps< - CheckboxInitialState, - CheckboxProps - >(props, USE_CHECKBOX_STATE_KEYS); - - const state = useCheckboxState(stateProps); - - return ; -}; - -export default Checkbox; diff --git a/src/checkbox/stories/CheckboxBasic.stories.tsx b/src/checkbox/stories/CheckboxBasic.stories.tsx deleted file mode 100644 index a6d84f96a..000000000 --- a/src/checkbox/stories/CheckboxBasic.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; -import { CheckboxState } from "../CheckboxState"; - -import js from "./templates/CheckboxBasicJsx"; -import ts from "./templates/CheckboxBasicTsx"; -import { Checkbox, CheckboxProps } from "./CheckboxBasic.component"; - -export default { - component: Checkbox, - title: "Checkbox/Basic", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts }), - }, - argTypes: createControls({ - ignore: [ - "unstable_system", - "unstable_clickOnEnter", - "unstable_clickOnSpace", - "wrapElement", - "focusable", - "as", - "checked", - "state", - "setState", - "onStateChange", - "value", - ], - }), -} as Meta; - -export const Default: Story = args => ; - -export const Controlled = () => { - const [value, setValue] = React.useState(true); - console.log("%cvalue", "color: #997326", value); - - return ; -}; diff --git a/src/datefield/date-segment.ts b/src/datefield/date-segment.ts new file mode 100644 index 000000000..8bc70b9bc --- /dev/null +++ b/src/datefield/date-segment.ts @@ -0,0 +1,43 @@ +import { useRef } from "react"; +import { useDateSegment as useAriaDateSegment } from "@react-aria/datepicker"; +import { mergeProps } from "@react-aria/utils"; +import { DateSegment as DateSegmentState } from "@react-stately/datepicker"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DateFieldBaseState } from "./datefield-base-state"; + +export const useDateSegment = createHook( + ({ state, segment, ...props }) => { + const ref = useRef(null); + + props = { ...props, ref: useForkRef(ref, props.ref) }; + const { segmentProps } = useAriaDateSegment(segment, state, ref); + props = mergeProps(segmentProps, props); + + return props; + }, +); + +export const DateSegment = createComponent(props => { + const htmlProps = useDateSegment(props); + + return createElement("div", htmlProps); +}); + +export type DateSegmentOptions = Options & { + segment: DateSegmentState; + /** + * Object returned by the `useDateFieldBaseState` hook. + */ + state: DateFieldBaseState; +}; + +export type DateSegmentProps = Props< + DateSegmentOptions +>; diff --git a/src/datefield/datefield-base-state.ts b/src/datefield/datefield-base-state.ts new file mode 100644 index 000000000..dd18ffd25 --- /dev/null +++ b/src/datefield/datefield-base-state.ts @@ -0,0 +1,34 @@ +import { Calendar } from "@internationalized/date"; +import { DateFieldState, useDateFieldState } from "@react-stately/datepicker"; +import { + DatePickerProps, + DateValue, + Granularity, +} from "@react-types/datepicker"; + +export function useDateFieldBaseState( + props: DateFieldBaseStateProps, +): DateFieldBaseState { + const state = useDateFieldState(props); + + return state; +} + +export type DateFieldBaseState = DateFieldState & {}; + +export type DateFieldBaseStateProps = DatePickerProps & { + /** + * The maximum unit to display in the date field. + * @default 'year' + */ + maxGranularity?: "year" | "month" | Granularity; + /** The locale to display and edit the value according to. */ + locale: string; + /** + * A function that creates a [Calendar](../internationalized/date/Calendar.html) + * object for a given calendar identifier. Such a function may be imported from the + * `@internationalized/date` package, or manually implemented to include support for + * only certain calendars. + */ + createCalendar: (name: string) => Calendar; +}; diff --git a/src/datefield/datefield-base.ts b/src/datefield/datefield-base.ts new file mode 100644 index 000000000..c107ca451 --- /dev/null +++ b/src/datefield/datefield-base.ts @@ -0,0 +1,34 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DateFieldState } from "./datefield-state"; + +export const useDateField = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.fieldProps, props); + + return props; + }, +); + +export const DateField = createComponent(props => { + const htmlProps = useDateField(props); + + return createElement("div", htmlProps); +}); + +export type DateFieldOptions = Options & { + /** + * Object returned by the `useDateFieldBaseState` hook. + */ + state: DateFieldState; +}; + +export type DateFieldProps = Props>; diff --git a/src/datefield/datefield-state.ts b/src/datefield/datefield-state.ts new file mode 100644 index 000000000..088bbbcc0 --- /dev/null +++ b/src/datefield/datefield-state.ts @@ -0,0 +1,38 @@ +import { RefObject, useRef } from "react"; +import { DateValue } from "@internationalized/date"; +import { DateFieldAria, useDateField } from "@react-aria/datepicker"; +import { AriaDatePickerProps } from "@react-types/datepicker"; + +import { DateFieldBaseState } from "./datefield-base-state"; + +export function useDateFieldState({ + state, + ...props +}: DateFieldStateProps): DateFieldState { + const ref = useRef(null); + const datefield = useDateField(props, state, ref); + + return { ...datefield, ref }; +} + +export type DateFieldState = DateFieldAria & { + /** + * Reference for the date picker's visible label element, if any. + */ + ref: RefObject; +}; + +export type DateFieldStateProps = Omit< + AriaDatePickerProps, + | "value" + | "defaultValue" + | "onChange" + | "minValue" + | "maxValue" + | "placeholderValue" +> & { + /** + * Object returned by the `useDateFieldBaseState` hook. + */ + state: DateFieldBaseState; +}; diff --git a/src/datefield/index.ts b/src/datefield/index.ts new file mode 100644 index 000000000..868020dbd --- /dev/null +++ b/src/datefield/index.ts @@ -0,0 +1,4 @@ +export * from "./date-segment"; +export * from "./datefield-base"; +export * from "./datefield-base-state"; +export * from "./datefield-state"; diff --git a/src/datefield/stories/DateFieldBasic.component.tsx b/src/datefield/stories/DateFieldBasic.component.tsx new file mode 100644 index 000000000..328bf7d73 --- /dev/null +++ b/src/datefield/stories/DateFieldBasic.component.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { + DateField, + DateFieldBaseStateProps, + DateSegment, + useDateFieldBaseState, + useDateFieldState, +} from "../../index"; + +export type DateFieldBasicProps = DateFieldBaseStateProps & {}; + +export const DateFieldBasic: React.FC = props => { + const state = useDateFieldBaseState({ ...props }); + const datefield = useDateFieldState({ ...props, state }); + + return ( + + {state.segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); +}; + +export default DateFieldBasic; diff --git a/src/datefield/stories/DateFieldBasic.css b/src/datefield/stories/DateFieldBasic.css new file mode 100644 index 000000000..65203bbe4 --- /dev/null +++ b/src/datefield/stories/DateFieldBasic.css @@ -0,0 +1,19 @@ +* { + box-sizing: border-box; +} + +.datepicker__field { + font-family: monospace; + display: flex; +} + +.datepicker__field--item { + padding: 2px; + border-radius: 4px; +} + +.datepicker__field--item:focus { + background-color: #1e65fd; + color: white; + outline: none; +} diff --git a/src/datefield/stories/DateFieldBasic.stories.tsx b/src/datefield/stories/DateFieldBasic.stories.tsx new file mode 100644 index 000000000..82cbd0bfa --- /dev/null +++ b/src/datefield/stories/DateFieldBasic.stories.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/DateFieldBasicCss"; +import js from "./templates/DateFieldBasicJsx"; +import ts from "./templates/DateFieldBasicTsx"; +import { DateFieldBasic } from "./DateFieldBasic.component"; + +import "./DateFieldBasic.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "DateField/Basic", + component: DateFieldBasic, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ( + + ); +}; diff --git a/src/datefield/stories/DateFieldStyled.component.tsx b/src/datefield/stories/DateFieldStyled.component.tsx new file mode 100644 index 000000000..131b2444d --- /dev/null +++ b/src/datefield/stories/DateFieldStyled.component.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { + DateField, + DateFieldBaseStateProps, + DateSegment, + useDateFieldBaseState, + useDateFieldState, +} from "../../index"; + +export type DateFieldStyledProps = DateFieldBaseStateProps & {}; + +export const DateFieldStyled: React.FC = props => { + const state = useDateFieldBaseState({ ...props }); + const datefield = useDateFieldState({ ...props, state }); + + return ( + + {state.segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); +}; + +export default DateFieldStyled; diff --git a/src/datefield/stories/DateFieldStyled.stories.tsx b/src/datefield/stories/DateFieldStyled.stories.tsx new file mode 100644 index 000000000..e292924bd --- /dev/null +++ b/src/datefield/stories/DateFieldStyled.stories.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/DateFieldStyledJsx"; +import ts from "./templates/DateFieldStyledTsx"; +import { DateFieldStyled } from "./DateFieldStyled.component"; + +import "./tailwind.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "DateField/Styled", + component: DateFieldStyled, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts }), + }, + decorators: [ + Story => { + document.body.id = "tailwind"; + return ; + }, + ], +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ( + + ); +}; diff --git a/src/datefield/stories/tailwind.css b/src/datefield/stories/tailwind.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/src/datefield/stories/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/datepicker/DatePicker.ts b/src/datepicker/DatePicker.ts deleted file mode 100644 index bb2737f58..000000000 --- a/src/datepicker/DatePicker.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - PickerBaseHTMLProps, - PickerBaseOptions, - usePickerBase, -} from "../picker-base"; -import { createComponent, createHook } from "../system"; -import { ariaAttr } from "../utils"; - -import { DATE_PICKER_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "./DatePickerState"; - -export type DatePickerOptions = PickerBaseOptions & - Pick; - -export type DatePickerHTMLProps = PickerBaseHTMLProps; - -export type DatePickerProps = DatePickerOptions & DatePickerHTMLProps; - -export const useDatePicker = createHook( - { - name: "DatePicker", - compose: usePickerBase, - keys: DATE_PICKER_KEYS, - - useProps(options, htmlProps) { - const { validationState, isRequired } = options; - - return { - "aria-invalid": ariaAttr(validationState === "invalid"), - "aria-required": ariaAttr(isRequired), - ...htmlProps, - }; - }, - }, -); - -export const DatePicker = createComponent({ - as: "div", - memo: true, - useHook: useDatePicker, -}); diff --git a/src/datepicker/DatePickerContent.ts b/src/datepicker/DatePickerContent.ts deleted file mode 100644 index 7e5a54693..000000000 --- a/src/datepicker/DatePickerContent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - PickerBaseHTMLProps, - PickerBaseOptions, - usePickerBaseContent, -} from "../picker-base"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_CONTENT_KEYS } from "./__keys"; - -export type DatePickerContentOptions = PickerBaseOptions; - -export type DatePickerContentHTMLProps = PickerBaseHTMLProps; - -export type DatePickerContentProps = DatePickerContentOptions & - DatePickerContentHTMLProps; - -export const useDatePickerContent = createHook< - DatePickerContentOptions, - DatePickerContentHTMLProps ->({ - name: "DatePickerContent", - compose: usePickerBaseContent, - keys: DATE_PICKER_CONTENT_KEYS, -}); - -export const DatePickerContent = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerContent, -}); diff --git a/src/datepicker/DatePickerSegment.ts b/src/datepicker/DatePickerSegment.ts deleted file mode 100644 index 0dcfcc5ca..000000000 --- a/src/datepicker/DatePickerSegment.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { unstable_useId as useId } from "reakit"; - -import { SegmentHTMLProps, SegmentOptions, useSegment } from "../segment"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_SEGMENT_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "."; - -export type DatePickerSegmentOptions = SegmentOptions & - Partial>; - -export type DatePickerSegmentHTMLProps = SegmentHTMLProps; - -export type DatePickerSegmentProps = DatePickerSegmentOptions & - DatePickerSegmentHTMLProps; - -export const useDatePickerSegment = createHook< - DatePickerSegmentOptions, - DatePickerSegmentHTMLProps ->({ - name: "DatePickerSegment", - compose: useSegment, - keys: DATE_PICKER_SEGMENT_KEYS, - - useProps(options, htmlProps) { - const { id } = useId({ baseId: "datepicker-segment" }); - return { - id, - ...(options.isDateRangePicker - ? { "aria-labelledby": `${options.pickerId} ${options.baseId} ${id}` } - : { "aria-labelledby": id }), - ...htmlProps, - }; - }, -}); - -export const DatePickerSegment = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerSegment, -}); diff --git a/src/datepicker/DatePickerSegmentField.ts b/src/datepicker/DatePickerSegmentField.ts deleted file mode 100644 index f3df3338f..000000000 --- a/src/datepicker/DatePickerSegmentField.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - SegmentFieldHTMLProps, - SegmentFieldOptions, - useSegmentField, -} from "../segment"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_SEGMENT_FIELD_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "./DatePickerState"; -import { DateRangePickerStateReturn } from "./DateRangePickerState"; - -export type DatePickerSegmentFieldOptions = - | SegmentFieldOptions - | Partial - | Partial; - -export type DatePickerSegmentFieldHTMLProps = SegmentFieldHTMLProps; - -export type DatePickerSegmentFieldProps = DatePickerSegmentFieldOptions & - DatePickerSegmentFieldHTMLProps; - -export const useDatePickerSegmentField = createHook< - DatePickerSegmentFieldOptions, - DatePickerSegmentFieldHTMLProps ->({ - name: "DatePickerSegmentField", - compose: useSegmentField, - keys: DATE_PICKER_SEGMENT_FIELD_KEYS, -}); - -export const DatePickerSegmentField = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerSegmentField, -}); diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts deleted file mode 100644 index 977f061f8..000000000 --- a/src/datepicker/DatePickerState.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useDatePickerState](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/datepicker/src/useDatePickerState.ts) - * to work with Reakit System - */ - -import * as React from "react"; -import { Validation, ValidationState, ValueBase } from "@react-types/shared"; - -import { useCalendarState } from "../calendar"; -import { PickerBaseInitialState, usePickerBaseState } from "../picker-base"; -import { SegmentInitialState, useSegmentState } from "../segment"; -import { toUTCString, useControllableState } from "../utils"; -import { RangeValueBase } from "../utils/types"; - -export type DatePickerInitialState = ValueBase & - RangeValueBase & - Validation & - PickerBaseInitialState & - Pick, "formatOptions" | "placeholderDate"> & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - }; - -export const useDatePickerState = (props: DatePickerInitialState = {}) => { - const { - value: initialValue, - defaultValue = toUTCString(new Date()), - onChange, - minValue, - maxValue, - isRequired, - autoFocus, - formatOptions, - placeholderDate, - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const date = new Date(value); - const setDate = (date: Date) => setValue(toUTCString(date)); - const minDateValue = minValue ? new Date(minValue) : new Date(-864e13); - const maxDateValue = maxValue ? new Date(maxValue) : new Date(864e13); - - const segmentState = useSegmentState({ - value: date, - onChange: setDate, - formatOptions, - placeholderDate, - }); - - const popover = usePickerBaseState({ - segmentFocus: segmentState.first, - ...props, - }); - - const selectDate = (newValue: string) => { - setValue(newValue); - popover.hide(); - }; - - const calendar = useCalendarState({ - value, - onChange: selectDate, - minValue: minValue, - maxValue: maxValue, - }); - - const validationState: ValidationState = - props.validationState || (isInvalidDateRange(date) ? "invalid" : "valid"); - - React.useEffect(() => { - if (popover.visible) { - calendar.setFocused(true); - calendar.focusCell(date); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [popover.visible]); - - React.useEffect(() => { - if (autoFocus) { - segmentState.first(); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFocus, segmentState.first]); - - function isInvalidDateRange(value: Date) { - const min = new Date(minDateValue); - const max = new Date(maxDateValue); - - return value < min || value > max; - } - - return { - dateValue: value, - setDateValue: setValue, - selectDate, - validationState, - minValue, - maxValue, - isRequired, - ...popover, - ...segmentState, - calendar, - isDateRangePicker: false, - }; -}; - -export type DatePickerStateReturn = ReturnType; diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts deleted file mode 100644 index 6e3c2c16c..000000000 --- a/src/datepicker/DatePickerTrigger.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - PickerBaseTriggerHTMLProps, - PickerBaseTriggerOptions, - usePickerBaseTrigger, -} from "../picker-base"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_TRIGGER_KEYS } from "./__keys"; - -export type DatePickerTriggerOptions = PickerBaseTriggerOptions; - -export type DatePickerTriggerHTMLProps = PickerBaseTriggerHTMLProps; - -export type DatePickerTriggerProps = DatePickerTriggerOptions & - DatePickerTriggerHTMLProps; - -export const useDatePickerTrigger = createHook< - DatePickerTriggerOptions, - DatePickerTriggerHTMLProps ->({ - name: "DatePickerTrigger", - compose: usePickerBaseTrigger, - keys: DATE_PICKER_TRIGGER_KEYS, -}); - -export const DatePickerTrigger = createComponent({ - as: "button", - memo: true, - useHook: useDatePickerTrigger, -}); diff --git a/src/datepicker/DateRangePickerState.ts b/src/datepicker/DateRangePickerState.ts deleted file mode 100644 index d6eae664a..000000000 --- a/src/datepicker/DateRangePickerState.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useDatePickerState](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/datepicker/src/useDatePickerState.ts) - * to work with Reakit System - */ - -import * as React from "react"; -import { unstable_useId as useId, useCompositeState } from "reakit"; -import { - RangeValue, - Validation, - ValidationState, - ValueBase, -} from "@react-types/shared"; - -import { useRangeCalendarState } from "../calendar"; -import { makeRange } from "../calendar/helpers"; -import { PickerBaseInitialState, usePickerBaseState } from "../picker-base"; -import { SegmentInitialState, useSegmentState } from "../segment"; -import { - addDays, - toUTCRangeString, - toUTCString, - useControllableState, -} from "../utils"; -import { RangeValueBase } from "../utils/types"; - -export type DateRangePickerInitialState = ValueBase> & - RangeValueBase & - Validation & - PickerBaseInitialState & - Pick, "formatOptions" | "placeholderDate"> & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - }; - -export const useDateRangePickerState = ( - props: DateRangePickerInitialState = {}, -) => { - const { - value: initialValue, - defaultValue = { - start: toUTCString(new Date()), - end: toUTCString(addDays(new Date(), 1)), - }, - onChange, - minValue, - maxValue, - isRequired, - autoFocus, - formatOptions, - placeholderDate, - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const dateRange: RangeValue = React.useMemo( - () => ({ - start: new Date(value.start), - end: new Date(value.end), - }), - [value.end, value.start], - ); - const minDateValue = minValue ? new Date(minValue) : new Date(-864e13); - const maxDateValue = maxValue ? new Date(maxValue) : new Date(864e13); - - const selectDate = (date: RangeValue) => { - if (props.isReadOnly || props.isDisabled) { - return; - } - - setValue( - toUTCRangeString(makeRange(new Date(date.start), new Date(date.end))), - ); - - popover.hide(); - }; - - const segmentComposite = useCompositeState({ orientation: "horizontal" }); - - const startSegmentState = useSegmentState({ - value: dateRange.start, - defaultValue: new Date(defaultValue.start), - onChange: date => - setValue(toUTCRangeString({ start: date, end: dateRange.end })), - formatOptions, - placeholderDate, - }); - - const endSegmentState = useSegmentState({ - value: dateRange.end, - defaultValue: new Date(defaultValue.end), - onChange: date => - setValue(toUTCRangeString({ start: dateRange.start, end: date })), - formatOptions, - placeholderDate, - }); - - const popover = usePickerBaseState({ - segmentFocus: segmentComposite.first, - ...props, - }); - - const calendar = useRangeCalendarState({ - value: { start: value.start, end: value.end }, - onChange: selectDate, - minValue: minValue, - maxValue: maxValue, - }); - - function isInvalidDateRange(value: Date) { - const min = new Date(minDateValue); - const max = new Date(maxDateValue); - - return value < min || value > max; - } - - const isStartInRange = isInvalidDateRange(dateRange.start); - const isEndInRange = isInvalidDateRange(dateRange.end); - - const validationState: ValidationState = - props.validationState || - (value != null && - (isStartInRange || - isEndInRange || - (value.end != null && value.start != null && value.end < value.start)) - ? "invalid" - : "valid"); - - React.useEffect(() => { - if (popover.visible) { - calendar.setFocused(true); - value.start && calendar.focusCell(new Date(value.start)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [popover.visible]); - - React.useEffect(() => { - if (autoFocus) { - segmentComposite.first(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFocus, segmentComposite.first]); - - const { id: startId } = useId({ baseId: "startsegment" }); - const { id: endId } = useId({ baseId: "endsegment" }); - - return { - dateValue: value, - setDateValue: setValue, - selectDate, - validationState, - minValue, - maxValue, - isRequired, - ...popover, - startSegmentState: { - ...startSegmentState, - ...segmentComposite, - baseId: startId, - }, - endSegmentState: { - ...endSegmentState, - ...segmentComposite, - baseId: endId, - }, - calendar, - isDateRangePicker: true, - }; -}; - -export type DateRangePickerStateReturn = ReturnType< - typeof useDateRangePickerState ->; diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts deleted file mode 100644 index 0cd428b9f..000000000 --- a/src/datepicker/__keys.ts +++ /dev/null @@ -1,163 +0,0 @@ -// Automatically generated -export const USE_DATE_PICKER_STATE_KEYS = [ - "value", - "defaultValue", - "onChange", - "minValue", - "maxValue", - "validationState", - "isRequired", - "baseId", - "visible", - "animated", - "modal", - "placement", - "unstable_fixed", - "unstable_flip", - "unstable_offset", - "gutter", - "unstable_preventOverflow", - "isDisabled", - "isReadOnly", - "pickerId", - "dialogId", - "segmentFocus", - "formatOptions", - "placeholderDate", - "autoFocus", -] as const; -export const DATE_PICKER_STATE_KEYS = [ - "calendar", - "isDateRangePicker", - "fieldValue", - "setFieldValue", - "segments", - "dateFormatter", - "increment", - "decrement", - "incrementPage", - "decrementPage", - "setSegment", - "confirmPlaceholder", - "baseId", - "unstable_idCountRef", - "setBaseId", - "unstable_virtual", - "rtl", - "orientation", - "items", - "groups", - "currentId", - "loop", - "wrap", - "shift", - "unstable_moves", - "unstable_hasActiveWidget", - "unstable_includesBaseElement", - "registerItem", - "unregisterItem", - "registerGroup", - "unregisterGroup", - "move", - "next", - "previous", - "up", - "down", - "first", - "last", - "sort", - "unstable_setVirtual", - "setRTL", - "setOrientation", - "setCurrentId", - "setLoop", - "setWrap", - "setShift", - "reset", - "unstable_setIncludesBaseElement", - "unstable_setHasActiveWidget", - "visible", - "animated", - "animating", - "show", - "hide", - "toggle", - "setVisible", - "setAnimated", - "stopAnimation", - "modal", - "unstable_disclosureRef", - "setModal", - "unstable_referenceRef", - "unstable_popoverRef", - "unstable_arrowRef", - "unstable_popoverStyles", - "unstable_arrowStyles", - "unstable_originalPlacement", - "unstable_update", - "placement", - "place", - "pickerId", - "dialogId", - "isDisabled", - "isReadOnly", - "segmentFocus", - "dateValue", - "setDateValue", - "selectDate", - "validationState", - "minValue", - "maxValue", - "isRequired", -] as const; -export const USE_DATE_RANGE_PICKER_STATE_KEYS = USE_DATE_PICKER_STATE_KEYS; -export const DATE_RANGE_PICKER_STATE_KEYS = [ - "startSegmentState", - "endSegmentState", - "calendar", - "isDateRangePicker", - "baseId", - "unstable_idCountRef", - "visible", - "animated", - "animating", - "setBaseId", - "show", - "hide", - "toggle", - "setVisible", - "setAnimated", - "stopAnimation", - "modal", - "unstable_disclosureRef", - "setModal", - "unstable_referenceRef", - "unstable_popoverRef", - "unstable_arrowRef", - "unstable_popoverStyles", - "unstable_arrowStyles", - "unstable_originalPlacement", - "unstable_update", - "placement", - "place", - "pickerId", - "dialogId", - "isDisabled", - "isReadOnly", - "segmentFocus", - "dateValue", - "setDateValue", - "selectDate", - "validationState", - "minValue", - "maxValue", - "isRequired", -] as const; -export const DATE_PICKER_KEYS = [ - ...DATE_PICKER_STATE_KEYS, - ...DATE_RANGE_PICKER_STATE_KEYS, -] as const; -export const DATE_PICKER_CONTENT_KEYS = DATE_PICKER_KEYS; -export const DATE_PICKER_SEGMENT_KEYS = DATE_PICKER_CONTENT_KEYS; -export const DATE_PICKER_SEGMENT_FIELD_KEYS = DATE_PICKER_SEGMENT_KEYS; -export const DATE_PICKER_TRIGGER_KEYS = DATE_PICKER_SEGMENT_FIELD_KEYS; diff --git a/src/datepicker/__tests__/DatePicker.test.tsx b/src/datepicker/__tests__/DatePicker.test.tsx deleted file mode 100644 index f1d3638d0..000000000 --- a/src/datepicker/__tests__/DatePicker.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import * as React from "react"; -import { axe, press, render, screen } from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; - -import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarStateReturn, - CalendarWeekTitle, -} from "../../calendar"; -import { addWeeks, subWeeks, toUTCString } from "../../utils"; -import { repeat } from "../../utils/test-utils"; -import { - DatePicker, - DatePickerContent, - DatePickerInitialState, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, - useDatePickerState, -} from ".."; - -/* -// Mocking useId otherwise snapshots will change each time -// since useCalendarState uses useId. -jest.spyOn(reakit, "unstable_useId").mockImplementation(options => ({ - id: options.baseId + "myid" -})); -*/ -afterEach(cleanup); - -export const CalendarComp: React.FC = state => { - return ( - - - - {"<"} - - - {"<<"} - - - - {">>"} - - - {">"} - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - - ); -}; - -const DatePickerComp: React.FC = props => { - const state = useDatePickerState({ - baseId: "calendar", - dialogId: "dialog", - pickerId: "picker", - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); - - return ( - <> - - - - {state.segments.map((segment, i) => ( - - ))} - - - open - - - - - - > - ); -}; - -const openDatePicker = () => { - press.Tab(); - expect(screen.getByLabelText("month", { selector: "div" })).toHaveFocus(); - - press.ArrowDown(null, { altKey: true }); - expect(screen.getByTestId("testid-datepicker-content")).toBeVisible(); -}; - -describe("DatePicker", () => { - it("should open/close the datepicker", () => { - render(); - - const datepickerContent = screen.getByTestId("testid-datepicker-content"); - const segment = screen.getByTestId("testid-segment"); - const month = screen.getByRole("spinbutton", { - name: /month/i, - }); - - expect(segment).toHaveTextContent("11/01/2020"); - expect(datepickerContent).not.toBeVisible(); - - // open - openDatePicker(); - - // close - press.Escape(); - expect(datepickerContent).not.toBeVisible(); - expect(month).toHaveFocus(); - }); - - it("should be able to open and select date", () => { - render(); - - const segment = screen.getByTestId("testid-segment"); - const datepickerContent = screen.getByTestId("testid-datepicker-content"); - const month = screen.getByRole("spinbutton", { - name: /month/i, - }); - - // open - openDatePicker(); - - // assert focused date on calendar - expect( - screen.getByLabelText(/Sunday, November 1, 2020 selected/i), - ).toHaveFocus(); - - // go to 24 - repeat(press.ArrowDown, 3); - repeat(press.ArrowRight, 2); - - expect(screen.getByLabelText(/Tuesday, November 24, 2020/i)).toHaveFocus(); - - press.Enter(); - expect(datepickerContent).not.toBeVisible(); - expect(month).toHaveFocus(); - expect(segment).toHaveTextContent("11/24/2020"); - }); - - it("should be able to open and select date and jump to different dates", () => { - render(); - const segment = screen.getByTestId("testid-segment"); - const calendarHeader = screen.getByTestId("testid-calendar-header"); - const datepickerContent = screen.getByTestId("testid-datepicker-content"); - const month = screen.getByRole("spinbutton", { - name: /month/i, - }); - - // open - openDatePicker(); - - // assert focused date on calendar - expect( - screen.getByLabelText(/^Sunday, November 1, 2020 selected$/i), - ).toHaveFocus(); - - // jump month - expect(calendarHeader).toHaveTextContent(/November 2020/i); - repeat(press.PageDown, 2); - - expect(calendarHeader).toHaveTextContent(/January 2021/i); - - // jump year - expect(calendarHeader).toHaveTextContent(/January 2021/i); - repeat(() => { - press.PageDown(null, { shiftKey: true }); - }, 2); - expect(calendarHeader).toHaveTextContent(/January 2023/i); - - press.Enter(); - expect(datepickerContent).not.toBeVisible(); - expect(month).toHaveFocus(); - expect(segment).toHaveTextContent("01/01/2023"); - }); - - it("should work with AutoFocus", () => { - render( - // eslint-disable-next-line jsx-a11y/no-autofocus - , - ); - - expect( - screen.getByRole("spinbutton", { - name: /month/i, - }), - ).toHaveFocus(); - }); - - it("should be invalid on out of range value", () => { - render( - , - ); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - - it("should be disabled", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-disabled", - "true", - ); - }); - - it("should be readonly", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-readonly", - "true", - ); - }); - - test("DatePicker renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container, { - rules: { "nested-interactive": { enabled: false } }, - }); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/datepicker/__tests__/DateRangePicker.test.tsx b/src/datepicker/__tests__/DateRangePicker.test.tsx deleted file mode 100644 index af2dd297a..000000000 --- a/src/datepicker/__tests__/DateRangePicker.test.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import * as React from "react"; -import { axe, fireEvent, press, render, screen } from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; - -import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - RangeCalendarStateReturn, -} from "../../calendar"; -import { - isEndSelection, - isInSelectionRange, - isStartSelection, - repeat, -} from "../../utils/test-utils"; -import { - DateRangePickerInitialState, - useDateRangePickerState, -} from "../DateRangePickerState"; -import { - DatePicker, - DatePickerContent, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, -} from "../index"; - -afterEach(cleanup); - -const RangeCalendarComp: React.FC = state => { - return ( - - - - {"<<"} - - - {"<"} - - - - {">"} - - - {">>"} - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - - ); -}; - -const DateRangePickerComp: React.FC = props => { - const state = useDateRangePickerState({ - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); - - return ( - <> - - - - {state.startSegmentState.segments.map((segment, i) => ( - - ))} - - - - - {state.endSegmentState.segments.map((segment, i) => ( - - ))} - - open - - - - - - > - ); -}; - -const openDatePicker = () => { - fireEvent.click(screen.getByText(/open/i)); - - jest.advanceTimersByTime(1); - - expect(screen.getByTestId("testid-datepicker-content")).toBeVisible(); -}; - -describe("DateRangePicker", () => { - it("should select date ranges correctly", () => { - jest.useFakeTimers(); - - render( - , - ); - - openDatePicker(); - - expect( - screen.getByLabelText(/Sunday, November 15, 2020 selected/), - ).toHaveFocus(); - - isStartSelection( - screen.getByLabelText(/Sunday, November 15, 2020 selected/i), - ); - // check if current date is selected - isEndSelection( - screen.getByLabelText(/Sunday, November 15, 2020 selected/i), - ); - isInSelectionRange( - screen.getByLabelText(/Sunday, November 15, 2020 selected/i), - ); - - // change date selection - press.Enter(); - press.ArrowRight(); - press.ArrowRight(); - press.ArrowDown(); - - expect(screen.getByLabelText(/Tuesday, November 24, 2020/gi)).toHaveFocus(); - - isEndSelection(screen.getByLabelText(/Tuesday, November 24, 2020/gi)); - isStartSelection(screen.getByLabelText(/Sunday, November 15, 2020/gi)); - isInSelectionRange(screen.getByLabelText(/Wednesday, November 18, 2020/gi)); - - // Finish selection - press.Enter(); - expect(screen.getByTestId("testid-datepicker-content")).not.toBeVisible(); - expect(screen.getByTestId("testid-segment")).toHaveTextContent( - "11/15/2020 - 11/24/2020", - ); - - jest.useRealTimers(); - }); - - it("should be invalid on wrong date selection", () => { - render( - , - ); - - const datepicker = screen.getByTestId("testid-datepicker"); - - expect(datepicker).not.toHaveAttribute("aria-invalid"); - - // reverse dates are invalid - repeat(press.Tab, 4); - repeat(press.ArrowDown, 2); - - expect(document.activeElement).toHaveTextContent("09"); - expect(datepicker).toHaveAttribute("aria-invalid", "true"); - }); - - it("should be invalid if selection range is out of min max values", () => { - render( - , - ); - const datepicker = screen.getByTestId("testid-datepicker"); - - expect(datepicker).not.toHaveAttribute("aria-invalid"); - - repeat(press.Tab, 2); - press.ArrowUp(); - - expect(document.activeElement).toHaveTextContent("16"); - expect(datepicker).toHaveAttribute("aria-invalid", "true"); - }); - - it("should be disabled", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-disabled", - "true", - ); - }); - - it("should be readonly", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-readonly", - "true", - ); - }); - - it("should work with AutoFocus", () => { - render( - // eslint-disable-next-line jsx-a11y/no-autofocus - , - ); - - expect( - screen.getAllByLabelText("month", { selector: "div" })[0], - ).toHaveFocus(); - }); - - test("DateRangePicker renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container, { - rules: { "nested-interactive": { enabled: false } }, - }); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/datepicker/datepicker-base-state.ts b/src/datepicker/datepicker-base-state.ts new file mode 100644 index 000000000..316c15fd7 --- /dev/null +++ b/src/datepicker/datepicker-base-state.ts @@ -0,0 +1,32 @@ +import { DatePickerState, useDatePickerState } from "@react-stately/datepicker"; +import { DatePickerProps, DateValue } from "@react-types/datepicker"; +import { PopoverState, PopoverStateProps, usePopoverState } from "ariakit"; + +export function useDatePickerBaseState( + props: DatePickerBaseStateProps, +): DatePickerBaseState { + const datepicker = useDatePickerState(props); + const { isOpen, setOpen } = datepicker; + + const popover = usePopoverState({ + visible: isOpen, + setVisible: setOpen, + ...props, + }); + + return { datepicker, popover }; +} + +export type DatePickerBaseState = { + datepicker: DatePickerState; + popover: PopoverState; +}; + +export type DatePickerBaseStateProps = DatePickerProps & + PopoverStateProps & { + /** + * Determines whether the date picker popover should close automatically when a date is selected. + * @default true + */ + shouldCloseOnSelect?: boolean | (() => boolean); + }; diff --git a/src/datepicker/datepicker-disclosure.ts b/src/datepicker/datepicker-disclosure.ts new file mode 100644 index 000000000..6a8437f28 --- /dev/null +++ b/src/datepicker/datepicker-disclosure.ts @@ -0,0 +1,46 @@ +import { useRef } from "react"; +import { useButton } from "@react-aria/button"; +import { usePopoverDisclosure } from "ariakit"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DatePickerState } from "./datepicker-state"; +import { DateRangePickerState } from "./daterangepicker-state"; + +export const useDatePickerDisclosure = createHook( + ({ state, ...props }) => { + const ref = useRef(null); + props = { ...props, ref: useForkRef(ref, props.ref, state.ref) }; + + props = usePopoverDisclosure({ ...props, state: state.baseState.popover }); + + const { buttonProps } = useButton(state.buttonProps, ref); + props = { ...props, ...buttonProps }; + + return props; + }, +); + +export const DatePickerDisclosure = + createComponent(props => { + const htmlProps = useDatePickerDisclosure(props); + + return createElement("button", htmlProps); + }); + +export type DatePickerDisclosureOptions = + Options & { + /** + * Object returned by the `useDatePickerDisclosureState` hook. + */ + state: DatePickerState | DateRangePickerState; + }; + +export type DatePickerDisclosureProps = Props< + DatePickerDisclosureOptions +>; diff --git a/src/datepicker/datepicker-group.ts b/src/datepicker/datepicker-group.ts new file mode 100644 index 000000000..e89212d83 --- /dev/null +++ b/src/datepicker/datepicker-group.ts @@ -0,0 +1,39 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DatePickerState } from "./datepicker-state"; +import { DateRangePickerState } from "./daterangepicker-state"; + +export const useDatePickerGroup = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.groupProps, props); + + return props; + }, +); + +export const DatePickerGroup = createComponent( + props => { + const htmlProps = useDatePickerGroup(props); + + return createElement("div", htmlProps); + }, +); + +export type DatePickerGroupOptions = Options & { + /** + * Object returned by the `useDatePickerState` hook. + */ + state: DatePickerState | DateRangePickerState; +}; + +export type DatePickerGroupProps = Props< + DatePickerGroupOptions +>; diff --git a/src/datepicker/datepicker-label.ts b/src/datepicker/datepicker-label.ts new file mode 100644 index 000000000..30a18b3d7 --- /dev/null +++ b/src/datepicker/datepicker-label.ts @@ -0,0 +1,37 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DatePickerState } from "./datepicker-state"; +import { DateRangePickerState } from "./daterangepicker-state"; + +export const useDatePickerLabel = createHook
@@ -70,18 +66,14 @@ export const Accordion: React.FC = props => { - + Billing Address - + - + @@ -109,14 +101,14 @@ export const Accordion: React.FC = props => { - + Shipping Address - + - + @@ -142,7 +134,7 @@ export const Accordion: React.FC = props => { - + ); }; diff --git a/src/accordion/stories/AccordionStyled.stories.tsx b/src/accordion/stories/AccordionStyled.stories.tsx index eb1c8ab8b..7b785c286 100644 --- a/src/accordion/stories/AccordionStyled.stories.tsx +++ b/src/accordion/stories/AccordionStyled.stories.tsx @@ -1,49 +1,52 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; +import { createPreviewTabs } from "../../../.storybook/utils"; import css from "./templates/AccordionStyledCss"; import js from "./templates/AccordionStyledJsx"; import ts from "./templates/AccordionStyledTsx"; -import { Accordion } from "./AccordionStyled.component"; +import { AccordionStyled } from "./AccordionStyled.component"; import "./AccordionStyled.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: Accordion, + component: AccordionStyled, title: "Accordion/Styled", parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css }), }, - argTypes: createControls({ - ignore: [ - "selectedId", - "wrap", - "baseId", - "unstable_virtual", - "rtl", - "orientation", - "currentId", - "shift", - "unstable_includesBaseElement", - "onSelectedIdChange", - "shouldUpdate", - ], - }), } as Meta; -export const Default: Story = args => ; +export const Default: Story = {}; + +export const DefaultFirstIdSelected: Story = { + args: { shouldSelectFirstId: true }, +}; + +export const DefaultSelected: Story = { + args: { defaultSelectedId: "accordion2" }, +}; + +export const SelectOnMove: Story = { + args: { selectOnMove: true }, +}; -export const DefaultSelected = Default.bind({}); -DefaultSelected.args = { defaultSelectedId: "accordion2" }; +export const NoLoop: Story = { + args: { focusLoop: false }, +}; -export const AutoSelect = Default.bind({}); -AutoSelect.args = { manual: false }; +export const AllowToggle: Story = { + args: { allowToggle: true }, +}; -export const Loop = Default.bind({}); -Loop.args = { loop: true }; +export const DefaultFirstIdToggle: Story = { + args: { shouldSelectFirstId: true, allowToggle: true }, +}; -export const AllowToggle = Default.bind({}); -AllowToggle.args = { allowToggle: true }; +export const SelectOnMoveToggle: Story = { + args: { selectOnMove: true, allowToggle: true }, +}; diff --git a/src/accordion/stories/AccordionTutorial.component.tsx b/src/accordion/stories/AccordionTutorial.component.tsx deleted file mode 100644 index e9d0607b8..000000000 --- a/src/accordion/stories/AccordionTutorial.component.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import * as React from "react"; - -import { - Accordion as RenderlesskitAccordion, - AccordionInitialState, - AccordionPanel, - AccordionTrigger, - useAccordionState, -} from "../../index"; - -export const Accordion: React.FC = props => { - const initialProps = { - defaultSelectedId: "accordion3", - manual: true, - loop: true, - allowToggle: true, - }; - - const state = useAccordionState(initialProps || props); - - const [text, setText] = React.useState("Start Tutorial"); - - let stateRef = React.useRef(state); - stateRef.current = state; - - const runTutorial = async () => { - stateRef.current.first(); - stateRef.current.currentId != null && - stateRef.current.setSelectedId(stateRef.current.currentId); - setText("Moved to First Accordion & opened it"); - await sleep(3000); - - stateRef.current.currentId != null && stateRef.current.select("accordion3"); - setText("Selected Accordion 3"); - await sleep(3000); - - stateRef.current.next(); - stateRef.current.currentId != null && - stateRef.current.setSelectedId(stateRef.current.currentId); - setText("Moved to Next Accordion & opened it"); - await sleep(3000); - - stateRef.current.previous(); - setText("Moved to Previous Accordion"); - await sleep(1500); - - stateRef.current.previous(); - stateRef.current.currentId != null && - stateRef.current.setSelectedId(stateRef.current.currentId); - setText("Moved to Previous Accordion once more & opened it"); - await sleep(3000); - - stateRef.current.currentId != null && stateRef.current.select("accordion6"); - setText("Selected Accordion 6"); - await sleep(3000); - }; - - return ( - - - {text} - - - - Trigger 1 - - Panel 1 - - Trigger 2 - - Panel 2 - - - Trigger 3 - - - Panel 3 - - Trigger 4 - - Panel 4 - - - Trigger 5 - - - Panel 5 - - - Trigger 6 - - - Panel 6 - - - ); -}; - -export default Accordion; - -function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/src/accordion/stories/AccordionTutorial.stories.tsx b/src/accordion/stories/AccordionTutorial.stories.tsx deleted file mode 100644 index 3bed700f2..000000000 --- a/src/accordion/stories/AccordionTutorial.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; - -import js from "./templates/AccordionTutorialJsx"; -import ts from "./templates/AccordionTutorialTsx"; -import Accordion from "./AccordionTutorial.component"; - -export default { - component: Accordion, - title: "Accordion/Tutorial", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts }), - }, - argTypes: createControls({ - ignore: [ - "selectedId", - "wrap", - "baseId", - "unstable_virtual", - "rtl", - "orientation", - "currentId", - "shift", - "unstable_includesBaseElement", - "onSelectedIdChange", - "shouldUpdate", - ], - }), -} as Meta; - -export const Default: Story = args => ; diff --git a/src/breadcrumbs/BreadcrumbLink.ts b/src/breadcrumbs/BreadcrumbLink.ts deleted file mode 100644 index 99620a8fd..000000000 --- a/src/breadcrumbs/BreadcrumbLink.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { LinkHTMLProps, LinkOptions, useLink } from "../link"; -import { createComponent, createHook } from "../system"; - -import { BREADCRUMB_LINK_KEYS } from "./__keys"; - -export const useBreadcrumbLink = createHook< - BreadcrumbLinkOptions, - BreadcrumbLinkHTMLProps ->({ - name: "BreadcrumbLink", - compose: useLink, - keys: BREADCRUMB_LINK_KEYS, - - useProps({ isCurrent }, htmlProps) { - return { "aria-current": isCurrent && "page", ...htmlProps }; - }, -}); - -export const BreadcrumbLink = createComponent({ - as: "a", - memo: true, - useHook: useBreadcrumbLink, -}); - -export type BreadcrumbLinkOptions = { - /** - * If true, sets `aria-current: "page"` - */ - isCurrent?: boolean; -} & LinkOptions; - -export type BreadcrumbLinkHTMLProps = LinkHTMLProps; - -export type BreadcrumbLinkProps = BreadcrumbLinkOptions & - BreadcrumbLinkHTMLProps; diff --git a/src/breadcrumbs/Breadcrumbs.ts b/src/breadcrumbs/Breadcrumbs.ts deleted file mode 100644 index f533e53d1..000000000 --- a/src/breadcrumbs/Breadcrumbs.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createComponent, createHook, useCreateElement } from "reakit-system"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { useWarning } from "reakit-warning"; - -export const useBreadcrumbs = createHook< - BreadcrumbsOptions, - BreadcrumbsHTMLProps ->({ - name: "Breadcrumb", - compose: useRole, -}); - -export const Breadcrumbs = createComponent({ - as: "nav", - memo: true, - useHook: useBreadcrumbs, - useCreateElement: (type, props, children) => { - useWarning( - !props["aria-label"] && !props["aria-labelledby"], - "You should provide either `aria-label` or `aria-labelledby` props.", - "See https://www.w3.org/TR/wai-aria-practices-1.1/#wai-aria-roles-states-and-properties-2", - ); - return useCreateElement(type, props, children); - }, -}); - -export type BreadcrumbsOptions = RoleOptions; - -export type BreadcrumbsHTMLProps = RoleHTMLProps; - -export type BreadcrumbProps = BreadcrumbsOptions & BreadcrumbsHTMLProps; diff --git a/src/breadcrumbs/__keys.ts b/src/breadcrumbs/__keys.ts deleted file mode 100644 index a554edec2..000000000 --- a/src/breadcrumbs/__keys.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Automatically generated -export const BREADCRUMB_LINK_KEYS = ["isCurrent"] as const; -export const BREADCRUMBS_KEYS = [] as const; diff --git a/src/breadcrumbs/__tests__/Breadcrumbs.test.tsx b/src/breadcrumbs/__tests__/Breadcrumbs.test.tsx deleted file mode 100644 index 619841218..000000000 --- a/src/breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; -import { axe, render } from "reakit-test-utils"; - -import { BreadcrumbLink, Breadcrumbs } from "../index"; - -const BreadcrumbComp = () => { - return ( - - - - - WAI-ARIA Authoring Practices 1.1 - - - - - Design Patterns - - - - - Breadcrumb Pattern - - - - - Breadcrumb Example - - - - - ); -}; - -describe("Breadcrumb", () => { - it("should render correctly", () => { - const { asFragment } = render(); - - expect(asFragment()).toMatchSnapshot(); - }); - - test("Breadcrumb renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap b/src/breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap deleted file mode 100644 index 91e68d16a..000000000 --- a/src/breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Breadcrumb should render correctly 1`] = ` - - - - - - WAI-ARIA Authoring Practices 1.1 - - - - - Design Patterns - - - - - Breadcrumb Pattern - - - - - Breadcrumb Example - - - - - -`; diff --git a/src/breadcrumbs/breadcrumb-link.ts b/src/breadcrumbs/breadcrumb-link.ts new file mode 100644 index 000000000..b0cc4c5c4 --- /dev/null +++ b/src/breadcrumbs/breadcrumb-link.ts @@ -0,0 +1,39 @@ +import { CommandOptions } from "ariakit"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Props } from "ariakit-utils/types"; + +import { useLink } from "../link"; + +export const useBreadcrumbLink = createHook( + ({ isCurrentPage, ...props }) => { + props = { + "aria-current": isCurrentPage && "page", + ...props, + }; + + props = useLink(props); + + return props; + }, +); + +export const BreadcrumbLink = createComponent(props => { + const htmlProps = useBreadcrumbLink(props); + + return createElement("a", htmlProps); +}); + +export type BreadcrumbLinkOptions = CommandOptions & { + /** + * If true, sets `aria-current: "page"` + */ + isCurrentPage?: boolean; +}; + +export type BreadcrumbLinkProps = Props< + BreadcrumbLinkOptions +>; diff --git a/src/breadcrumbs/breadcrumbs-base.ts b/src/breadcrumbs/breadcrumbs-base.ts new file mode 100644 index 000000000..17af2c3a7 --- /dev/null +++ b/src/breadcrumbs/breadcrumbs-base.ts @@ -0,0 +1,27 @@ +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +export const useBreadcrumbs = createHook(({ ...props }) => { + props = { + "aria-label": "breadcrumbs", + ...props, + }; + + return props; +}); + +export const Breadcrumbs = createComponent(props => { + const htmlProps = useBreadcrumbs(props); + + return createElement("nav", htmlProps); +}); + +export type BreadcrumbsOptions = Options & {}; + +export type BreadcrumbsProps = Props< + BreadcrumbsOptions +>; diff --git a/src/breadcrumbs/index.ts b/src/breadcrumbs/index.ts index ce9ce43cc..1797d71ad 100644 --- a/src/breadcrumbs/index.ts +++ b/src/breadcrumbs/index.ts @@ -1,3 +1,2 @@ -export * from "./__keys"; -export * from "./BreadcrumbLink"; -export * from "./Breadcrumbs"; +export * from "./breadcrumb-link"; +export * from "./breadcrumbs-base"; diff --git a/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx b/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx index 54a3984cf..5a88c8647 100644 --- a/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx +++ b/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx @@ -1,13 +1,12 @@ import * as React from "react"; -import { - BreadcrumbLink, - Breadcrumbs as RenderlesskitBreadcrumbs, -} from "../../index"; +import { BreadcrumbLink, Breadcrumbs, BreadcrumbsProps } from "../../index"; -export const Breadcrumbs = () => { +export type BreadcrumbsBasicProps = BreadcrumbsProps & {}; + +export const BreadcrumbsBasic: React.FC = props => { return ( - + @@ -21,7 +20,7 @@ export const Breadcrumbs = () => { Breadcrumb Pattern @@ -33,8 +32,8 @@ export const Breadcrumbs = () => { - + ); }; -export default Breadcrumbs; +export default BreadcrumbsBasic; diff --git a/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx b/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx index 27a4cfbbf..9fffe76a2 100644 --- a/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx +++ b/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx @@ -1,23 +1,23 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import css from "./templates/BreadcrumbsBasicCss"; import js from "./templates/BreadcrumbsBasicJsx"; import ts from "./templates/BreadcrumbsBasicTsx"; -import Breadcrumbs from "./BreadcrumbsBasic.component"; +import { BreadcrumbsBasic } from "./BreadcrumbsBasic.component"; import "./BreadcrumbsBasic.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: Breadcrumbs, title: "Breadcrumbs/Basic", + component: BreadcrumbsBasic, parameters: { layout: "centered", - preview: createPreviewTabs({ js, ts, css }), - options: { showPanel: false }, + preview: createPreviewTabs({ js, ts }), }, } as Meta; -export const Default: Story = args => ; +export const Default: Story = {}; diff --git a/src/calendar/Calendar.ts b/src/calendar/Calendar.ts deleted file mode 100644 index 98ba93a9c..000000000 --- a/src/calendar/Calendar.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendar = createHook({ - name: "Calendar", - compose: useRole, - keys: CALENDAR_KEYS, - - useProps({ calendarId }, htmlProps) { - return { - role: "group", - "aria-labelledby": calendarId, - ...htmlProps, - }; - }, -}); - -export const Calendar = createComponent({ - as: "div", - memo: true, - useHook: useCalendar, -}); - -export type CalendarOptions = RoleOptions & - Pick; - -export type CalendarHTMLProps = RoleHTMLProps; - -export type CalendarProps = CalendarOptions & CalendarHTMLProps; diff --git a/src/calendar/CalendarButton.ts b/src/calendar/CalendarButton.ts deleted file mode 100644 index a74265945..000000000 --- a/src/calendar/CalendarButton.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_BUTTON_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendarButton = createHook< - CalendarButtonOptions, - CalendarButtonHTMLProps ->({ - name: "CalendarButton", - compose: useButton, - keys: CALENDAR_BUTTON_KEYS, - - useProps(options, { onClick: htmlOnClick, ...htmlProps }) { - const { - focusNextMonth, - focusPreviousMonth, - focusPreviousYear, - focusNextYear, - goto, - } = options; - - const HANDLER_TYPES = { - nextMonth: { - handler: focusNextMonth, - ariaLabel: "Next Month", - }, - previousMonth: { - handler: focusPreviousMonth, - ariaLabel: "Previous Month", - }, - nextYear: { - handler: focusNextYear, - ariaLabel: "Next Year", - }, - previousYear: { - handler: focusPreviousYear, - ariaLabel: "Previous Year", - }, - }; - - return { - "aria-label": HANDLER_TYPES[goto]?.ariaLabel, - onClick: callAllHandlers(htmlOnClick, HANDLER_TYPES[goto]?.handler), - ...htmlProps, - }; - }, -}); - -export const CalendarButton = createComponent({ - as: "button", - memo: true, - useHook: useCalendarButton, -}); - -export type CalendarButtonOptions = ButtonOptions & - Pick< - CalendarStateReturn, - | "focusNextMonth" - | "focusPreviousMonth" - | "focusPreviousYear" - | "focusNextYear" - > & { - goto: CalendarGoto; - }; - -export type CalendarButtonHTMLProps = ButtonHTMLProps; - -export type CalendarButtonProps = CalendarButtonOptions & - CalendarButtonHTMLProps; - -export type CalendarGoto = - | "nextMonth" - | "previousMonth" - | "nextYear" - | "previousYear"; diff --git a/src/calendar/CalendarCell.ts b/src/calendar/CalendarCell.ts deleted file mode 100644 index 863ea2bbc..000000000 --- a/src/calendar/CalendarCell.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { useCallback } from "react"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; -import { - ariaAttr, - dataAttr, - getDaysInMonth, - isSameDay, - isWeekend, -} from "../utils"; - -import { CALENDAR_CELL_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; -import { RangeCalendarStateReturn } from "./RangeCalendarState"; - -export const useCalendarCell = createHook< - CalendarCellOptions, - CalendarCellHTMLProps ->({ - name: "CalendarCell", - compose: useRole, - keys: CALENDAR_CELL_KEYS, - - useProps(options, { onMouseEnter: htmlOnMouseEnter, ...htmlProps }) { - const { isDisabled, highlightDate, date } = options; - const onMouseEnter = useCallback(() => { - if (isDisabled) return; - - highlightDate?.(date); - }, [date, highlightDate, isDisabled]); - - return { - role: "gridcell", - "data-weekend": dataAttr(isWeekend(date)), - onMouseEnter: options.isRangeCalendar - ? callAllHandlers(htmlOnMouseEnter, onMouseEnter) - : htmlOnMouseEnter, - ...getCalendarCellProps(options), - ...htmlProps, - }; - }, -}); - -export const CalendarCell = createComponent({ - as: "div", - memo: true, - useHook: useCalendarCell, -}); - -const getCalendarCellProps = (options: CalendarCellOptions) => { - const { date, dateValue, highlightedRange, currentMonth } = options; - - if (options.isRangeCalendar) { - const isSelected = highlightedRange - ? date >= highlightedRange.start && date <= highlightedRange.end - : false; - - const isRangeStart = isSelected && date.getDate() === 1; - const isRangeEnd = - isSelected && date.getDate() === getDaysInMonth(currentMonth); - const isSelectionStart = highlightedRange - ? isSameDay(date, highlightedRange.start) - : false; - const isSelectionEnd = highlightedRange - ? isSameDay(date, highlightedRange.end) - : false; - - return { - "aria-selected": ariaAttr(isSelected), - "data-is-range-selection": dataAttr(isSelected), - "data-is-range-end": dataAttr(isRangeEnd), - "data-is-range-start": dataAttr(isRangeStart), - "data-is-selection-end": dataAttr(isSelectionEnd), - "data-is-selection-start": dataAttr(isSelectionStart), - }; - } - - const isSelected = dateValue ? isSameDay(date, dateValue) : false; - - return { - "aria-selected": ariaAttr(isSelected), - }; -}; - -export type CalendarCellOptions = RoleOptions & - Pick< - CalendarStateReturn, - "dateValue" | "isDisabled" | "currentMonth" | "isRangeCalendar" - > & - Partial< - Pick - > & { - date: Date; - }; - -export type CalendarCellHTMLProps = RoleHTMLProps; - -export type CalendarCellProps = CalendarCellOptions & CalendarCellHTMLProps; diff --git a/src/calendar/CalendarCellButton.ts b/src/calendar/CalendarCellButton.ts deleted file mode 100644 index 0caa8aa42..000000000 --- a/src/calendar/CalendarCellButton.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import * as React from "react"; -import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; -import { ensureFocus, useForkRef } from "reakit-utils"; -import { callAllHandlers } from "@chakra-ui/utils"; -import { useDateFormatter } from "@react-aria/i18n"; - -import { createComponent, createHook } from "../system"; -import { isSameDay } from "../utils"; - -import { CALENDAR_CELL_BUTTON_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; -import { RangeCalendarStateReturn } from "./RangeCalendarState"; - -export const useCalendarCellButton = createHook< - CalendarCellButtonOptions, - CalendarCellButtonHTMLProps ->({ - name: "CalendarCellButton", - compose: useButton, - keys: CALENDAR_CELL_BUTTON_KEYS, - - useOptions(options, { disabled }) { - const { - isDisabled: isDisabledOption, - date, - month, - isInvalidDateRange, - } = options; - const isCurrentMonth = date.getMonth() === month; - const isDisabled = - isDisabledOption || !isCurrentMonth || isInvalidDateRange(date); - const truelyDisabled = disabled || isDisabled; - - return { disabled: truelyDisabled, ...options }; - }, - - useProps( - options, - { onFocus: htmlOnFocus, onClick: htmlOnClick, ref: htmlRef, ...htmlProps }, - ) { - const { - date, - disabled, - dateValue, - selectDate, - anchorDate, - focusedDate, - isDisabled, - setFocusedDate, - isFocused: isFocusedOption, - } = options; - - const ref = React.useRef(null); - const isSelected = dateValue ? isSameDay(date, dateValue) : false; - const isFocused = - isFocusedOption && focusedDate && isSameDay(date, focusedDate); - const isToday = isSameDay(date, new Date()); - - // Focus the button in the DOM when the state updates. - React.useEffect(() => { - if (isFocused && ref.current) { - ensureFocus(ref.current); - } - }, [date, focusedDate, isFocused, ref]); - - const onClick = React.useCallback(() => { - if (disabled) return; - - selectDate(date); - setFocusedDate(date); - }, [date, disabled, selectDate, setFocusedDate]); - - const onFocus = React.useCallback(() => { - if (disabled) return; - - setFocusedDate(date); - }, [date, disabled, setFocusedDate]); - - const dateFormatter = useDateFormatter({ - weekday: "long", - day: "numeric", - month: "long", - year: "numeric", - }); - - // aria-label should be localize Day of week, Month, Day and Year without Time. - function getAriaLabel() { - let ariaLabel = dateFormatter.format(date); - const isTodayLabel = isToday ? "Today, " : ""; - const isSelctedLabel = isSelected ? " selected" : ""; - ariaLabel = `${isTodayLabel}${ariaLabel}${isSelctedLabel}`; - - // When a cell is focused and this is a range calendar, add a prompt to help - // screenreader users know that they are in a range selection mode. - if (options.isRangeCalendar && isFocused && !isDisabled) { - let rangeSelectionPrompt = ""; - - // If selection has started add "click to finish selecting range" - if (anchorDate) { - rangeSelectionPrompt = "click to finish selecting range"; - // Otherwise, add "click to start selecting range" prompt - } else { - rangeSelectionPrompt = "click to start selecting range"; - } - - // Append to aria-label - if (rangeSelectionPrompt) { - ariaLabel = `${ariaLabel} (${rangeSelectionPrompt})`; - } - } - - return ariaLabel; - } - - return { - children: useDateFormatter({ day: "numeric" }).format(date), - "aria-label": getAriaLabel(), - tabIndex: !disabled ? (isSameDay(date, focusedDate) ? 0 : -1) : undefined, - ref: useForkRef(ref, htmlRef), - onClick: callAllHandlers(htmlOnClick, onClick), - onFocus: callAllHandlers(htmlOnFocus, onFocus), - ...htmlProps, - }; - }, -}); - -export const CalendarCellButton = createComponent({ - as: "span", - memo: true, - useHook: useCalendarCellButton, -}); - -export type CalendarCellButtonOptions = ButtonOptions & - Partial> & - Pick< - CalendarStateReturn, - | "focusedDate" - | "selectDate" - | "setFocusedDate" - | "isDisabled" - | "month" - | "dateValue" - | "isFocused" - | "isRangeCalendar" - | "isInvalidDateRange" - > & { - date: Date; - }; - -export type CalendarCellButtonHTMLProps = ButtonHTMLProps; - -export type CalendarCellButtonProps = CalendarCellButtonOptions & - CalendarCellButtonHTMLProps; diff --git a/src/calendar/CalendarGrid.ts b/src/calendar/CalendarGrid.ts deleted file mode 100644 index 7658fc400..000000000 --- a/src/calendar/CalendarGrid.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useWeekStart](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/calendar/src/useWeekStart.ts) - * to work with Reakit System - */ -import { KeyboardEvent, useRef } from "react"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { createOnKeyDown, useForkRef } from "reakit-utils"; -import { callAllHandlers } from "@chakra-ui/utils"; -import { chain } from "@react-aria/utils"; - -import { createComponent, createHook } from "../system"; -import { ariaAttr } from "../utils"; - -import { CALENDAR_GRID_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; -import { RangeCalendarStateReturn } from "./RangeCalendarState"; - -export const useCalendarGrid = createHook< - CalendarGridOptions, - CalendarGridHTMLProps ->({ - name: "CalendarGrid", - compose: useRole, - keys: CALENDAR_GRID_KEYS, - - useProps( - options, - { - ref: htmlRef, - onKeyDown: htmlOnKeyDown, - onBlur: htmlOnFocus, - onBlur: htmlOnBlur, - ...htmlProps - }, - ) { - const { - isReadOnly, - isDisabled, - setFocused, - selectFocusedDate, - focusPreviousYear, - focusPreviousMonth, - focusNextYear, - focusNextMonth, - focusEndOfMonth, - focusStartOfMonth, - focusNextDay, - focusPreviousDay, - focusNextWeek, - focusPreviousWeek, - calendarId, - setAnchorDate, - } = options; - const ref = useRef(null); - - const onKeyDown = createOnKeyDown({ - onKeyDown: htmlOnKeyDown, - preventDefault: true, - keyMap: (event: KeyboardEvent) => { - const shift = event.shiftKey; - - return { - " ": selectFocusedDate, - Enter: selectFocusedDate, - End: focusEndOfMonth, - Home: focusStartOfMonth, - ArrowLeft: focusPreviousDay, - ArrowUp: focusPreviousWeek, - ArrowRight: focusNextDay, - ArrowDown: focusNextWeek, - PageUp: () => { - shift ? focusPreviousYear() : focusPreviousMonth(); - }, - PageDown: () => { - shift ? focusNextYear() : focusNextMonth(); - }, - }; - }, - }); - - let rangeCalendarProps = {}; - - if (options.isRangeCalendar) { - const onRangeKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case "Escape": - // Cancel the selection. - setAnchorDate?.(null); - break; - } - }; - - rangeCalendarProps = { - "aria-multiselectable": true, - onKeyDown: callAllHandlers( - htmlOnKeyDown, - chain(onKeyDown, onRangeKeyDown), - ), - }; - } - - return { - ref: useForkRef(ref, htmlRef), - role: "grid", - "aria-labelledby": calendarId, - "aria-readonly": ariaAttr(isReadOnly), - "aria-disabled": ariaAttr(isDisabled), - onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), - onFocus: callAllHandlers(htmlOnFocus, () => setFocused(true)), - onBlur: callAllHandlers(htmlOnBlur, () => setFocused(false)), - ...rangeCalendarProps, - ...htmlProps, - }; - }, -}); - -export const CalendarGrid = createComponent({ - as: "div", - memo: true, - useHook: useCalendarGrid, -}); - -export type CalendarGridOptions = RoleOptions & - Pick< - CalendarStateReturn, - | "calendarId" - | "isReadOnly" - | "isDisabled" - | "setFocused" - | "selectFocusedDate" - | "focusPreviousYear" - | "focusPreviousMonth" - | "focusNextYear" - | "focusNextMonth" - | "focusEndOfMonth" - | "focusStartOfMonth" - | "focusNextDay" - | "focusPreviousDay" - | "focusNextWeek" - | "focusPreviousWeek" - | "isRangeCalendar" - > & - Partial>; - -export type CalendarGridHTMLProps = RoleHTMLProps; - -export type CalendarGridProps = CalendarGridOptions & CalendarGridHTMLProps; diff --git a/src/calendar/CalendarHeader.ts b/src/calendar/CalendarHeader.ts deleted file mode 100644 index 071f959ee..000000000 --- a/src/calendar/CalendarHeader.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { useDateFormatter } from "@react-aria/i18n"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_HEADER_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendarHeader = createHook< - CalendarHeaderOptions, - CalendarHeaderHTMLProps ->({ - name: "CalendarHeader", - compose: useRole, - keys: CALENDAR_HEADER_KEYS, - - useProps( - { format = { month: "long", year: "numeric" }, currentMonth, calendarId }, - htmlProps, - ) { - return { - id: calendarId, - children: useDateFormatter(format).format(currentMonth), - "aria-live": "polite", - ...htmlProps, - }; - }, -}); - -export const CalendarHeader = createComponent({ - as: "h2", - memo: true, - useHook: useCalendarHeader, -}); - -export type CalendarHeaderOptions = RoleOptions & - Pick & { - format?: Intl.DateTimeFormatOptions; - }; - -export type CalendarHeaderHTMLProps = RoleHTMLProps; - -export type CalendarHeaderProps = CalendarHeaderOptions & - CalendarHeaderHTMLProps; diff --git a/src/calendar/CalendarState.ts b/src/calendar/CalendarState.ts deleted file mode 100644 index 22e2a14b2..000000000 --- a/src/calendar/CalendarState.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useCalendarState](https://github.com/adobe/react-spectrum/tree/main/packages/%40react-stately/calendar) - * to work with Reakit System - */ - -import * as React from "react"; -import { unstable_useId as useId } from "reakit"; -import { useUpdateEffect } from "@chakra-ui/hooks"; -import { useDateFormatter } from "@react-aria/i18n"; -import { InputBase } from "@react-types/shared"; - -import { - addDays, - addMonths, - addWeeks, - addYears, - endOfMonth, - getDaysInMonth, - isSameMonth, - startOfDay, - startOfMonth, - subDays, - subMonths, - subWeeks, - subYears, - toUTCString, - useControllableState, -} from "../utils"; -import { announce } from "../utils/LiveAnnouncer"; - -import { generateDaysInMonthArray, useWeekDays, useWeekStart } from "./helpers"; - -export function useCalendarState( - props: CalendarInitialState = {}, -): CalendarStateReturn { - const { - value: initialValue, - defaultValue = toUTCString(new Date()), - onChange, - minValue, - maxValue, - isDisabled = false, - isReadOnly = false, - autoFocus = false, - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const date = React.useMemo(() => new Date(value), [value]); - const minDateValue = React.useMemo( - () => (minValue ? new Date(minValue) : new Date(-864e13)), - [minValue], - ); - const maxDateValue = React.useMemo( - () => (maxValue ? new Date(maxValue) : new Date(864e13)), - [maxValue], - ); - const [currentMonth, setCurrentMonth] = React.useState(date); - const [focusedDate, setFocusedDate] = React.useState(date); - const [isFocused, setFocused] = React.useState(autoFocus); - const month = currentMonth.getMonth(); - const year = currentMonth.getFullYear(); - const weekStart = useWeekStart(); - const weekDays = useWeekDays(weekStart); - - let monthStartsAt = (startOfMonth(currentMonth).getDay() - weekStart) % 7; - if (monthStartsAt < 0) { - monthStartsAt += 7; - } - - const days = getDaysInMonth(currentMonth); - const weeksInMonth = Math.ceil((monthStartsAt + days) / 7); - - // Get 2D Date arrays in 7 days a week format - const daysInMonth = React.useMemo( - () => generateDaysInMonthArray(month, monthStartsAt, weeksInMonth, year), - [month, monthStartsAt, weeksInMonth, year], - ); - - function isInvalidDateRange(value: Date) { - const min = new Date(minDateValue); - const max = new Date(maxDateValue); - - return value < min || value > max; - } - - // Sets focus to a specific cell date - function focusCell(date: Date) { - if (isInvalidDateRange(date)) return; - - if (!isSameMonth(date, currentMonth)) { - setCurrentMonth(startOfMonth(date)); - } - - setFocusedDate(date); - } - - const dateFormatter = useDateFormatter({ dateStyle: "full" }); - - const announceSelectedDate = React.useCallback( - (value: Date) => { - if (!value) return; - - announce(`Selected Date: ${dateFormatter.format(value)}`); - }, - [dateFormatter], - ); - - const setDate = React.useCallback( - (value: Date) => { - if (!isDisabled && !isReadOnly) { - setValue(toUTCString(value)); - announceSelectedDate(value); - } - }, - [announceSelectedDate, isDisabled, isReadOnly, setValue], - ); - - // TODO - // This runs only once when the component is mounted - // Controlled state doesn't change the claender position - // React.useEffect(() => { - // const clampedDate = clamp(date, { - // start: minDateValue, - // end: maxDateValue, - // }); - // setDate(clampedDate); - // setCurrentMonth(clampedDate); - // setFocusedDate(clampedDate); - // }, [date, maxDateValue, minDateValue, setDate]); - - const monthFormatter = useDateFormatter({ month: "long", year: "numeric" }); - - // Announce when the current month changes - useUpdateEffect(() => { - // announce the new month with a change from the Previous or Next button - if (!isFocused) { - announce(monthFormatter.format(currentMonth)); - } - // handle an update to the current month from the Previous or Next button - // rather than move focus, we announce the new month value - }, [currentMonth]); - - const { id: calendarId } = useId({ id: props.id, baseId: "calendar" }); - - return { - dateValue: date, - setDateValue: setDate, - calendarId, - month, - year, - weekStart, - weekDays, - daysInMonth, - isDisabled, - isFocused, - isReadOnly, - setFocused, - currentMonth, - setCurrentMonth, - focusedDate, - focusCell, - setFocusedDate, - focusNextDay() { - focusCell(addDays(focusedDate, 1)); - }, - focusPreviousDay() { - focusCell(subDays(focusedDate, 1)); - }, - focusNextWeek() { - focusCell(addWeeks(focusedDate, 1)); - }, - focusPreviousWeek() { - focusCell(subWeeks(focusedDate, 1)); - }, - focusNextMonth() { - focusCell(addMonths(focusedDate, 1)); - }, - focusPreviousMonth() { - focusCell(subMonths(focusedDate, 1)); - }, - focusStartOfMonth() { - focusCell(startOfMonth(focusedDate)); - }, - focusEndOfMonth() { - focusCell(endOfMonth(startOfDay(focusedDate))); - }, - focusNextYear() { - focusCell(addYears(focusedDate, 1)); - }, - focusPreviousYear() { - focusCell(subYears(focusedDate, 1)); - }, - selectFocusedDate() { - setDate(focusedDate); - }, - selectDate(date: Date) { - setDate(date); - }, - isInvalidDateRange, - isRangeCalendar: false, - }; -} - -export type CalendarState = { - /** - * Id for the Calendar Header - */ - calendarId: string | undefined; - /** - * Selected Date value - */ - dateValue: Date; - /** - * Month of the current date value - */ - month: number; - /** - * Year of the current date value - */ - year: number; - /** - * Start of the week for the current date value - */ - weekStart: number; - /** - * Generated week days for CalendarWeekTitle based on weekStart - */ - weekDays: { - title: string; - abbr: string; - }[]; - /** - * Generated days in the current month - */ - daysInMonth: Date[][]; - /** - * `true` if the calendar is disabled - */ - isDisabled: boolean; - /** - * `true` if the calendar is focused - */ - isFocused: boolean; - /** - * `true` if the calendar is only readonly - */ - isReadOnly: boolean; - /** - * Month of the current Date - */ - currentMonth: Date; - /** - * Date value that is currently focused - */ - focusedDate: Date; - /** - * Informs if the given date is within the min & max date. - */ - isInvalidDateRange: (value: Date) => boolean; - /** - * `true` if the calendar is used as RangeCalendar - */ - isRangeCalendar: boolean; -}; - -export type CalendarActions = { - /** - * Sets `isFocused` - */ - setFocused: React.Dispatch>; - /** - * Sets `currentMonth` - */ - setCurrentMonth: React.Dispatch>; - /** - * Sets `focusedDate` - */ - setFocusedDate: React.Dispatch>; - /** - * Sets `dateValue` - */ - setDateValue: (value: Date) => void; - /** - * Focus the cell of the specified date - */ - focusCell: (value: Date) => void; - /** - * Focus the cell next to the current date - */ - focusNextDay: () => void; - /** - * Focus the cell prev to the current date - */ - focusPreviousDay: () => void; - /** - * Focus the cell one week next to the current date - */ - focusNextWeek: () => void; - /** - * Focus the cell one week prev to the current date - */ - focusPreviousWeek: () => void; - /** - * Focus the cell one month next to the current date - */ - focusNextMonth: () => void; - /** - * Focus the cell one month prev to the current date - */ - focusPreviousMonth: () => void; - /** - * Focus the cell of the first day of the month - */ - focusStartOfMonth: () => void; - /** - * Focus the cell of the last day of the month - */ - focusEndOfMonth: () => void; - /** - * Focus the cell of the date one year from the current date - */ - focusNextYear: () => void; - /** - * Focus the cell of the date one year before the current date - */ - focusPreviousYear: () => void; - /** - * Selects the `focusedDate` - */ - selectFocusedDate: () => void; - /** - * sets `dateValue` - */ - selectDate: (value: Date) => void; -}; - -type ValueBase = { - /** The current date (controlled). */ - value?: string; - /** The default date (uncontrolled). */ - defaultValue?: string; - /** Handler that is called when the date changes. */ - onChange?: (value: string) => void; -}; - -type RangeValueMinMax = { - /** The lowest date allowed. */ - minValue?: string; - /** The highest date allowed. */ - maxValue?: string; -}; - -export type CalendarInitialState = ValueBase & - RangeValueMinMax & - InputBase & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - /** - * Id for the calendar grid - */ - id?: string; - }; - -export type CalendarStateReturn = CalendarState & CalendarActions; diff --git a/src/calendar/CalendarWeekTitle.ts b/src/calendar/CalendarWeekTitle.ts deleted file mode 100644 index 8276c7750..000000000 --- a/src/calendar/CalendarWeekTitle.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_WEEK_TITLE_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendarWeekTitle = createHook< - CalendarWeekTitleOptions, - CalendarWeekTitleHTMLProps ->({ - name: "CalendarWeekTitle", - compose: useRole, - keys: CALENDAR_WEEK_TITLE_KEYS, - - useProps({ dayIndex, weekDays }, htmlProps) { - return { - "aria-label": weekDays[dayIndex]?.title, - ...htmlProps, - }; - }, -}); - -export const CalendarWeekTitle = createComponent({ - as: "div", - memo: true, - useHook: useCalendarWeekTitle, -}); - -export type CalendarWeekTitleOptions = RoleOptions & - Pick & { - dayIndex: number; - }; - -export type CalendarWeekTitleHTMLProps = RoleHTMLProps; - -export type CalendarWeekTitleProps = CalendarWeekTitleOptions & - CalendarWeekTitleHTMLProps; diff --git a/src/calendar/RangeCalendarState.ts b/src/calendar/RangeCalendarState.ts deleted file mode 100644 index 60bce086c..000000000 --- a/src/calendar/RangeCalendarState.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useRangeCalendar](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useRangeCalendar.ts) - * to work with Reakit System - */ -import * as React from "react"; -import { useDateFormatter } from "@react-aria/i18n"; -import { InputBase, RangeValue } from "@react-types/shared"; - -import { - addDays, - isSameDay, - toUTCRangeString, - toUTCString, - useControllableState, -} from "../utils"; -import { announce } from "../utils/LiveAnnouncer"; - -import { - CalendarActions, - CalendarState, - useCalendarState, -} from "./CalendarState"; -import { makeRange } from "./helpers"; - -export function useRangeCalendarState( - props: RangeCalendarInitialState = {}, -): RangeCalendarStateReturn { - const { - value: initialValue, - defaultValue = { - start: toUTCString(new Date()), - end: toUTCString(addDays(new Date(), 1)), - }, - onChange, - ...calendarProps - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const dateRange: RangeValue = React.useMemo( - () => ({ - start: new Date(value.start), - end: new Date(value.end), - }), - [value.end, value.start], - ); - const [anchorDate, setAnchorDate] = React.useState(null); - const [lastSelectedDate, setLastSelectedDate] = React.useState( - dateRange.end, - ); - const calendar = useCalendarState({ - ...calendarProps, - value: toUTCString(lastSelectedDate), - }); - - const highlightedRange = anchorDate - ? makeRange(anchorDate, calendar.focusedDate) - : value && dateRange && makeRange(dateRange.start, dateRange.end); - - const dateFormatter = useDateFormatter({ dateStyle: "full" }); - - const announceRange = React.useCallback(() => { - if (!highlightedRange) return; - - if (isSameDay(highlightedRange.start, highlightedRange.end)) { - announce( - `Selected range, from ${dateFormatter.format( - highlightedRange.start, - )} to ${dateFormatter.format(highlightedRange.start)}`, - ); - } else { - announce( - `Selected range from ${dateFormatter.format( - highlightedRange.start, - )} to ${dateFormatter.format(highlightedRange.start)}`, - ); - } - }, [dateFormatter, highlightedRange]); - - const selectDate = React.useCallback( - (date: Date) => { - if (props.isReadOnly) return; - - setLastSelectedDate(date); - if (!anchorDate) { - setAnchorDate(date); - announce(`Starting range from ${dateFormatter.format(date)}`); - } else { - setValue(toUTCRangeString(makeRange(anchorDate, date))); - announceRange(); - setAnchorDate(null); - } - }, - [anchorDate, announceRange, dateFormatter, props.isReadOnly, setValue], - ); - - const setDateValue = React.useCallback( - (value: RangeValue) => { - setValue(toUTCRangeString(value)); - }, - [setValue], - ); - - return { - ...calendar, - dateRangeValue: dateRange, - setDateRangeValue: setDateValue, - anchorDate, - setAnchorDate, - highlightedRange, - selectDate, - selectFocusedDate() { - selectDate(calendar.focusedDate); - }, - highlightDate(date: Date) { - if (!anchorDate) return; - calendar.setFocusedDate(date); - }, - isRangeCalendar: true, - }; -} - -export type RangeCalendarState = CalendarState & { - dateRangeValue: RangeValue | null; - anchorDate: Date | null; - highlightedRange: RangeValue | null; - isRangeCalendar: boolean; -}; - -export type RangeCalendarActions = CalendarActions & { - setDateRangeValue: (value: RangeValue) => void; - setAnchorDate: React.Dispatch>; - selectDate: (date: Date) => void; - selectFocusedDate: () => void; - highlightDate: (date: Date) => void; -}; - -type Range = { - /** The start value of the range. */ - start: string; - /** The end value of the range. */ - end: string; -}; - -type RangeValueBase = { - /** The current value (controlled). */ - value?: Range; - /** The default value (uncontrolled). */ - defaultValue?: Range; - /** Handler that is called when the value changes. */ - onChange?: (value: Range) => void; -}; - -type RangeValueMinMax = { - /** The smallest value allowed. */ - minValue?: string; - /** The largest value allowed. */ - maxValue?: string; -}; - -export type RangeCalendarInitialState = RangeValueBase & - RangeValueMinMax & - InputBase & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - /** - * Id for the calendar grid - */ - id?: string; - }; - -export type RangeCalendarStateReturn = RangeCalendarState & - RangeCalendarActions; diff --git a/src/calendar/__keys.ts b/src/calendar/__keys.ts deleted file mode 100644 index 0666156d3..000000000 --- a/src/calendar/__keys.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Automatically generated -export const USE_CALENDAR_STATE_KEYS = [ - "value", - "defaultValue", - "onChange", - "minValue", - "maxValue", - "isDisabled", - "isReadOnly", - "autoFocus", - "id", -] as const; -export const CALENDAR_STATE_KEYS = [ - "calendarId", - "dateValue", - "month", - "year", - "weekStart", - "weekDays", - "daysInMonth", - "isDisabled", - "isFocused", - "isReadOnly", - "currentMonth", - "focusedDate", - "isInvalidDateRange", - "isRangeCalendar", - "setFocused", - "setCurrentMonth", - "setFocusedDate", - "setDateValue", - "focusCell", - "focusNextDay", - "focusPreviousDay", - "focusNextWeek", - "focusPreviousWeek", - "focusNextMonth", - "focusPreviousMonth", - "focusStartOfMonth", - "focusEndOfMonth", - "focusNextYear", - "focusPreviousYear", - "selectFocusedDate", - "selectDate", -] as const; -export const USE_RANGE_CALENDAR_STATE_KEYS = USE_CALENDAR_STATE_KEYS; -export const RANGE_CALENDAR_STATE_KEYS = [ - ...CALENDAR_STATE_KEYS, - "dateRangeValue", - "anchorDate", - "highlightedRange", - "setDateRangeValue", - "setAnchorDate", - "highlightDate", -] as const; -export const CALENDAR_KEYS = RANGE_CALENDAR_STATE_KEYS; -export const CALENDAR_BUTTON_KEYS = [...CALENDAR_KEYS, "goto"] as const; -export const CALENDAR_CELL_KEYS = [...CALENDAR_KEYS, "date"] as const; -export const CALENDAR_CELL_BUTTON_KEYS = CALENDAR_CELL_KEYS; -export const CALENDAR_GRID_KEYS = CALENDAR_KEYS; -export const CALENDAR_HEADER_KEYS = [...CALENDAR_GRID_KEYS, "format"] as const; -export const CALENDAR_WEEK_TITLE_KEYS = [ - ...CALENDAR_GRID_KEYS, - "dayIndex", -] as const; diff --git a/src/calendar/__tests__/Calendar.test.tsx b/src/calendar/__tests__/Calendar.test.tsx deleted file mode 100644 index 84d2c6e8f..000000000 --- a/src/calendar/__tests__/Calendar.test.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/* eslint-disable testing-library/prefer-explicit-assert */ -import * as React from "react"; -import { axe, press, render, screen } from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; -import MockDate from "mockdate"; - -import { repeat } from "../../utils/test-utils"; -import { - Calendar as CalendarWrapper, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarInitialState, - CalendarWeekTitle, - useCalendarState, -} from "../index"; - -export const CalendarComp: React.FC = props => { - const state = useCalendarState(props); - - return ( - - - - previous year - - - previous month - - - - next month - - - next year - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week: any[], weekIndex: React.Key) => ( - - {week.map((day: Date, dayIndex: React.Key) => ( - - - - ))} - - ))} - - - - ); -}; - -beforeEach(() => { - // You SHALL Freeze 🧙 - MockDate.set(new Date(2020, 9, 29)); -}); - -afterEach(() => { - cleanup(); - MockDate.reset(); -}); - -describe("Calendar", () => { - it("should render correctly", () => { - const { getByTestId: testId } = render(); - - expect(testId("testid-weekDays").children).toHaveLength(7); - expect(testId("testid-current-year")).toHaveTextContent(/^october 2020$/i); - }); - - it("should have proper calendar header keyboard navigation", () => { - render(); - - const currentYear = screen.getByTestId("testid-current-year"); - const { getByText: text } = screen; - - expect(currentYear).toHaveTextContent(/^october 2020$/i); - press.Tab(); - press.Enter(); - expect(text(/previous year/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^october 2019$/i); - - press.Tab(); - press.Enter(); - expect(text(/previous month/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^september 2019$/i); - - press.Tab(); - press.Enter(); - expect(text(/next month/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^october 2019$/i); - - press.Tab(); - press.Enter(); - expect(text(/next year/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^october 2020$/i); - }); - - it("should proper grid navigation", () => { - render(); - const currentYear = screen.getByTestId("testid-current-year"); - - const { getByLabelText: label } = screen; - - expect(currentYear).toHaveTextContent(/^october 2020$/i); - - // Tab to go inside the calendar dates - repeat(press.Tab, 5); - expect(label(/wednesday, october 7, 2020 selected/i)).toHaveFocus(); - - // Let's navigate to 30 - repeat(press.ArrowDown, 2); - repeat(press.ArrowRight, 2); - press.ArrowDown(); - - expect(label(/^friday, october 30, 2020$/i)).toHaveFocus(); - - // Let's go to next month - press.ArrowDown(); - expect(label(/^friday, november 6, 2020$/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^november 2020$/i); - - // Grid navigation pageup/down - press.PageUp(); - expect(currentYear).toHaveTextContent(/^october 2020$/i); - - press.PageUp(null, { shiftKey: true }); - expect(currentYear).toHaveTextContent(/^october 2019$/i); - }); - - test("should have min/max values", () => { - render( - , - ); - const { getByLabelText: label } = screen; - - // Tab to go inside the calendar dates - repeat(press.Tab, 5); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - // try to go outside the min max value - repeat(press.ArrowUp, 4); - expect(label(/^saturday, october 31, 2020$/i)).toHaveFocus(); - - repeat(press.ArrowDown, 3); - expect(label(/^saturday, november 14, 2020$/i)).toHaveFocus(); - }); - - test("should be able to go to prev/next month when min/max values are set", () => { - render( - , - ); - const { getByLabelText: label } = screen; - - // Tab to go inside the calendar dates - repeat(press.Tab, 5); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - // Should not be able to go to next/prev months - press.PageUp(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageDown(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - // Should not be able to go to next/prev years - press.PageDown(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - }); - - test("should not be able to go to prev/next year when min/max values are set", () => { - render( - , - ); - - const { getByLabelText: label } = screen; - - repeat(press.Tab, 5); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageDown(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageDown(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - }); - - it("should have proper aria-label for calendar cell button", () => { - MockDate.set("2020-11-07"); - render(); - - screen.getByRole("button", { - name: /^today, saturday, november 7, 2020 selected$/i, - }); - - repeat(press.Tab, 5); - press.ArrowRight(); - press.Enter(); - screen.getByRole("button", { - name: /sunday, november 8, 2020 selected/i, - }); - - repeat(press.ArrowLeft, 2); - press.Enter(); - screen.getByRole("button", { - name: /friday, november 6, 2020 selected/i, - }); - - MockDate.reset(); - }); - - test("Calendar renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/calendar/__tests__/RangeCalendar.test.tsx b/src/calendar/__tests__/RangeCalendar.test.tsx deleted file mode 100644 index a703d0b16..000000000 --- a/src/calendar/__tests__/RangeCalendar.test.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import * as React from "react"; -import { axe, press, render } from "reakit-test-utils"; -import { cleanup, screen } from "@testing-library/react"; -import MockDate from "mockdate"; - -import { announce, destroyAnnouncer } from "../../utils/LiveAnnouncer"; -import { - isEndSelection, - isStartSelection, - repeat, -} from "../../utils/test-utils"; -import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - RangeCalendarInitialState, - useRangeCalendarState, -} from "../index"; - -jest.mock("../../utils/LiveAnnouncer"); - -afterEach(cleanup); - -beforeEach(() => { - destroyAnnouncer(); -}); - -const RangeCalendarComp: React.FC = props => { - const state = useRangeCalendarState(props); - - return ( - - - - previous year - - - previous month - - - - next month - - - next year - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - - ); -}; - -describe("RangeCalendar", () => { - it("should render correctly", () => { - const { getByTestId: testId } = render( - , - ); - - expect(testId("testid-week-days").children).toHaveLength(7); - expect(testId("testid-current-year")).toHaveTextContent("October 2020"); - }); - - it("should have proper initial start and end ranges", () => { - const { baseElement } = render( - , - ); - - const start = baseElement.querySelector("[data-is-selection-start]"); - // If anyone is reading this code from future - // Note that this will fail again on 15th october 2050. - const anyMiddleDate = screen.getByLabelText(/Saturday, October 15, 2050/); - const end = baseElement.querySelector("[data-is-selection-end]"); - - expect(start).toHaveTextContent("7"); - expect(anyMiddleDate.parentElement).toHaveAttribute( - "data-is-range-selection", - ); - expect(end).toHaveTextContent("30"); - }); - - it("should announce selected range after finishing selection", () => { - const { getByLabelText: label } = render( - , - ); - - repeat(press.Tab, 5); - expect(label(/Wednesday, October 30, 2019 selected/)).toHaveFocus(); - - press.ArrowUp(); - press.ArrowRight(); - press.Enter(label(/Thursday, October 24, 2019/)); - - expect(announce).toHaveBeenLastCalledWith( - "Starting range from Thursday, October 24, 2019", - ); - - press.ArrowRight(); - press.Enter(label(/Friday, October 25, 2019/)); - - expect(announce).toHaveBeenLastCalledWith( - "Selected range from Thursday, October 24, 2019 to Thursday, October 24, 2019", - ); - expect(announce).toHaveBeenCalledTimes(2); - }); - - it("should be able to select ranges with keyboard navigation", () => { - MockDate.set("2020-10-07"); - const { baseElement } = render( - , - ); - - expect(screen.getByTestId("testid-current-year")).toHaveTextContent( - /October 2020/i, - ); - - repeat(press.Tab, 5); - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 selected \(click to start selecting range\)$/, - ), - ).toHaveFocus(); - - press.ArrowUp(); // go to down just for some variety - press.Enter(); // start the selection, currently the start and end should be the same date - expect( - baseElement.querySelector("[data-is-selection-start]"), - ).toHaveTextContent("23"); - press.ArrowDown(); - expect( - baseElement.querySelector("[data-is-selection-end]"), - ).toHaveTextContent("30"); - - // finish the selection - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 \(click to finish selecting range\)$/, - ), - ).toHaveFocus(); - - // check if the selection is actually finished or not - press.Enter(); - const selectedDate = screen.getByLabelText( - /^Friday, October 30, 2020 selected \(click to start selecting range\)$/, - ); - expect(selectedDate).toHaveFocus(); - expect(selectedDate?.parentElement).toHaveAttribute( - "data-is-range-selection", - ); - - press.ArrowRight(); - const nextDate = screen.getByLabelText( - /^Saturday, October 31, 2020 \(click to start selecting range\)$/, - ); - expect(nextDate).toHaveFocus(); - expect(nextDate?.parentElement).not.toHaveAttribute( - "data-is-range-selection", - ); - - // Verify selection ranges - const end = baseElement.querySelector("[data-is-selection-end]"); - expect(end).toHaveTextContent("30"); - - const start = baseElement.querySelector("[data-is-selection-start]"); - expect(start).toHaveTextContent("23"); - }); - - it("should be able to cancel selection", () => { - render( - , - ); - - expect(screen.getByTestId("testid-current-year")).toHaveTextContent( - /October 2020/i, - ); - - repeat(press.Tab, 5); - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 selected \(click to start selecting range\)$/, - ), - ).toHaveFocus(); - - press.ArrowUp(); - press.Enter(); // start the selection - - // Now we choose the end date, let's choose 30 - press.ArrowDown(); - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 \(click to finish selecting range\)$/i, - ), - ).toHaveFocus(); - - press.Escape(); - isStartSelection(screen.getByLabelText(/Wednesday, October 7, 2020/)); - isEndSelection(screen.getByLabelText(/Friday, October 30, 2020/)); - }); - - test("RangeCalendar renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/calendar/__tests__/__snapshots__/utils.test.tsx.snap b/src/calendar/__tests__/__snapshots__/utils.test.tsx.snap deleted file mode 100644 index b503da53a..000000000 --- a/src/calendar/__tests__/__snapshots__/utils.test.tsx.snap +++ /dev/null @@ -1,135 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Calendar Utils generateDaysInMonthArray 1`] = ` -Array [ - Array [ - 2020-02-01T00:00:00.000Z, - 2020-02-02T00:00:00.000Z, - 2020-02-03T00:00:00.000Z, - 2020-02-04T00:00:00.000Z, - 2020-02-05T00:00:00.000Z, - 2020-02-06T00:00:00.000Z, - 2020-02-07T00:00:00.000Z, - ], - Array [ - 2020-02-08T00:00:00.000Z, - 2020-02-09T00:00:00.000Z, - 2020-02-10T00:00:00.000Z, - 2020-02-11T00:00:00.000Z, - 2020-02-12T00:00:00.000Z, - 2020-02-13T00:00:00.000Z, - 2020-02-14T00:00:00.000Z, - ], - Array [ - 2020-02-15T00:00:00.000Z, - 2020-02-16T00:00:00.000Z, - 2020-02-17T00:00:00.000Z, - 2020-02-18T00:00:00.000Z, - 2020-02-19T00:00:00.000Z, - 2020-02-20T00:00:00.000Z, - 2020-02-21T00:00:00.000Z, - ], - Array [ - 2020-02-22T00:00:00.000Z, - 2020-02-23T00:00:00.000Z, - 2020-02-24T00:00:00.000Z, - 2020-02-25T00:00:00.000Z, - 2020-02-26T00:00:00.000Z, - 2020-02-27T00:00:00.000Z, - 2020-02-28T00:00:00.000Z, - ], - Array [ - 2020-02-29T00:00:00.000Z, - 2020-03-01T00:00:00.000Z, - 2020-03-02T00:00:00.000Z, - 2020-03-03T00:00:00.000Z, - 2020-03-04T00:00:00.000Z, - 2020-03-05T00:00:00.000Z, - 2020-03-06T00:00:00.000Z, - ], - Array [ - 2020-03-07T00:00:00.000Z, - 2020-03-08T00:00:00.000Z, - 2020-03-09T00:00:00.000Z, - 2020-03-10T00:00:00.000Z, - 2020-03-11T00:00:00.000Z, - 2020-03-12T00:00:00.000Z, - 2020-03-13T00:00:00.000Z, - ], - Array [ - 2020-03-14T00:00:00.000Z, - 2020-03-15T00:00:00.000Z, - 2020-03-16T00:00:00.000Z, - 2020-03-17T00:00:00.000Z, - 2020-03-18T00:00:00.000Z, - 2020-03-19T00:00:00.000Z, - 2020-03-20T00:00:00.000Z, - ], -] -`; - -exports[`Calendar Utils useWeekDays 1`] = ` -Array [ - Object { - "abbr": "Sun", - "title": "Sunday", - }, - Object { - "abbr": "Mon", - "title": "Monday", - }, - Object { - "abbr": "Tue", - "title": "Tuesday", - }, - Object { - "abbr": "Wed", - "title": "Wednesday", - }, - Object { - "abbr": "Thu", - "title": "Thursday", - }, - Object { - "abbr": "Fri", - "title": "Friday", - }, - Object { - "abbr": "Sat", - "title": "Saturday", - }, -] -`; - -exports[`Calendar Utils useWeekDays 2`] = ` -Array [ - Object { - "abbr": "Tue", - "title": "Tuesday", - }, - Object { - "abbr": "Wed", - "title": "Wednesday", - }, - Object { - "abbr": "Thu", - "title": "Thursday", - }, - Object { - "abbr": "Fri", - "title": "Friday", - }, - Object { - "abbr": "Sat", - "title": "Saturday", - }, - Object { - "abbr": "Sun", - "title": "Sunday", - }, - Object { - "abbr": "Mon", - "title": "Monday", - }, -] -`; diff --git a/src/calendar/__tests__/utils.test.tsx b/src/calendar/__tests__/utils.test.tsx deleted file mode 100644 index 963a84f5a..000000000 --- a/src/calendar/__tests__/utils.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; -import MockDate from "mockdate"; - -import { generateDaysInMonthArray, makeRange, useWeekDays } from "../helpers"; - -describe("Calendar Utils", () => { - test("makeRange", () => { - const range = makeRange( - new Date(1999, 4, 4, 0, 0), - new Date(2020, 4, 4, 0, 0), - ); - expect(range.start).toMatchInlineSnapshot(`1999-05-03T18:30:00.000Z`); - expect(range.end).toMatchInlineSnapshot(`2020-05-03T18:30:00.000Z`); - }); - - test("useWeekDays", () => { - // MIND THE BLOCK SCOPE! - { - const { - result: { current }, - } = renderHook(() => useWeekDays(0)); - - expect(current).toMatchSnapshot(); - } - { - const { - result: { current }, - } = renderHook(() => useWeekDays(2)); - - expect(current).toMatchSnapshot(); - } - }); - - test("generateDaysInMonthArray", () => { - MockDate.set(new Date("2020-02-01T11:30:00.000Z")); - const days = generateDaysInMonthArray(1, 0, 7, 2020); - - expect(days).toMatchSnapshot(); - - MockDate.reset(); - }); -}); diff --git a/src/calendar/calendar-base-state.ts b/src/calendar/calendar-base-state.ts new file mode 100644 index 000000000..e2fe6a081 --- /dev/null +++ b/src/calendar/calendar-base-state.ts @@ -0,0 +1,32 @@ +import { Calendar, DateDuration } from "@internationalized/date"; +import { CalendarState, useCalendarState } from "@react-stately/calendar"; +import { CalendarProps, DateValue } from "@react-types/calendar"; + +export function useCalendarBaseState( + props: CalendarBaseStateProps, +): CalendarBaseState { + const state = useCalendarState(props); + + return state; +} + +export type CalendarBaseState = CalendarState; + +export type CalendarBaseStateProps = CalendarProps & { + /** The locale to display and edit the value according to. */ + locale: string; + /** + * A function that creates a [Calendar](../internationalized/date/Calendar.html) + * object for a given calendar identifier. Such a function may be imported from the + * `@internationalized/date` package, or manually implemented to include support for + * only certain calendars. + */ + createCalendar: (name: string) => Calendar; + /** + * The amount of days that will be displayed at once. This affects how pagination works. + * @default {months: 1} + */ + visibleDuration?: DateDuration; + /** Determines how to align the initial selection relative to the visible date range. */ + selectionAlignment?: "start" | "center" | "end"; +}; diff --git a/src/calendar/calendar-base.ts b/src/calendar/calendar-base.ts new file mode 100644 index 000000000..68d27b985 --- /dev/null +++ b/src/calendar/calendar-base.ts @@ -0,0 +1,32 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; + +export const useCalendar = createHook( + ({ state, ...props }) => { + props = mergeProps(state.calendarProps, props); + + return props; + }, +); + +export const Calendar = createComponent(props => { + const htmlProps = useCalendar(props); + + return createElement("div", htmlProps); +}); + +export type CalendarOptions = Options & { + /** + * Object returned by the `useCalendarState` hook. + */ + state: CalendarState; +}; + +export type CalendarProps = Props>; diff --git a/src/calendar/calendar-cell-button.ts b/src/calendar/calendar-cell-button.ts new file mode 100644 index 000000000..a7357e269 --- /dev/null +++ b/src/calendar/calendar-cell-button.ts @@ -0,0 +1,38 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarCellState } from "./calendar-cell-state"; + +export const useCalendarCellButton = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.buttonProps, props); + + return props; + }, +); + +export const CalendarCellButton = createComponent( + props => { + const htmlProps = useCalendarCellButton(props); + + return createElement("span", htmlProps); + }, +); + +export type CalendarCellButtonOptions = Options & { + /** + * Object returned by the `useCalendarCellButtonState` hook. + */ + state: CalendarCellState; +}; + +export type CalendarCellButtonProps = Props< + CalendarCellButtonOptions +>; diff --git a/src/calendar/calendar-cell-state.ts b/src/calendar/calendar-cell-state.ts new file mode 100644 index 000000000..df48eb531 --- /dev/null +++ b/src/calendar/calendar-cell-state.ts @@ -0,0 +1,75 @@ +import { HTMLAttributes, RefObject, useRef } from "react"; +import { CalendarDate } from "@internationalized/date"; +import { useCalendarCell } from "@react-aria/calendar"; + +import { CalendarBaseState } from "./calendar-base-state"; +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export function useCalendarCellState({ + state, + ...props +}: CalendarCellStateProps): CalendarCellState { + const ref = useRef(null); + const calendarCellProps = useCalendarCell(props, state, ref); + + return { ...calendarCellProps, ref, baseState: state, date: props.date }; +} + +export type CalendarCellState = { + /** Props for the grid cell element (e.g. ``). */ + cellProps: HTMLAttributes; + /** Props for the button element within the cell. */ + buttonProps: HTMLAttributes; + /** Whether the cell is currently being pressed. */ + isPressed: boolean; + /** Whether the cell is selected. */ + isSelected: boolean; + /** Whether the cell is focused. */ + isFocused: boolean; + /** + * Whether the cell is disabled, according to the calendar's `minValue`, `maxValue`, and `isDisabled` props. + * Disabled dates are not focusable, and cannot be selected by the user. They are typically + * displayed with a dimmed appearance. + */ + isDisabled: boolean; + /** + * Whether the cell is unavailable, according to the calendar's `isDateUnavailable` prop. Unavailable dates remain + * focusable, but cannot be selected by the user. They should be displayed with a visual affordance to indicate they + * are unavailable, such as a different color or a strikethrough. + * + * Note that because they are focusable, unavailable dates must meet a 4.5:1 color contrast ratio, + * [as defined by WCAG](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html). + */ + isUnavailable: boolean; + /** + * Whether the cell is outside the visible range of the calendar. + * For example, dates before the first day of a month in the same week. + */ + isOutsideVisibleRange: boolean; + /** The day number formatted according to the current locale. */ + formattedDate: string; + /** + * Reference for the button element within the cell inside the table + */ + ref: RefObject; + /** + * Object returned by the `useSliderState` hook. + */ + baseState: CalendarBaseState | RangeCalendarBaseState; + /** The date that this cell represents. */ + date: CalendarDate; +}; + +export type CalendarCellStateProps = { + /** The date that this cell represents. */ + date: CalendarDate; + /** + * Whether the cell is disabled. By default, this is determined by the + * Calendar's `minValue`, `maxValue`, and `isDisabled` props. + */ + isDisabled?: boolean; + /** + * Object returned by the `useSliderState` hook. + */ + state: CalendarBaseState | RangeCalendarBaseState; +}; diff --git a/src/calendar/calendar-cell.ts b/src/calendar/calendar-cell.ts new file mode 100644 index 000000000..ad1d14683 --- /dev/null +++ b/src/calendar/calendar-cell.ts @@ -0,0 +1,82 @@ +import { ariaAttr } from "@chakra-ui/utils"; +import { getDayOfWeek, isSameDay } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarCellState } from "./calendar-cell-state"; +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export const useCalendarCell = createHook( + ({ state, ...props }) => { + const { baseState } = state; + + const isLastSelectedBeforeDisabled = + !state.isDisabled && + baseState.isCellUnavailable(state.date.add({ days: 1 })); + const isFirstSelectedAfterDisabled = + !state.isDisabled && + baseState.isCellUnavailable(state.date.subtract({ days: 1 })); + let highlightedRange = + "highlightedRange" in baseState && + (baseState as RangeCalendarBaseState).highlightedRange; + let isSelectionStart = + state.isSelected && + highlightedRange && + isSameDay(state.date, highlightedRange.start); + let isSelectionEnd = + state.isSelected && + highlightedRange && + isSameDay(state.date, highlightedRange.end); + const { locale } = useLocale(); + const dayOfWeek = getDayOfWeek(state.date, locale); + let isRangeStart = + state.isSelected && + (isFirstSelectedAfterDisabled || dayOfWeek === 0 || state.date.day === 1); + const isRangeEnd = + state.isSelected && + (isLastSelectedBeforeDisabled || + dayOfWeek === 6 || + state.date.day === + baseState.visibleRange.start.calendar.getDaysInMonth( + baseState.visibleRange.start, + )); + + props = { + "data-is-range-selection": ariaAttr( + state.isSelected && "highlightedRange" in baseState, + ), + "data-is-range-end": ariaAttr(isRangeEnd), + "data-is-range-start": ariaAttr(isRangeStart), + "data-is-selection-end": ariaAttr(isSelectionEnd), + "data-is-selection-start": ariaAttr(isSelectionStart), + ...props, + }; + + props = mergeProps(state.cellProps, props); + + return props; + }, +); + +export const CalendarCell = createComponent(props => { + const htmlProps = useCalendarCell(props); + + return createElement("td", htmlProps); +}); + +export type CalendarCellOptions = Options & { + /** + * Object returned by the `useCalendarCellState` hook. + */ + state: CalendarCellState; +}; + +export type CalendarCellProps = Props< + CalendarCellOptions +>; diff --git a/src/calendar/calendar-grid-state.ts b/src/calendar/calendar-grid-state.ts new file mode 100644 index 000000000..6ef75bbe7 --- /dev/null +++ b/src/calendar/calendar-grid-state.ts @@ -0,0 +1,35 @@ +import { CalendarDate } from "@internationalized/date"; +import { CalendarGridAria, useCalendarGrid } from "@react-aria/calendar"; + +import { CalendarBaseState } from "./calendar-base-state"; +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export function useCalendarGridState({ + state, + ...props +}: CalendarGridStateProps): CalendarGridState { + const calendarGridProps = useCalendarGrid(props, state); + + return calendarGridProps; +} + +export type CalendarGridState = CalendarGridAria; + +export type CalendarGridStateProps = { + /** + * The first date displayed in the calendar grid. + * Defaults to the first visible date in the calendar. + * Override this to display multiple date grids in a calendar. + */ + startDate?: CalendarDate; + /** + * The last date displayed in the calendar grid. + * Defaults to the last visible date in the calendar. + * Override this to display multiple date grids in a calendar. + */ + endDate?: CalendarDate; + /** + * Object returned by the `useSliderState` hook. + */ + state: CalendarBaseState | RangeCalendarBaseState; +}; diff --git a/src/calendar/calendar-grid.ts b/src/calendar/calendar-grid.ts new file mode 100644 index 000000000..b0a2ecbf3 --- /dev/null +++ b/src/calendar/calendar-grid.ts @@ -0,0 +1,34 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarGridState } from "./calendar-grid-state"; + +export const useCalendarGrid = createHook( + ({ state, ...props }) => { + props = mergeProps(state.gridProps, props); + + return props; + }, +); + +export const CalendarGrid = createComponent(props => { + const htmlProps = useCalendarGrid(props); + + return createElement("table", htmlProps); +}); + +export type CalendarGridOptions = Options & { + /** + * Object returned by the `useCalendarGridState` hook. + */ + state: CalendarGridState; +}; + +export type CalendarGridProps = Props< + CalendarGridOptions +>; diff --git a/src/calendar/calendar-next-button.ts b/src/calendar/calendar-next-button.ts new file mode 100644 index 000000000..50da24998 --- /dev/null +++ b/src/calendar/calendar-next-button.ts @@ -0,0 +1,43 @@ +import { useRef } from "react"; +import { useButton } from "@react-aria/button"; +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; +import { RangeCalendarState } from "./range-calendar-state"; + +export const useCalendarNextButton = createHook( + ({ state, ...props }) => { + const ref = useRef(null); + props = { ...props, ref: useForkRef(ref, props.ref) }; + const { buttonProps } = useButton(state.nextButtonProps, ref); + props = mergeProps(buttonProps, props); + + return props; + }, +); + +export const CalendarNextButton = createComponent( + props => { + const htmlProps = useCalendarNextButton(props); + + return createElement("button", htmlProps); + }, +); + +export type CalendarNextButtonOptions = Options & { + /** + * Object returned by the `useCalendarNextButtonState` hook. + */ + state: CalendarState | RangeCalendarState; +}; + +export type CalendarNextButtonProps = Props< + CalendarNextButtonOptions +>; diff --git a/src/calendar/calendar-prev-button.ts b/src/calendar/calendar-prev-button.ts new file mode 100644 index 000000000..fe999f3d1 --- /dev/null +++ b/src/calendar/calendar-prev-button.ts @@ -0,0 +1,42 @@ +import { useRef } from "react"; +import { useButton } from "@react-aria/button"; +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; +import { RangeCalendarState } from "./range-calendar-state"; + +export const useCalendarPreviousButton = + createHook(({ state, ...props }) => { + const ref = useRef(null); + props = { ...props, ref: useForkRef(ref, props.ref) }; + const { buttonProps } = useButton(state.prevButtonProps, ref); + props = mergeProps(buttonProps, props); + + return props; + }); + +export const CalendarPreviousButton = + createComponent(props => { + const htmlProps = useCalendarPreviousButton(props); + + return createElement("button", htmlProps); + }); + +export type CalendarPreviousButtonOptions = + Options & { + /** + * Object returned by the `useCalendarPreviousButtonState` hook. + */ + state: CalendarState | RangeCalendarState; + }; + +export type CalendarPreviousButtonProps = Props< + CalendarPreviousButtonOptions +>; diff --git a/src/calendar/calendar-state.ts b/src/calendar/calendar-state.ts new file mode 100644 index 000000000..e87001c06 --- /dev/null +++ b/src/calendar/calendar-state.ts @@ -0,0 +1,33 @@ +import { HTMLAttributes } from "react"; +import { useCalendar } from "@react-aria/calendar"; +import { AriaButtonProps } from "@react-types/button"; +import { CalendarProps, DateValue } from "@react-types/calendar"; + +import { CalendarBaseState } from "./calendar-base-state"; + +export function useCalendarState({ + state, + ...props +}: CalendarStateProps): CalendarState { + const calendarProps = useCalendar(props, state); + + return calendarProps; +} + +export type CalendarState = { + /** Props for the calendar grouping element. */ + calendarProps: HTMLAttributes; + /** Props for the next button. */ + nextButtonProps: AriaButtonProps; + /** Props for the previous button. */ + prevButtonProps: AriaButtonProps; + /** A description of the visible date range, for use in the calendar title. */ + title: string; +}; + +export type CalendarStateProps = CalendarProps & { + /** + * Object returned by the `useSliderState` hook. + */ + state: CalendarBaseState; +}; diff --git a/src/calendar/calendar-title.ts b/src/calendar/calendar-title.ts new file mode 100644 index 000000000..780c570f5 --- /dev/null +++ b/src/calendar/calendar-title.ts @@ -0,0 +1,33 @@ +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; + +export const useCalendarTitle = createHook( + ({ state, ...props }) => { + props = { children: state.title, ...props }; + + return props; + }, +); + +export const CalendarTitle = createComponent(props => { + const htmlProps = useCalendarTitle(props); + + return createElement("h2", htmlProps); +}); + +export type CalendarTitleOptions = Options & { + /** + * Object returned by the `useCalendarTitleState` hook. + */ + state: CalendarState; +}; + +export type CalendarTitleProps = Props< + CalendarTitleOptions +>; diff --git a/src/calendar/helpers/index.ts b/src/calendar/helpers/index.ts deleted file mode 100644 index e8c125f9a..000000000 --- a/src/calendar/helpers/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * for these utils inspiration - */ -import { useDateFormatter } from "@react-aria/i18n"; -import { RangeValue } from "@react-types/shared"; - -import { setDay, toUTCString } from "../../utils"; - -export function useWeekDays(weekStart: number) { - const dayFormatter = useDateFormatter({ weekday: "short" }); - const dayFormatterLong = useDateFormatter({ weekday: "long" }); - - return [0, 1, 2, 3, 4, 5, 6].map(index => { - const dateDay = setDay(Date.now(), (index + weekStart) % 7); - - const day = dayFormatter.format(dateDay); - const dayLong = dayFormatterLong.format(dateDay); - return { title: dayLong, abbr: day } as const; - }); -} - -export function generateDaysInMonthArray( - month: number, - monthStartsAt: number, - weeksInMonth: number, - year: number, -) { - return Array(weeksInMonth) - .fill(1) - .reduce((weeks: Date[][], _, weekIndex) => { - const daysInWeek = [0, 1, 2, 3, 4, 5, 6].reduce( - (days: Date[], dayIndex) => { - const day = weekIndex * 7 + dayIndex - monthStartsAt + 2; - const utcDate = toUTCString(new Date(year, month, day)); - const cellDate = new Date(utcDate); - - return [...days, cellDate]; - }, - [], - ); - - return [...weeks, daysInWeek]; - }, []); -} - -export function makeRange(start: Date, end: Date): RangeValue { - if (end < start) { - [start, end] = [end, start]; - } - - return { start, end }; -} - -export * from "./useWeekStart"; diff --git a/src/calendar/helpers/useWeekStart.ts b/src/calendar/helpers/useWeekStart.ts deleted file mode 100644 index e4ea8ceca..000000000 --- a/src/calendar/helpers/useWeekStart.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useWeekStart](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/calendar/src/useWeekStart.ts) - * to work with Reakit System - */ -import { useLocale } from "@react-aria/i18n"; - -// Data from https://github.com/unicode-cldr/cldr-core/blob/master/supplemental/weekData.json -// Locales starting on Sunday have been removed for compression. -const data: Record = { - "001": 1, - AD: 1, - AE: 6, - AF: 6, - AI: 1, - AL: 1, - AM: 1, - AN: 1, - AT: 1, - AX: 1, - AZ: 1, - BA: 1, - BE: 1, - BG: 1, - BH: 6, - BM: 1, - BN: 1, - BY: 1, - CH: 1, - CL: 1, - CM: 1, - CR: 1, - CY: 1, - CZ: 1, - DE: 1, - DJ: 6, - DK: 1, - DZ: 6, - EC: 1, - EE: 1, - EG: 6, - ES: 1, - FI: 1, - FJ: 1, - FO: 1, - FR: 1, - GB: 1, - GE: 1, - GF: 1, - GP: 1, - GR: 1, - HR: 1, - HU: 1, - IE: 1, - IQ: 6, - IR: 6, - IS: 1, - IT: 1, - JO: 6, - KG: 1, - KW: 6, - KZ: 1, - LB: 1, - LI: 1, - LK: 1, - LT: 1, - LU: 1, - LV: 1, - LY: 6, - MC: 1, - MD: 1, - ME: 1, - MK: 1, - MN: 1, - MQ: 1, - MV: 5, - MY: 1, - NL: 1, - NO: 1, - NZ: 1, - OM: 6, - PL: 1, - QA: 6, - RE: 1, - RO: 1, - RS: 1, - RU: 1, - SD: 6, - SE: 1, - SI: 1, - SK: 1, - SM: 1, - SY: 6, - TJ: 1, - TM: 1, - TR: 1, - UA: 1, - UY: 1, - UZ: 1, - VA: 1, - VN: 1, - XK: 1, -}; - -export function useWeekStart() { - const region = useRegion(); - return data[region] || 0; -} - -function useRegion(): string { - const { locale } = useLocale(); - - // If the Intl.Locale API is available, use it to get the region for the locale. - // @ts-ignore - if (Intl.Locale) { - // @ts-ignore - return new Intl.Locale(locale).maximize().region; - } - - // If not, just try splitting the string. - return locale.split("-")[1]; -} diff --git a/src/calendar/index.ts b/src/calendar/index.ts index d69459175..6fa20b6f3 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -1,10 +1,14 @@ -export * from "./__keys"; -export * from "./Calendar"; -export * from "./CalendarButton"; -export * from "./CalendarCell"; -export * from "./CalendarCellButton"; -export * from "./CalendarGrid"; -export * from "./CalendarHeader"; -export * from "./CalendarState"; -export * from "./CalendarWeekTitle"; -export * from "./RangeCalendarState"; +export * from "./calendar-base"; +export * from "./calendar-base-state"; +export * from "./calendar-cell"; +export * from "./calendar-cell-button"; +export * from "./calendar-cell-state"; +export * from "./calendar-grid"; +export * from "./calendar-grid-state"; +export * from "./calendar-next-button"; +export * from "./calendar-prev-button"; +export * from "./calendar-state"; +export * from "./calendar-title"; +export * from "./range-calendar"; +export * from "./range-calendar-base-state"; +export * from "./range-calendar-state"; diff --git a/src/calendar/range-calendar-base-state.ts b/src/calendar/range-calendar-base-state.ts new file mode 100644 index 000000000..bc627a3f4 --- /dev/null +++ b/src/calendar/range-calendar-base-state.ts @@ -0,0 +1,33 @@ +import { Calendar, DateDuration } from "@internationalized/date"; +import { + RangeCalendarState, + useRangeCalendarState, +} from "@react-stately/calendar"; +import { DateValue, RangeCalendarProps } from "@react-types/calendar"; + +export function useRangeCalendarBaseState( + props: RangeCalendarBaseStateProps, +): RangeCalendarBaseState { + const state = useRangeCalendarState(props); + + return state; +} + +export type RangeCalendarBaseState = RangeCalendarState; + +export type RangeCalendarBaseStateProps = RangeCalendarProps & { + /** The locale to display and edit the value according to. */ + locale: string; + /** + * A function that creates a [Calendar](../internationalized/date/Calendar.html) + * object for a given calendar identifier. Such a function may be imported from the + * `@internationalized/date` package, or manually implemented to include support for + * only certain calendars. + */ + createCalendar: (name: string) => Calendar; + /** + * The amount of days that will be displayed at once. This affects how pagination works. + * @default {months: 1} + */ + visibleDuration?: DateDuration; +}; diff --git a/src/calendar/range-calendar-state.ts b/src/calendar/range-calendar-state.ts new file mode 100644 index 000000000..3e392eff3 --- /dev/null +++ b/src/calendar/range-calendar-state.ts @@ -0,0 +1,38 @@ +import { HTMLAttributes, RefObject, useRef } from "react"; +import { useRangeCalendar } from "@react-aria/calendar"; +import { AriaButtonProps } from "@react-types/button"; +import { DateValue, RangeCalendarProps } from "@react-types/calendar"; + +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export function useRangeCalendarState({ + state, + ...props +}: RangeCalendarStateProps): RangeCalendarState { + const ref = useRef(null); + const calendarProps = useRangeCalendar(props, state, ref); + + return { ...calendarProps, ref }; +} + +export type RangeCalendarState = { + /** Props for the calendar grouping element. */ + calendarProps: HTMLAttributes; + /** Props for the next button. */ + nextButtonProps: AriaButtonProps; + /** Props for the previous button. */ + prevButtonProps: AriaButtonProps; + /** A description of the visible date range, for use in the calendar title. */ + title: string; + /** + * Reference for the calendar wrapper element within the cell inside the table + */ + ref: RefObject; +}; + +export type RangeCalendarStateProps = RangeCalendarProps & { + /** + * Object returned by the `useSliderState` hook. + */ + state: RangeCalendarBaseState; +}; diff --git a/src/calendar/range-calendar.ts b/src/calendar/range-calendar.ts new file mode 100644 index 000000000..caa2fbf37 --- /dev/null +++ b/src/calendar/range-calendar.ts @@ -0,0 +1,36 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { RangeCalendarState } from "./range-calendar-state"; + +export const useRangeCalendar = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.calendarProps, props); + + return props; + }, +); + +export const RangeCalendar = createComponent(props => { + const htmlProps = useRangeCalendar(props); + + return createElement("div", htmlProps); +}); + +export type RangeCalendarOptions = Options & { + /** + * Object returned by the `useRangeCalendarState` hook. + */ + state: RangeCalendarState; +}; + +export type RangeCalendarProps = Props< + RangeCalendarOptions +>; diff --git a/src/calendar/stories/CalendarBasic.component.tsx b/src/calendar/stories/CalendarBasic.component.tsx index e5a8604d3..a3dae5a56 100644 --- a/src/calendar/stories/CalendarBasic.component.tsx +++ b/src/calendar/stories/CalendarBasic.component.tsx @@ -1,77 +1,113 @@ import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; import { - Calendar as CalendarWrapper, - CalendarButton, + Calendar, + CalendarBaseStateProps, CalendarCell, CalendarCellButton, + CalendarCellStateProps, CalendarGrid, - CalendarHeader, - CalendarInitialState, - CalendarWeekTitle, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + useCalendarBaseState, + useCalendarCellState, + useCalendarGridState, useCalendarState, } from "../../index"; -import { - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarBasicProps = CalendarBaseStateProps & {}; -export const Calendar: React.FC = props => { - const state = useCalendarState(props); +export const CalendarBasic: React.FC = props => { + const state = useCalendarBaseState(props); + const calendar = useCalendarState({ ...props, state }); return ( - + - - - - + - - - + + + - - - - + + + + ); +}; + +export default CalendarBasic; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - + ))} + + ); }; -export default Calendar; +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarBasic.css b/src/calendar/stories/CalendarBasic.css index 32193ea4b..ff17a4c0a 100644 --- a/src/calendar/stories/CalendarBasic.css +++ b/src/calendar/stories/CalendarBasic.css @@ -38,30 +38,24 @@ border: 0; } -.calendar .prev-year, .calendar .prev-month, -.calendar .next-month, -.calendar .next-year { +.calendar .next-month { padding: 4px; width: 24px; height: 24px; color: #676d7e; } -.calendar .prev-year:focus, .calendar .prev-month:focus, -.calendar .next-month:focus, -.calendar .next-year:focus { +.calendar .next-month:focus { padding: 2px; border: 2px solid #676d7e; border-radius: 4px; outline: 0; } -.calendar .prev-year:hover, .calendar .prev-month:hover, -.calendar .next-month:hover, -.calendar .next-year:hover { +.calendar .next-month:hover { padding: 3px; border: 1px solid #676d7e; border-radius: 4px; @@ -89,7 +83,7 @@ text-align: center; } -.calendar .dates th abbr { +.calendar .dates th span { text-decoration: none; } @@ -113,7 +107,7 @@ user-select: none; } -.calendar .dates td[aria-selected] span { +.calendar .dates td[aria-selected="true"] span { border-radius: 50%; border: 2px dotted black; background-color: #fbfbff; diff --git a/src/calendar/stories/CalendarBasic.stories.tsx b/src/calendar/stories/CalendarBasic.stories.tsx index 1456163dc..6701de79a 100644 --- a/src/calendar/stories/CalendarBasic.stories.tsx +++ b/src/calendar/stories/CalendarBasic.stories.tsx @@ -1,66 +1,33 @@ import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { addMonths, addYears, subMonths, toUTCString } from "../../utils"; import css from "./templates/CalendarBasicCss"; import js from "./templates/CalendarBasicJsx"; import ts from "./templates/CalendarBasicTsx"; import jsUtils from "./templates/UtilsJsx"; import tsUtils from "./templates/UtilsTsx"; -import Calendar from "./CalendarBasic.component"; +import { CalendarBasic } from "./CalendarBasic.component"; import "./CalendarBasic.css"; +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + export default { - component: Calendar, title: "Calendar/Basic", - argTypes: { - defaultValue: { control: "date" }, - value: { control: "date" }, - minValue: { control: "date" }, - maxValue: { control: "date" }, - }, + component: CalendarBasic, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css, jsUtils, tsUtils }), }, } as Meta; -export const Default: Story = args => { - return ; -}; - -export const DefaultDate = Default.bind({}); -DefaultDate.args = { defaultValue: toUTCString(addYears(new Date(), 1)) }; - -export const MinMaxDate = Default.bind({}); -MinMaxDate.args = { - minValue: toUTCString(subMonths(new Date(), 1)), - maxValue: toUTCString(addMonths(new Date(), 1)), -}; - -export const IsDisabled = Default.bind({}); -IsDisabled.args = { defaultValue: toUTCString(new Date()), isDisabled: true }; - -export const IsReadonly = Default.bind({}); -IsReadonly.args = { defaultValue: toUTCString(new Date()), isReadOnly: true }; - -export const AutoFocus = Default.bind({}); -AutoFocus.args = { defaultValue: toUTCString(new Date()), autoFocus: true }; - -export const ControlledInput = () => { - const [value, setValue] = React.useState("2020-10-13"); +export const Default = () => { + let { locale } = useLocale(); - return ( - - setValue(e.target.value)} - value={value} - /> - - - ); + return ; }; diff --git a/src/calendar/stories/CalendarRange.component.tsx b/src/calendar/stories/CalendarRange.component.tsx index 7498e388c..246adc737 100644 --- a/src/calendar/stories/CalendarRange.component.tsx +++ b/src/calendar/stories/CalendarRange.component.tsx @@ -1,77 +1,113 @@ import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; import { - Calendar, - CalendarButton, CalendarCell, CalendarCellButton, + CalendarCellStateProps, CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - RangeCalendarInitialState, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + RangeCalendar, + RangeCalendarBaseStateProps, + useCalendarCellState, + useCalendarGridState, + useRangeCalendarBaseState, useRangeCalendarState, } from "../../index"; -import { - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarRangeProps = RangeCalendarBaseStateProps & {}; -export const RangeCalendar: React.FC = props => { - const state = useRangeCalendarState(props); +export const CalendarRange: React.FC = props => { + const state = useRangeCalendarBaseState(props); + const calendar = useRangeCalendarState({ ...props, state }); return ( - + - - - - + - - - + + + - - - - + + + + ); +}; + +export default CalendarRange; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - + ))} + + ); }; -export default RangeCalendar; +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarRange.css b/src/calendar/stories/CalendarRange.css index 706b15de9..935a89084 100644 --- a/src/calendar/stories/CalendarRange.css +++ b/src/calendar/stories/CalendarRange.css @@ -90,7 +90,7 @@ text-align: center; } -.calendar-range .dates th abbr { +.calendar-range .dates th span { text-decoration: none; } diff --git a/src/calendar/stories/CalendarRange.stories.tsx b/src/calendar/stories/CalendarRange.stories.tsx index 1711b52a8..102f97963 100644 --- a/src/calendar/stories/CalendarRange.stories.tsx +++ b/src/calendar/stories/CalendarRange.stories.tsx @@ -1,95 +1,33 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { addDays, addWeeks, subDays, subWeeks, toUTCString } from "../../utils"; import css from "./templates/CalendarRangeCss"; import js from "./templates/CalendarRangeJsx"; import ts from "./templates/CalendarRangeTsx"; import jsUtils from "./templates/UtilsJsx"; import tsUtils from "./templates/UtilsTsx"; -import RangeCalendar from "./CalendarRange.component"; +import { CalendarRange } from "./CalendarRange.component"; import "./CalendarRange.css"; +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + export default { - component: RangeCalendar, title: "Calendar/Range", - argTypes: { - minValue: { control: "date" }, - maxValue: { control: "date" }, - }, + component: CalendarRange, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css, jsUtils, tsUtils }), }, } as Meta; -export const Default: Story = args => { - return ; -}; - -export const DefaultValue = Default.bind({}); -DefaultValue.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, -}; - -export const MinMaxDate = Default.bind({}); -MinMaxDate.args = { - minValue: toUTCString(subWeeks(new Date(), 1)), - maxValue: toUTCString(addWeeks(new Date(), 1)), -}; - -export const Disabled = Default.bind({}); -Disabled.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - isDisabled: true, -}; - -export const Readonly = Default.bind({}); -Readonly.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - isReadOnly: true, -}; - -export const Autofocus = Default.bind({}); -Autofocus.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - autoFocus: true, -}; - -export const ControlledInput = () => { - const [start, setStart] = React.useState(toUTCString(subDays(new Date(), 1))); - const [end, setEnd] = React.useState(toUTCString(addDays(new Date(), 1))); +export const Default = () => { + let { locale } = useLocale(); - return ( - - setStart(e.target.value)} - value={start} - /> - setEnd(e.target.value)} value={end} /> - { - setStart(start); - setEnd(end); - }} - /> - - ); + return ; }; diff --git a/src/calendar/stories/CalendarRangeStyled.component.tsx b/src/calendar/stories/CalendarRangeStyled.component.tsx new file mode 100644 index 000000000..24d9d77fb --- /dev/null +++ b/src/calendar/stories/CalendarRangeStyled.component.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; + +import { + CalendarCell, + CalendarCellButton, + CalendarCellStateProps, + CalendarGrid, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + RangeCalendar, + RangeCalendarBaseStateProps, + useCalendarCellState, + useCalendarGridState, + useRangeCalendarBaseState, + useRangeCalendarState, +} from "../../index"; + +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarRangeStyledProps = RangeCalendarBaseStateProps & {}; + +export const CalendarRangeStyled: React.FC< + CalendarRangeStyledProps +> = props => { + const state = useRangeCalendarBaseState(props); + const calendar = useRangeCalendarState({ ...props, state }); + + return ( + + + + + + + + + + + + + ); +}; + +export default CalendarRangeStyled; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); + + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} + + ))} + + + ); +}; + +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarRangeStyled.stories.tsx b/src/calendar/stories/CalendarRangeStyled.stories.tsx new file mode 100644 index 000000000..09db680aa --- /dev/null +++ b/src/calendar/stories/CalendarRangeStyled.stories.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/CalendarRangeStyledJsx"; +import ts from "./templates/CalendarRangeStyledTsx"; +import jsUtils from "./templates/UtilsJsx"; +import tsUtils from "./templates/UtilsTsx"; +import { CalendarRangeStyled } from "./CalendarRangeStyled.component"; + +import "./tailwind.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "Calendar/RangeStyled", + component: CalendarRangeStyled, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, jsUtils, tsUtils }), + }, + decorators: [ + Story => { + document.body.id = "tailwind"; + return ; + }, + ], +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ( + + ); +}; diff --git a/src/calendar/stories/CalendarStyled.component.tsx b/src/calendar/stories/CalendarStyled.component.tsx new file mode 100644 index 000000000..e0f6e220a --- /dev/null +++ b/src/calendar/stories/CalendarStyled.component.tsx @@ -0,0 +1,128 @@ +import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; + +import { + Calendar, + CalendarBaseStateProps, + CalendarCell, + CalendarCellButton, + CalendarCellStateProps, + CalendarGrid, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + useCalendarBaseState, + useCalendarCellState, + useCalendarGridState, + useCalendarState, +} from "../../index"; + +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarStyledProps = CalendarBaseStateProps & {}; + +export const CalendarStyled: React.FC = props => { + const state = useCalendarBaseState(props); + const calendar = useCalendarState({ ...props, state }); + + return ( + + + + + + + + + + + + + ); +}; + +export default CalendarStyled; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); + + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} + + ))} + + + ); +}; + +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarStyled.stories.tsx b/src/calendar/stories/CalendarStyled.stories.tsx new file mode 100644 index 000000000..497ccf0ba --- /dev/null +++ b/src/calendar/stories/CalendarStyled.stories.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/CalendarStyledJsx"; +import ts from "./templates/CalendarStyledTsx"; +import jsUtils from "./templates/UtilsJsx"; +import tsUtils from "./templates/UtilsTsx"; +import { CalendarStyled } from "./CalendarStyled.component"; + +import "./tailwind.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "Calendar/Styled", + component: CalendarStyled, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, jsUtils, tsUtils }), + }, + decorators: [ + Story => { + document.body.id = "tailwind"; + return ; + }, + ], +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ; +}; diff --git a/src/calendar/stories/Utils.component.tsx b/src/calendar/stories/Utils.component.tsx index cb9286a68..b8b592850 100644 --- a/src/calendar/stories/Utils.component.tsx +++ b/src/calendar/stories/Utils.component.tsx @@ -1,24 +1,5 @@ import * as React from "react"; -export const DoubleChevronLeft = (props: React.SVGProps) => { - return ( - - - - ); -}; - export const ChevronLeft = (props: React.SVGProps) => { return ( ) => { export const ChevronRight = (props: React.SVGProps) => ( ); - -export const DoubleChevronRight = (props: React.SVGProps) => ( - -); diff --git a/src/calendar/stories/tailwind.css b/src/calendar/stories/tailwind.css new file mode 100644 index 000000000..a7de9a2d9 --- /dev/null +++ b/src/calendar/stories/tailwind.css @@ -0,0 +1,53 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .styled-datepicker .calendar__cell { + height: 32px; + width: 32px; + max-height: 32px; + max-width: 32px; + @apply text-sm text-center rounded-lg; + } + .styled-datepicker .calendar__cell[data-is-range-selection] { + @apply bg-blue-100 rounded-none text-gray-800 !important; + } + .styled-datepicker .calendar__cell[data-is-selection-start] { + @apply bg-blue-500 rounded-l-lg text-white !important; + } + .styled-datepicker .calendar__cell[data-is-selection-end] { + @apply bg-blue-500 rounded-r-lg text-white !important; + } + + .styled-datepicker .calendar__cell[data-is-range-selection]:focus-within { + @apply bg-blue-400 text-white !important; + } + .styled-datepicker .calendar__cell:focus-within { + @apply bg-gray-100; + } + + .styled-datepicker.calendar [data-weekend] { + @apply text-red-600; + } + + .styled-datepicker.calendar [aria-selected="true"] { + @apply text-white bg-blue-500; + } + + .styled-datepicker.calendar [aria-selected]:focus-within { + @apply bg-gray-100; + } + + .styled-datepicker.calendar [aria-selected="true"]:focus-within { + @apply text-white bg-blue-400; + } + + .styled-datepicker.calendar [aria-disabled="true"] { + @apply text-gray-500; + } + + .styled-datepicker.calendar span { + outline: none; + } +} diff --git a/src/checkbox/Checkbox.tsx b/src/checkbox/Checkbox.tsx deleted file mode 100644 index b097541df..000000000 --- a/src/checkbox/Checkbox.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import * as React from "react"; -import { ClickableHTMLProps, ClickableOptions, useClickable } from "reakit"; -import { removeIndexFromArray, useForkRef, useLiveRef } from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { createComponent, createHook } from "../system"; - -import { CHECKBOX_KEYS } from "./__keys"; -import { CheckboxStateReturn } from "./CheckboxState"; -import { fireChange, getChecked, useIndeterminateState } from "./helpers"; - -export type CheckboxOptions = ClickableOptions & - Pick, "state" | "setState"> & { - /** - * Checkbox's value is going to be used when multiple checkboxes share the - * same state. Checking a checkbox with value will add it to the state - * array. - */ - value?: string | number; - - /** - * Checkbox's checked state. If present, it's used instead of `state`. - */ - checked?: boolean; - }; - -export type CheckboxHTMLProps = ClickableHTMLProps & - React.InputHTMLAttributes & { - value?: string | number; - }; - -export type CheckboxProps = CheckboxOptions & CheckboxHTMLProps; - -export const useCheckbox = createHook({ - name: "Checkbox", - compose: useClickable, - keys: CHECKBOX_KEYS, - - useOptions(options, htmlProps) { - const { unstable_clickOnEnter = false, ...restOptions } = options; - const { value, checked } = htmlProps; - - return { - unstable_clickOnEnter, - value, - checked: getChecked({ checked, ...options }), - ...restOptions, - }; - }, - - useProps(options, htmlProps) { - const { state, setState, value, checked, disabled } = options; - const { - ref: htmlRef, - onChange: htmlOnChange, - onClick: htmlOnClick, - ...restHtmlProps - } = htmlProps; - const ref = React.useRef(null); - const [isNativeCheckbox, setIsNativeCheckbox] = React.useState(true); - const onChangeRef = useLiveRef(htmlOnChange); - const onClickRef = useLiveRef(htmlOnClick); - - React.useEffect(() => { - const element = ref.current; - - if (!element) { - warning( - true, - "Can't determine whether the element is a native checkbox because `ref` wasn't passed to the component", - ); - return; - } - - if (element.tagName !== "INPUT" || element.type !== "checkbox") { - setIsNativeCheckbox(false); - } - }, []); - - useIndeterminateState(ref, options); - - const onChange = React.useCallback( - (event: React.ChangeEvent) => { - const element = event.currentTarget; - - if (disabled) { - event.stopPropagation(); - event.preventDefault(); - - return; - } - - if (onChangeRef.current) { - // If component is NOT rendered as a native input, it will not have - // the `checked` property. So we assign it for consistency. - if (!isNativeCheckbox) { - element.checked = !element.checked; - } - - onChangeRef.current(event); - } - - if (!setState) return; - - if (typeof value === "undefined") { - setState(!checked); - } else { - const stateProp = Array.isArray(state) ? state : []; - const index = stateProp.indexOf(value); - - if (index === -1) { - setState([...stateProp, value]); - } else { - setState(removeIndexFromArray(stateProp, index)); - } - } - }, - [ - disabled, - onChangeRef, - setState, - value, - isNativeCheckbox, - checked, - state, - ], - ); - - const onClick = React.useCallback( - (event: React.MouseEvent) => { - onClickRef.current?.(event); - - if (event.defaultPrevented) return; - - if (isNativeCheckbox) return; - - fireChange(event.currentTarget, onChange); - }, - [isNativeCheckbox, onChange, onClickRef], - ); - - return { - ref: useForkRef(ref, htmlRef), - role: !isNativeCheckbox ? "checkbox" : undefined, - type: isNativeCheckbox ? "checkbox" : undefined, - value: isNativeCheckbox ? value : undefined, - checked: checked, - "aria-checked": state === "indeterminate" ? "mixed" : checked, - onChange, - onClick, - ...restHtmlProps, - }; - }, -}); - -export const Checkbox = createComponent({ - as: "input", - memo: true, - useHook: useCheckbox, -}); diff --git a/src/checkbox/CheckboxState.tsx b/src/checkbox/CheckboxState.tsx deleted file mode 100644 index 4ca481e9a..000000000 --- a/src/checkbox/CheckboxState.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useControllableState } from "../utils/index"; - -export type CheckboxState = { - /** - * Stores the state of the checkbox. - * If checkboxes that share this state have defined a `value` prop, it's - * going to be an array. - */ - state: boolean | "indeterminate" | Array; -}; - -export type CheckboxActions = { - /** - * Sets `state` for the checkbox. - */ - setState: React.Dispatch>; -}; - -export type CheckboxInitialState = { - /** - * Default State of the Checkbox for uncontrolled Checkbox. - * - * @default false - */ - defaultState?: CheckboxState["state"]; - - /** - * State of the Checkbox for controlled Checkbox.. - */ - state?: CheckboxState["state"]; - - /** - * OnChange callback for controlled Checkbox. - */ - onStateChange?: React.Dispatch>; -}; - -export type CheckboxStateReturn = CheckboxState & CheckboxActions; - -export function useCheckboxState( - props: CheckboxInitialState = {}, -): CheckboxStateReturn { - const { - // Default State should be false otherwise input state will be undefined - defaultState = false, - state: stateProp, - onStateChange, - } = props; - - const [state, setState] = useControllableState({ - defaultValue: defaultState, - value: stateProp, - onChange: onStateChange, - }); - - return { state, setState }; -} diff --git a/src/checkbox/__keys.ts b/src/checkbox/__keys.ts deleted file mode 100644 index 372208d29..000000000 --- a/src/checkbox/__keys.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Automatically generated -export const USE_CHECKBOX_STATE_KEYS = [ - "defaultState", - "state", - "onStateChange", -] as const; -export const CHECKBOX_STATE_KEYS = ["state", "setState"] as const; -export const CHECKBOX_KEYS = [ - ...CHECKBOX_STATE_KEYS, - "value", - "checked", -] as const; diff --git a/src/checkbox/helpers.tsx b/src/checkbox/helpers.tsx deleted file mode 100644 index 77b0a1ed6..000000000 --- a/src/checkbox/helpers.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react"; -import { createEvent } from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { CheckboxOptions } from "./Checkbox"; - -export function getChecked(options: CheckboxOptions) { - const { checked, value, state } = options; - if (typeof checked !== "undefined") return checked; - - if (typeof value === "undefined") return !!state; - - const stateProp = Array.isArray(state) ? state : []; - - return stateProp.indexOf(value) !== -1; -} - -export function fireChange( - element: HTMLElement, - onChange?: React.ChangeEventHandler, -) { - const event = createEvent(element, "change"); - - Object.defineProperties(event, { - type: { value: "change" }, - target: { value: element }, - currentTarget: { value: element }, - }); - - onChange?.(event as any); -} - -export function useIndeterminateState( - ref: React.RefObject, - options: CheckboxOptions, -) { - const { state } = options; - - React.useEffect(() => { - const element = ref.current; - - if (!element) { - warning( - state === "indeterminate", - "Can't set indeterminate state because `ref` wasn't passed to component.", - ); - return; - } - - if (state === "indeterminate") { - element.indeterminate = true; - } else if (element.indeterminate) { - element.indeterminate = false; - } - }, [state, ref]); -} diff --git a/src/checkbox/index.ts b/src/checkbox/index.ts deleted file mode 100644 index 95b2cc59a..000000000 --- a/src/checkbox/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./__keys"; -export * from "./Checkbox"; -export * from "./CheckboxState"; diff --git a/src/checkbox/stories/CheckboxBasic.component.tsx b/src/checkbox/stories/CheckboxBasic.component.tsx deleted file mode 100644 index f4e398794..000000000 --- a/src/checkbox/stories/CheckboxBasic.component.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from "react"; - -import { - Checkbox as RenderlesskitCheckbox, - CheckboxHTMLProps, - CheckboxInitialState, - splitStateProps, - USE_CHECKBOX_STATE_KEYS, - useCheckboxState, -} from "../../index"; - -export type CheckboxProps = CheckboxHTMLProps & CheckboxInitialState & {}; - -export const Checkbox: React.FC = props => { - const [stateProps, checkboxProps] = splitStateProps< - CheckboxInitialState, - CheckboxProps - >(props, USE_CHECKBOX_STATE_KEYS); - - const state = useCheckboxState(stateProps); - - return ; -}; - -export default Checkbox; diff --git a/src/checkbox/stories/CheckboxBasic.stories.tsx b/src/checkbox/stories/CheckboxBasic.stories.tsx deleted file mode 100644 index a6d84f96a..000000000 --- a/src/checkbox/stories/CheckboxBasic.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; -import { CheckboxState } from "../CheckboxState"; - -import js from "./templates/CheckboxBasicJsx"; -import ts from "./templates/CheckboxBasicTsx"; -import { Checkbox, CheckboxProps } from "./CheckboxBasic.component"; - -export default { - component: Checkbox, - title: "Checkbox/Basic", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts }), - }, - argTypes: createControls({ - ignore: [ - "unstable_system", - "unstable_clickOnEnter", - "unstable_clickOnSpace", - "wrapElement", - "focusable", - "as", - "checked", - "state", - "setState", - "onStateChange", - "value", - ], - }), -} as Meta; - -export const Default: Story = args => ; - -export const Controlled = () => { - const [value, setValue] = React.useState(true); - console.log("%cvalue", "color: #997326", value); - - return ; -}; diff --git a/src/datefield/date-segment.ts b/src/datefield/date-segment.ts new file mode 100644 index 000000000..8bc70b9bc --- /dev/null +++ b/src/datefield/date-segment.ts @@ -0,0 +1,43 @@ +import { useRef } from "react"; +import { useDateSegment as useAriaDateSegment } from "@react-aria/datepicker"; +import { mergeProps } from "@react-aria/utils"; +import { DateSegment as DateSegmentState } from "@react-stately/datepicker"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DateFieldBaseState } from "./datefield-base-state"; + +export const useDateSegment = createHook( + ({ state, segment, ...props }) => { + const ref = useRef(null); + + props = { ...props, ref: useForkRef(ref, props.ref) }; + const { segmentProps } = useAriaDateSegment(segment, state, ref); + props = mergeProps(segmentProps, props); + + return props; + }, +); + +export const DateSegment = createComponent(props => { + const htmlProps = useDateSegment(props); + + return createElement("div", htmlProps); +}); + +export type DateSegmentOptions = Options & { + segment: DateSegmentState; + /** + * Object returned by the `useDateFieldBaseState` hook. + */ + state: DateFieldBaseState; +}; + +export type DateSegmentProps = Props< + DateSegmentOptions +>; diff --git a/src/datefield/datefield-base-state.ts b/src/datefield/datefield-base-state.ts new file mode 100644 index 000000000..dd18ffd25 --- /dev/null +++ b/src/datefield/datefield-base-state.ts @@ -0,0 +1,34 @@ +import { Calendar } from "@internationalized/date"; +import { DateFieldState, useDateFieldState } from "@react-stately/datepicker"; +import { + DatePickerProps, + DateValue, + Granularity, +} from "@react-types/datepicker"; + +export function useDateFieldBaseState( + props: DateFieldBaseStateProps, +): DateFieldBaseState { + const state = useDateFieldState(props); + + return state; +} + +export type DateFieldBaseState = DateFieldState & {}; + +export type DateFieldBaseStateProps = DatePickerProps & { + /** + * The maximum unit to display in the date field. + * @default 'year' + */ + maxGranularity?: "year" | "month" | Granularity; + /** The locale to display and edit the value according to. */ + locale: string; + /** + * A function that creates a [Calendar](../internationalized/date/Calendar.html) + * object for a given calendar identifier. Such a function may be imported from the + * `@internationalized/date` package, or manually implemented to include support for + * only certain calendars. + */ + createCalendar: (name: string) => Calendar; +}; diff --git a/src/datefield/datefield-base.ts b/src/datefield/datefield-base.ts new file mode 100644 index 000000000..c107ca451 --- /dev/null +++ b/src/datefield/datefield-base.ts @@ -0,0 +1,34 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DateFieldState } from "./datefield-state"; + +export const useDateField = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.fieldProps, props); + + return props; + }, +); + +export const DateField = createComponent(props => { + const htmlProps = useDateField(props); + + return createElement("div", htmlProps); +}); + +export type DateFieldOptions = Options & { + /** + * Object returned by the `useDateFieldBaseState` hook. + */ + state: DateFieldState; +}; + +export type DateFieldProps = Props>; diff --git a/src/datefield/datefield-state.ts b/src/datefield/datefield-state.ts new file mode 100644 index 000000000..088bbbcc0 --- /dev/null +++ b/src/datefield/datefield-state.ts @@ -0,0 +1,38 @@ +import { RefObject, useRef } from "react"; +import { DateValue } from "@internationalized/date"; +import { DateFieldAria, useDateField } from "@react-aria/datepicker"; +import { AriaDatePickerProps } from "@react-types/datepicker"; + +import { DateFieldBaseState } from "./datefield-base-state"; + +export function useDateFieldState({ + state, + ...props +}: DateFieldStateProps): DateFieldState { + const ref = useRef(null); + const datefield = useDateField(props, state, ref); + + return { ...datefield, ref }; +} + +export type DateFieldState = DateFieldAria & { + /** + * Reference for the date picker's visible label element, if any. + */ + ref: RefObject; +}; + +export type DateFieldStateProps = Omit< + AriaDatePickerProps, + | "value" + | "defaultValue" + | "onChange" + | "minValue" + | "maxValue" + | "placeholderValue" +> & { + /** + * Object returned by the `useDateFieldBaseState` hook. + */ + state: DateFieldBaseState; +}; diff --git a/src/datefield/index.ts b/src/datefield/index.ts new file mode 100644 index 000000000..868020dbd --- /dev/null +++ b/src/datefield/index.ts @@ -0,0 +1,4 @@ +export * from "./date-segment"; +export * from "./datefield-base"; +export * from "./datefield-base-state"; +export * from "./datefield-state"; diff --git a/src/datefield/stories/DateFieldBasic.component.tsx b/src/datefield/stories/DateFieldBasic.component.tsx new file mode 100644 index 000000000..328bf7d73 --- /dev/null +++ b/src/datefield/stories/DateFieldBasic.component.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { + DateField, + DateFieldBaseStateProps, + DateSegment, + useDateFieldBaseState, + useDateFieldState, +} from "../../index"; + +export type DateFieldBasicProps = DateFieldBaseStateProps & {}; + +export const DateFieldBasic: React.FC = props => { + const state = useDateFieldBaseState({ ...props }); + const datefield = useDateFieldState({ ...props, state }); + + return ( + + {state.segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); +}; + +export default DateFieldBasic; diff --git a/src/datefield/stories/DateFieldBasic.css b/src/datefield/stories/DateFieldBasic.css new file mode 100644 index 000000000..65203bbe4 --- /dev/null +++ b/src/datefield/stories/DateFieldBasic.css @@ -0,0 +1,19 @@ +* { + box-sizing: border-box; +} + +.datepicker__field { + font-family: monospace; + display: flex; +} + +.datepicker__field--item { + padding: 2px; + border-radius: 4px; +} + +.datepicker__field--item:focus { + background-color: #1e65fd; + color: white; + outline: none; +} diff --git a/src/datefield/stories/DateFieldBasic.stories.tsx b/src/datefield/stories/DateFieldBasic.stories.tsx new file mode 100644 index 000000000..82cbd0bfa --- /dev/null +++ b/src/datefield/stories/DateFieldBasic.stories.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/DateFieldBasicCss"; +import js from "./templates/DateFieldBasicJsx"; +import ts from "./templates/DateFieldBasicTsx"; +import { DateFieldBasic } from "./DateFieldBasic.component"; + +import "./DateFieldBasic.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "DateField/Basic", + component: DateFieldBasic, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ( + + ); +}; diff --git a/src/datefield/stories/DateFieldStyled.component.tsx b/src/datefield/stories/DateFieldStyled.component.tsx new file mode 100644 index 000000000..131b2444d --- /dev/null +++ b/src/datefield/stories/DateFieldStyled.component.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { + DateField, + DateFieldBaseStateProps, + DateSegment, + useDateFieldBaseState, + useDateFieldState, +} from "../../index"; + +export type DateFieldStyledProps = DateFieldBaseStateProps & {}; + +export const DateFieldStyled: React.FC = props => { + const state = useDateFieldBaseState({ ...props }); + const datefield = useDateFieldState({ ...props, state }); + + return ( + + {state.segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); +}; + +export default DateFieldStyled; diff --git a/src/datefield/stories/DateFieldStyled.stories.tsx b/src/datefield/stories/DateFieldStyled.stories.tsx new file mode 100644 index 000000000..e292924bd --- /dev/null +++ b/src/datefield/stories/DateFieldStyled.stories.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/DateFieldStyledJsx"; +import ts from "./templates/DateFieldStyledTsx"; +import { DateFieldStyled } from "./DateFieldStyled.component"; + +import "./tailwind.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "DateField/Styled", + component: DateFieldStyled, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts }), + }, + decorators: [ + Story => { + document.body.id = "tailwind"; + return ; + }, + ], +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ( + + ); +}; diff --git a/src/datefield/stories/tailwind.css b/src/datefield/stories/tailwind.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/src/datefield/stories/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/datepicker/DatePicker.ts b/src/datepicker/DatePicker.ts deleted file mode 100644 index bb2737f58..000000000 --- a/src/datepicker/DatePicker.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - PickerBaseHTMLProps, - PickerBaseOptions, - usePickerBase, -} from "../picker-base"; -import { createComponent, createHook } from "../system"; -import { ariaAttr } from "../utils"; - -import { DATE_PICKER_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "./DatePickerState"; - -export type DatePickerOptions = PickerBaseOptions & - Pick; - -export type DatePickerHTMLProps = PickerBaseHTMLProps; - -export type DatePickerProps = DatePickerOptions & DatePickerHTMLProps; - -export const useDatePicker = createHook( - { - name: "DatePicker", - compose: usePickerBase, - keys: DATE_PICKER_KEYS, - - useProps(options, htmlProps) { - const { validationState, isRequired } = options; - - return { - "aria-invalid": ariaAttr(validationState === "invalid"), - "aria-required": ariaAttr(isRequired), - ...htmlProps, - }; - }, - }, -); - -export const DatePicker = createComponent({ - as: "div", - memo: true, - useHook: useDatePicker, -}); diff --git a/src/datepicker/DatePickerContent.ts b/src/datepicker/DatePickerContent.ts deleted file mode 100644 index 7e5a54693..000000000 --- a/src/datepicker/DatePickerContent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - PickerBaseHTMLProps, - PickerBaseOptions, - usePickerBaseContent, -} from "../picker-base"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_CONTENT_KEYS } from "./__keys"; - -export type DatePickerContentOptions = PickerBaseOptions; - -export type DatePickerContentHTMLProps = PickerBaseHTMLProps; - -export type DatePickerContentProps = DatePickerContentOptions & - DatePickerContentHTMLProps; - -export const useDatePickerContent = createHook< - DatePickerContentOptions, - DatePickerContentHTMLProps ->({ - name: "DatePickerContent", - compose: usePickerBaseContent, - keys: DATE_PICKER_CONTENT_KEYS, -}); - -export const DatePickerContent = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerContent, -}); diff --git a/src/datepicker/DatePickerSegment.ts b/src/datepicker/DatePickerSegment.ts deleted file mode 100644 index 0dcfcc5ca..000000000 --- a/src/datepicker/DatePickerSegment.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { unstable_useId as useId } from "reakit"; - -import { SegmentHTMLProps, SegmentOptions, useSegment } from "../segment"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_SEGMENT_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "."; - -export type DatePickerSegmentOptions = SegmentOptions & - Partial>; - -export type DatePickerSegmentHTMLProps = SegmentHTMLProps; - -export type DatePickerSegmentProps = DatePickerSegmentOptions & - DatePickerSegmentHTMLProps; - -export const useDatePickerSegment = createHook< - DatePickerSegmentOptions, - DatePickerSegmentHTMLProps ->({ - name: "DatePickerSegment", - compose: useSegment, - keys: DATE_PICKER_SEGMENT_KEYS, - - useProps(options, htmlProps) { - const { id } = useId({ baseId: "datepicker-segment" }); - return { - id, - ...(options.isDateRangePicker - ? { "aria-labelledby": `${options.pickerId} ${options.baseId} ${id}` } - : { "aria-labelledby": id }), - ...htmlProps, - }; - }, -}); - -export const DatePickerSegment = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerSegment, -}); diff --git a/src/datepicker/DatePickerSegmentField.ts b/src/datepicker/DatePickerSegmentField.ts deleted file mode 100644 index f3df3338f..000000000 --- a/src/datepicker/DatePickerSegmentField.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - SegmentFieldHTMLProps, - SegmentFieldOptions, - useSegmentField, -} from "../segment"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_SEGMENT_FIELD_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "./DatePickerState"; -import { DateRangePickerStateReturn } from "./DateRangePickerState"; - -export type DatePickerSegmentFieldOptions = - | SegmentFieldOptions - | Partial - | Partial; - -export type DatePickerSegmentFieldHTMLProps = SegmentFieldHTMLProps; - -export type DatePickerSegmentFieldProps = DatePickerSegmentFieldOptions & - DatePickerSegmentFieldHTMLProps; - -export const useDatePickerSegmentField = createHook< - DatePickerSegmentFieldOptions, - DatePickerSegmentFieldHTMLProps ->({ - name: "DatePickerSegmentField", - compose: useSegmentField, - keys: DATE_PICKER_SEGMENT_FIELD_KEYS, -}); - -export const DatePickerSegmentField = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerSegmentField, -}); diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts deleted file mode 100644 index 977f061f8..000000000 --- a/src/datepicker/DatePickerState.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useDatePickerState](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/datepicker/src/useDatePickerState.ts) - * to work with Reakit System - */ - -import * as React from "react"; -import { Validation, ValidationState, ValueBase } from "@react-types/shared"; - -import { useCalendarState } from "../calendar"; -import { PickerBaseInitialState, usePickerBaseState } from "../picker-base"; -import { SegmentInitialState, useSegmentState } from "../segment"; -import { toUTCString, useControllableState } from "../utils"; -import { RangeValueBase } from "../utils/types"; - -export type DatePickerInitialState = ValueBase & - RangeValueBase & - Validation & - PickerBaseInitialState & - Pick, "formatOptions" | "placeholderDate"> & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - }; - -export const useDatePickerState = (props: DatePickerInitialState = {}) => { - const { - value: initialValue, - defaultValue = toUTCString(new Date()), - onChange, - minValue, - maxValue, - isRequired, - autoFocus, - formatOptions, - placeholderDate, - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const date = new Date(value); - const setDate = (date: Date) => setValue(toUTCString(date)); - const minDateValue = minValue ? new Date(minValue) : new Date(-864e13); - const maxDateValue = maxValue ? new Date(maxValue) : new Date(864e13); - - const segmentState = useSegmentState({ - value: date, - onChange: setDate, - formatOptions, - placeholderDate, - }); - - const popover = usePickerBaseState({ - segmentFocus: segmentState.first, - ...props, - }); - - const selectDate = (newValue: string) => { - setValue(newValue); - popover.hide(); - }; - - const calendar = useCalendarState({ - value, - onChange: selectDate, - minValue: minValue, - maxValue: maxValue, - }); - - const validationState: ValidationState = - props.validationState || (isInvalidDateRange(date) ? "invalid" : "valid"); - - React.useEffect(() => { - if (popover.visible) { - calendar.setFocused(true); - calendar.focusCell(date); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [popover.visible]); - - React.useEffect(() => { - if (autoFocus) { - segmentState.first(); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFocus, segmentState.first]); - - function isInvalidDateRange(value: Date) { - const min = new Date(minDateValue); - const max = new Date(maxDateValue); - - return value < min || value > max; - } - - return { - dateValue: value, - setDateValue: setValue, - selectDate, - validationState, - minValue, - maxValue, - isRequired, - ...popover, - ...segmentState, - calendar, - isDateRangePicker: false, - }; -}; - -export type DatePickerStateReturn = ReturnType; diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts deleted file mode 100644 index 6e3c2c16c..000000000 --- a/src/datepicker/DatePickerTrigger.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - PickerBaseTriggerHTMLProps, - PickerBaseTriggerOptions, - usePickerBaseTrigger, -} from "../picker-base"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_TRIGGER_KEYS } from "./__keys"; - -export type DatePickerTriggerOptions = PickerBaseTriggerOptions; - -export type DatePickerTriggerHTMLProps = PickerBaseTriggerHTMLProps; - -export type DatePickerTriggerProps = DatePickerTriggerOptions & - DatePickerTriggerHTMLProps; - -export const useDatePickerTrigger = createHook< - DatePickerTriggerOptions, - DatePickerTriggerHTMLProps ->({ - name: "DatePickerTrigger", - compose: usePickerBaseTrigger, - keys: DATE_PICKER_TRIGGER_KEYS, -}); - -export const DatePickerTrigger = createComponent({ - as: "button", - memo: true, - useHook: useDatePickerTrigger, -}); diff --git a/src/datepicker/DateRangePickerState.ts b/src/datepicker/DateRangePickerState.ts deleted file mode 100644 index d6eae664a..000000000 --- a/src/datepicker/DateRangePickerState.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useDatePickerState](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/datepicker/src/useDatePickerState.ts) - * to work with Reakit System - */ - -import * as React from "react"; -import { unstable_useId as useId, useCompositeState } from "reakit"; -import { - RangeValue, - Validation, - ValidationState, - ValueBase, -} from "@react-types/shared"; - -import { useRangeCalendarState } from "../calendar"; -import { makeRange } from "../calendar/helpers"; -import { PickerBaseInitialState, usePickerBaseState } from "../picker-base"; -import { SegmentInitialState, useSegmentState } from "../segment"; -import { - addDays, - toUTCRangeString, - toUTCString, - useControllableState, -} from "../utils"; -import { RangeValueBase } from "../utils/types"; - -export type DateRangePickerInitialState = ValueBase> & - RangeValueBase & - Validation & - PickerBaseInitialState & - Pick, "formatOptions" | "placeholderDate"> & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - }; - -export const useDateRangePickerState = ( - props: DateRangePickerInitialState = {}, -) => { - const { - value: initialValue, - defaultValue = { - start: toUTCString(new Date()), - end: toUTCString(addDays(new Date(), 1)), - }, - onChange, - minValue, - maxValue, - isRequired, - autoFocus, - formatOptions, - placeholderDate, - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const dateRange: RangeValue = React.useMemo( - () => ({ - start: new Date(value.start), - end: new Date(value.end), - }), - [value.end, value.start], - ); - const minDateValue = minValue ? new Date(minValue) : new Date(-864e13); - const maxDateValue = maxValue ? new Date(maxValue) : new Date(864e13); - - const selectDate = (date: RangeValue) => { - if (props.isReadOnly || props.isDisabled) { - return; - } - - setValue( - toUTCRangeString(makeRange(new Date(date.start), new Date(date.end))), - ); - - popover.hide(); - }; - - const segmentComposite = useCompositeState({ orientation: "horizontal" }); - - const startSegmentState = useSegmentState({ - value: dateRange.start, - defaultValue: new Date(defaultValue.start), - onChange: date => - setValue(toUTCRangeString({ start: date, end: dateRange.end })), - formatOptions, - placeholderDate, - }); - - const endSegmentState = useSegmentState({ - value: dateRange.end, - defaultValue: new Date(defaultValue.end), - onChange: date => - setValue(toUTCRangeString({ start: dateRange.start, end: date })), - formatOptions, - placeholderDate, - }); - - const popover = usePickerBaseState({ - segmentFocus: segmentComposite.first, - ...props, - }); - - const calendar = useRangeCalendarState({ - value: { start: value.start, end: value.end }, - onChange: selectDate, - minValue: minValue, - maxValue: maxValue, - }); - - function isInvalidDateRange(value: Date) { - const min = new Date(minDateValue); - const max = new Date(maxDateValue); - - return value < min || value > max; - } - - const isStartInRange = isInvalidDateRange(dateRange.start); - const isEndInRange = isInvalidDateRange(dateRange.end); - - const validationState: ValidationState = - props.validationState || - (value != null && - (isStartInRange || - isEndInRange || - (value.end != null && value.start != null && value.end < value.start)) - ? "invalid" - : "valid"); - - React.useEffect(() => { - if (popover.visible) { - calendar.setFocused(true); - value.start && calendar.focusCell(new Date(value.start)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [popover.visible]); - - React.useEffect(() => { - if (autoFocus) { - segmentComposite.first(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFocus, segmentComposite.first]); - - const { id: startId } = useId({ baseId: "startsegment" }); - const { id: endId } = useId({ baseId: "endsegment" }); - - return { - dateValue: value, - setDateValue: setValue, - selectDate, - validationState, - minValue, - maxValue, - isRequired, - ...popover, - startSegmentState: { - ...startSegmentState, - ...segmentComposite, - baseId: startId, - }, - endSegmentState: { - ...endSegmentState, - ...segmentComposite, - baseId: endId, - }, - calendar, - isDateRangePicker: true, - }; -}; - -export type DateRangePickerStateReturn = ReturnType< - typeof useDateRangePickerState ->; diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts deleted file mode 100644 index 0cd428b9f..000000000 --- a/src/datepicker/__keys.ts +++ /dev/null @@ -1,163 +0,0 @@ -// Automatically generated -export const USE_DATE_PICKER_STATE_KEYS = [ - "value", - "defaultValue", - "onChange", - "minValue", - "maxValue", - "validationState", - "isRequired", - "baseId", - "visible", - "animated", - "modal", - "placement", - "unstable_fixed", - "unstable_flip", - "unstable_offset", - "gutter", - "unstable_preventOverflow", - "isDisabled", - "isReadOnly", - "pickerId", - "dialogId", - "segmentFocus", - "formatOptions", - "placeholderDate", - "autoFocus", -] as const; -export const DATE_PICKER_STATE_KEYS = [ - "calendar", - "isDateRangePicker", - "fieldValue", - "setFieldValue", - "segments", - "dateFormatter", - "increment", - "decrement", - "incrementPage", - "decrementPage", - "setSegment", - "confirmPlaceholder", - "baseId", - "unstable_idCountRef", - "setBaseId", - "unstable_virtual", - "rtl", - "orientation", - "items", - "groups", - "currentId", - "loop", - "wrap", - "shift", - "unstable_moves", - "unstable_hasActiveWidget", - "unstable_includesBaseElement", - "registerItem", - "unregisterItem", - "registerGroup", - "unregisterGroup", - "move", - "next", - "previous", - "up", - "down", - "first", - "last", - "sort", - "unstable_setVirtual", - "setRTL", - "setOrientation", - "setCurrentId", - "setLoop", - "setWrap", - "setShift", - "reset", - "unstable_setIncludesBaseElement", - "unstable_setHasActiveWidget", - "visible", - "animated", - "animating", - "show", - "hide", - "toggle", - "setVisible", - "setAnimated", - "stopAnimation", - "modal", - "unstable_disclosureRef", - "setModal", - "unstable_referenceRef", - "unstable_popoverRef", - "unstable_arrowRef", - "unstable_popoverStyles", - "unstable_arrowStyles", - "unstable_originalPlacement", - "unstable_update", - "placement", - "place", - "pickerId", - "dialogId", - "isDisabled", - "isReadOnly", - "segmentFocus", - "dateValue", - "setDateValue", - "selectDate", - "validationState", - "minValue", - "maxValue", - "isRequired", -] as const; -export const USE_DATE_RANGE_PICKER_STATE_KEYS = USE_DATE_PICKER_STATE_KEYS; -export const DATE_RANGE_PICKER_STATE_KEYS = [ - "startSegmentState", - "endSegmentState", - "calendar", - "isDateRangePicker", - "baseId", - "unstable_idCountRef", - "visible", - "animated", - "animating", - "setBaseId", - "show", - "hide", - "toggle", - "setVisible", - "setAnimated", - "stopAnimation", - "modal", - "unstable_disclosureRef", - "setModal", - "unstable_referenceRef", - "unstable_popoverRef", - "unstable_arrowRef", - "unstable_popoverStyles", - "unstable_arrowStyles", - "unstable_originalPlacement", - "unstable_update", - "placement", - "place", - "pickerId", - "dialogId", - "isDisabled", - "isReadOnly", - "segmentFocus", - "dateValue", - "setDateValue", - "selectDate", - "validationState", - "minValue", - "maxValue", - "isRequired", -] as const; -export const DATE_PICKER_KEYS = [ - ...DATE_PICKER_STATE_KEYS, - ...DATE_RANGE_PICKER_STATE_KEYS, -] as const; -export const DATE_PICKER_CONTENT_KEYS = DATE_PICKER_KEYS; -export const DATE_PICKER_SEGMENT_KEYS = DATE_PICKER_CONTENT_KEYS; -export const DATE_PICKER_SEGMENT_FIELD_KEYS = DATE_PICKER_SEGMENT_KEYS; -export const DATE_PICKER_TRIGGER_KEYS = DATE_PICKER_SEGMENT_FIELD_KEYS; diff --git a/src/datepicker/__tests__/DatePicker.test.tsx b/src/datepicker/__tests__/DatePicker.test.tsx deleted file mode 100644 index f1d3638d0..000000000 --- a/src/datepicker/__tests__/DatePicker.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import * as React from "react"; -import { axe, press, render, screen } from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; - -import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarStateReturn, - CalendarWeekTitle, -} from "../../calendar"; -import { addWeeks, subWeeks, toUTCString } from "../../utils"; -import { repeat } from "../../utils/test-utils"; -import { - DatePicker, - DatePickerContent, - DatePickerInitialState, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, - useDatePickerState, -} from ".."; - -/* -// Mocking useId otherwise snapshots will change each time -// since useCalendarState uses useId. -jest.spyOn(reakit, "unstable_useId").mockImplementation(options => ({ - id: options.baseId + "myid" -})); -*/ -afterEach(cleanup); - -export const CalendarComp: React.FC = state => { - return ( - - - - {"<"} - - - {"<<"} - - - - {">>"} - - - {">"} - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - - ); -}; - -const DatePickerComp: React.FC = props => { - const state = useDatePickerState({ - baseId: "calendar", - dialogId: "dialog", - pickerId: "picker", - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); - - return ( - <> - - - - {state.segments.map((segment, i) => ( - - ))} - - - open - - - - - - > - ); -}; - -const openDatePicker = () => { - press.Tab(); - expect(screen.getByLabelText("month", { selector: "div" })).toHaveFocus(); - - press.ArrowDown(null, { altKey: true }); - expect(screen.getByTestId("testid-datepicker-content")).toBeVisible(); -}; - -describe("DatePicker", () => { - it("should open/close the datepicker", () => { - render(); - - const datepickerContent = screen.getByTestId("testid-datepicker-content"); - const segment = screen.getByTestId("testid-segment"); - const month = screen.getByRole("spinbutton", { - name: /month/i, - }); - - expect(segment).toHaveTextContent("11/01/2020"); - expect(datepickerContent).not.toBeVisible(); - - // open - openDatePicker(); - - // close - press.Escape(); - expect(datepickerContent).not.toBeVisible(); - expect(month).toHaveFocus(); - }); - - it("should be able to open and select date", () => { - render(); - - const segment = screen.getByTestId("testid-segment"); - const datepickerContent = screen.getByTestId("testid-datepicker-content"); - const month = screen.getByRole("spinbutton", { - name: /month/i, - }); - - // open - openDatePicker(); - - // assert focused date on calendar - expect( - screen.getByLabelText(/Sunday, November 1, 2020 selected/i), - ).toHaveFocus(); - - // go to 24 - repeat(press.ArrowDown, 3); - repeat(press.ArrowRight, 2); - - expect(screen.getByLabelText(/Tuesday, November 24, 2020/i)).toHaveFocus(); - - press.Enter(); - expect(datepickerContent).not.toBeVisible(); - expect(month).toHaveFocus(); - expect(segment).toHaveTextContent("11/24/2020"); - }); - - it("should be able to open and select date and jump to different dates", () => { - render(); - const segment = screen.getByTestId("testid-segment"); - const calendarHeader = screen.getByTestId("testid-calendar-header"); - const datepickerContent = screen.getByTestId("testid-datepicker-content"); - const month = screen.getByRole("spinbutton", { - name: /month/i, - }); - - // open - openDatePicker(); - - // assert focused date on calendar - expect( - screen.getByLabelText(/^Sunday, November 1, 2020 selected$/i), - ).toHaveFocus(); - - // jump month - expect(calendarHeader).toHaveTextContent(/November 2020/i); - repeat(press.PageDown, 2); - - expect(calendarHeader).toHaveTextContent(/January 2021/i); - - // jump year - expect(calendarHeader).toHaveTextContent(/January 2021/i); - repeat(() => { - press.PageDown(null, { shiftKey: true }); - }, 2); - expect(calendarHeader).toHaveTextContent(/January 2023/i); - - press.Enter(); - expect(datepickerContent).not.toBeVisible(); - expect(month).toHaveFocus(); - expect(segment).toHaveTextContent("01/01/2023"); - }); - - it("should work with AutoFocus", () => { - render( - // eslint-disable-next-line jsx-a11y/no-autofocus - , - ); - - expect( - screen.getByRole("spinbutton", { - name: /month/i, - }), - ).toHaveFocus(); - }); - - it("should be invalid on out of range value", () => { - render( - , - ); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - - it("should be disabled", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-disabled", - "true", - ); - }); - - it("should be readonly", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-readonly", - "true", - ); - }); - - test("DatePicker renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container, { - rules: { "nested-interactive": { enabled: false } }, - }); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/datepicker/__tests__/DateRangePicker.test.tsx b/src/datepicker/__tests__/DateRangePicker.test.tsx deleted file mode 100644 index af2dd297a..000000000 --- a/src/datepicker/__tests__/DateRangePicker.test.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import * as React from "react"; -import { axe, fireEvent, press, render, screen } from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; - -import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - RangeCalendarStateReturn, -} from "../../calendar"; -import { - isEndSelection, - isInSelectionRange, - isStartSelection, - repeat, -} from "../../utils/test-utils"; -import { - DateRangePickerInitialState, - useDateRangePickerState, -} from "../DateRangePickerState"; -import { - DatePicker, - DatePickerContent, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, -} from "../index"; - -afterEach(cleanup); - -const RangeCalendarComp: React.FC = state => { - return ( - - - - {"<<"} - - - {"<"} - - - - {">"} - - - {">>"} - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - - ); -}; - -const DateRangePickerComp: React.FC = props => { - const state = useDateRangePickerState({ - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); - - return ( - <> - - - - {state.startSegmentState.segments.map((segment, i) => ( - - ))} - - - - - {state.endSegmentState.segments.map((segment, i) => ( - - ))} - - open - - - - - - > - ); -}; - -const openDatePicker = () => { - fireEvent.click(screen.getByText(/open/i)); - - jest.advanceTimersByTime(1); - - expect(screen.getByTestId("testid-datepicker-content")).toBeVisible(); -}; - -describe("DateRangePicker", () => { - it("should select date ranges correctly", () => { - jest.useFakeTimers(); - - render( - , - ); - - openDatePicker(); - - expect( - screen.getByLabelText(/Sunday, November 15, 2020 selected/), - ).toHaveFocus(); - - isStartSelection( - screen.getByLabelText(/Sunday, November 15, 2020 selected/i), - ); - // check if current date is selected - isEndSelection( - screen.getByLabelText(/Sunday, November 15, 2020 selected/i), - ); - isInSelectionRange( - screen.getByLabelText(/Sunday, November 15, 2020 selected/i), - ); - - // change date selection - press.Enter(); - press.ArrowRight(); - press.ArrowRight(); - press.ArrowDown(); - - expect(screen.getByLabelText(/Tuesday, November 24, 2020/gi)).toHaveFocus(); - - isEndSelection(screen.getByLabelText(/Tuesday, November 24, 2020/gi)); - isStartSelection(screen.getByLabelText(/Sunday, November 15, 2020/gi)); - isInSelectionRange(screen.getByLabelText(/Wednesday, November 18, 2020/gi)); - - // Finish selection - press.Enter(); - expect(screen.getByTestId("testid-datepicker-content")).not.toBeVisible(); - expect(screen.getByTestId("testid-segment")).toHaveTextContent( - "11/15/2020 - 11/24/2020", - ); - - jest.useRealTimers(); - }); - - it("should be invalid on wrong date selection", () => { - render( - , - ); - - const datepicker = screen.getByTestId("testid-datepicker"); - - expect(datepicker).not.toHaveAttribute("aria-invalid"); - - // reverse dates are invalid - repeat(press.Tab, 4); - repeat(press.ArrowDown, 2); - - expect(document.activeElement).toHaveTextContent("09"); - expect(datepicker).toHaveAttribute("aria-invalid", "true"); - }); - - it("should be invalid if selection range is out of min max values", () => { - render( - , - ); - const datepicker = screen.getByTestId("testid-datepicker"); - - expect(datepicker).not.toHaveAttribute("aria-invalid"); - - repeat(press.Tab, 2); - press.ArrowUp(); - - expect(document.activeElement).toHaveTextContent("16"); - expect(datepicker).toHaveAttribute("aria-invalid", "true"); - }); - - it("should be disabled", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-disabled", - "true", - ); - }); - - it("should be readonly", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-readonly", - "true", - ); - }); - - it("should work with AutoFocus", () => { - render( - // eslint-disable-next-line jsx-a11y/no-autofocus - , - ); - - expect( - screen.getAllByLabelText("month", { selector: "div" })[0], - ).toHaveFocus(); - }); - - test("DateRangePicker renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container, { - rules: { "nested-interactive": { enabled: false } }, - }); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/datepicker/datepicker-base-state.ts b/src/datepicker/datepicker-base-state.ts new file mode 100644 index 000000000..316c15fd7 --- /dev/null +++ b/src/datepicker/datepicker-base-state.ts @@ -0,0 +1,32 @@ +import { DatePickerState, useDatePickerState } from "@react-stately/datepicker"; +import { DatePickerProps, DateValue } from "@react-types/datepicker"; +import { PopoverState, PopoverStateProps, usePopoverState } from "ariakit"; + +export function useDatePickerBaseState( + props: DatePickerBaseStateProps, +): DatePickerBaseState { + const datepicker = useDatePickerState(props); + const { isOpen, setOpen } = datepicker; + + const popover = usePopoverState({ + visible: isOpen, + setVisible: setOpen, + ...props, + }); + + return { datepicker, popover }; +} + +export type DatePickerBaseState = { + datepicker: DatePickerState; + popover: PopoverState; +}; + +export type DatePickerBaseStateProps = DatePickerProps & + PopoverStateProps & { + /** + * Determines whether the date picker popover should close automatically when a date is selected. + * @default true + */ + shouldCloseOnSelect?: boolean | (() => boolean); + }; diff --git a/src/datepicker/datepicker-disclosure.ts b/src/datepicker/datepicker-disclosure.ts new file mode 100644 index 000000000..6a8437f28 --- /dev/null +++ b/src/datepicker/datepicker-disclosure.ts @@ -0,0 +1,46 @@ +import { useRef } from "react"; +import { useButton } from "@react-aria/button"; +import { usePopoverDisclosure } from "ariakit"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DatePickerState } from "./datepicker-state"; +import { DateRangePickerState } from "./daterangepicker-state"; + +export const useDatePickerDisclosure = createHook( + ({ state, ...props }) => { + const ref = useRef(null); + props = { ...props, ref: useForkRef(ref, props.ref, state.ref) }; + + props = usePopoverDisclosure({ ...props, state: state.baseState.popover }); + + const { buttonProps } = useButton(state.buttonProps, ref); + props = { ...props, ...buttonProps }; + + return props; + }, +); + +export const DatePickerDisclosure = + createComponent(props => { + const htmlProps = useDatePickerDisclosure(props); + + return createElement("button", htmlProps); + }); + +export type DatePickerDisclosureOptions = + Options & { + /** + * Object returned by the `useDatePickerDisclosureState` hook. + */ + state: DatePickerState | DateRangePickerState; + }; + +export type DatePickerDisclosureProps = Props< + DatePickerDisclosureOptions +>; diff --git a/src/datepicker/datepicker-group.ts b/src/datepicker/datepicker-group.ts new file mode 100644 index 000000000..e89212d83 --- /dev/null +++ b/src/datepicker/datepicker-group.ts @@ -0,0 +1,39 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DatePickerState } from "./datepicker-state"; +import { DateRangePickerState } from "./daterangepicker-state"; + +export const useDatePickerGroup = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.groupProps, props); + + return props; + }, +); + +export const DatePickerGroup = createComponent( + props => { + const htmlProps = useDatePickerGroup(props); + + return createElement("div", htmlProps); + }, +); + +export type DatePickerGroupOptions = Options & { + /** + * Object returned by the `useDatePickerState` hook. + */ + state: DatePickerState | DateRangePickerState; +}; + +export type DatePickerGroupProps = Props< + DatePickerGroupOptions +>; diff --git a/src/datepicker/datepicker-label.ts b/src/datepicker/datepicker-label.ts new file mode 100644 index 000000000..30a18b3d7 --- /dev/null +++ b/src/datepicker/datepicker-label.ts @@ -0,0 +1,37 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DatePickerState } from "./datepicker-state"; +import { DateRangePickerState } from "./daterangepicker-state"; + +export const useDatePickerLabel = createHook
@@ -109,14 +101,14 @@ export const Accordion: React.FC = props => { - + Shipping Address - + - + @@ -142,7 +134,7 @@ export const Accordion: React.FC = props => { - + ); }; diff --git a/src/accordion/stories/AccordionStyled.stories.tsx b/src/accordion/stories/AccordionStyled.stories.tsx index eb1c8ab8b..7b785c286 100644 --- a/src/accordion/stories/AccordionStyled.stories.tsx +++ b/src/accordion/stories/AccordionStyled.stories.tsx @@ -1,49 +1,52 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; +import { createPreviewTabs } from "../../../.storybook/utils"; import css from "./templates/AccordionStyledCss"; import js from "./templates/AccordionStyledJsx"; import ts from "./templates/AccordionStyledTsx"; -import { Accordion } from "./AccordionStyled.component"; +import { AccordionStyled } from "./AccordionStyled.component"; import "./AccordionStyled.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: Accordion, + component: AccordionStyled, title: "Accordion/Styled", parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css }), }, - argTypes: createControls({ - ignore: [ - "selectedId", - "wrap", - "baseId", - "unstable_virtual", - "rtl", - "orientation", - "currentId", - "shift", - "unstable_includesBaseElement", - "onSelectedIdChange", - "shouldUpdate", - ], - }), } as Meta; -export const Default: Story = args => ; +export const Default: Story = {}; + +export const DefaultFirstIdSelected: Story = { + args: { shouldSelectFirstId: true }, +}; + +export const DefaultSelected: Story = { + args: { defaultSelectedId: "accordion2" }, +}; + +export const SelectOnMove: Story = { + args: { selectOnMove: true }, +}; -export const DefaultSelected = Default.bind({}); -DefaultSelected.args = { defaultSelectedId: "accordion2" }; +export const NoLoop: Story = { + args: { focusLoop: false }, +}; -export const AutoSelect = Default.bind({}); -AutoSelect.args = { manual: false }; +export const AllowToggle: Story = { + args: { allowToggle: true }, +}; -export const Loop = Default.bind({}); -Loop.args = { loop: true }; +export const DefaultFirstIdToggle: Story = { + args: { shouldSelectFirstId: true, allowToggle: true }, +}; -export const AllowToggle = Default.bind({}); -AllowToggle.args = { allowToggle: true }; +export const SelectOnMoveToggle: Story = { + args: { selectOnMove: true, allowToggle: true }, +}; diff --git a/src/accordion/stories/AccordionTutorial.component.tsx b/src/accordion/stories/AccordionTutorial.component.tsx deleted file mode 100644 index e9d0607b8..000000000 --- a/src/accordion/stories/AccordionTutorial.component.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import * as React from "react"; - -import { - Accordion as RenderlesskitAccordion, - AccordionInitialState, - AccordionPanel, - AccordionTrigger, - useAccordionState, -} from "../../index"; - -export const Accordion: React.FC = props => { - const initialProps = { - defaultSelectedId: "accordion3", - manual: true, - loop: true, - allowToggle: true, - }; - - const state = useAccordionState(initialProps || props); - - const [text, setText] = React.useState("Start Tutorial"); - - let stateRef = React.useRef(state); - stateRef.current = state; - - const runTutorial = async () => { - stateRef.current.first(); - stateRef.current.currentId != null && - stateRef.current.setSelectedId(stateRef.current.currentId); - setText("Moved to First Accordion & opened it"); - await sleep(3000); - - stateRef.current.currentId != null && stateRef.current.select("accordion3"); - setText("Selected Accordion 3"); - await sleep(3000); - - stateRef.current.next(); - stateRef.current.currentId != null && - stateRef.current.setSelectedId(stateRef.current.currentId); - setText("Moved to Next Accordion & opened it"); - await sleep(3000); - - stateRef.current.previous(); - setText("Moved to Previous Accordion"); - await sleep(1500); - - stateRef.current.previous(); - stateRef.current.currentId != null && - stateRef.current.setSelectedId(stateRef.current.currentId); - setText("Moved to Previous Accordion once more & opened it"); - await sleep(3000); - - stateRef.current.currentId != null && stateRef.current.select("accordion6"); - setText("Selected Accordion 6"); - await sleep(3000); - }; - - return ( - - - {text} - - - - Trigger 1 - - Panel 1 - - Trigger 2 - - Panel 2 - - - Trigger 3 - - - Panel 3 - - Trigger 4 - - Panel 4 - - - Trigger 5 - - - Panel 5 - - - Trigger 6 - - - Panel 6 - - - ); -}; - -export default Accordion; - -function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/src/accordion/stories/AccordionTutorial.stories.tsx b/src/accordion/stories/AccordionTutorial.stories.tsx deleted file mode 100644 index 3bed700f2..000000000 --- a/src/accordion/stories/AccordionTutorial.stories.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; - -import js from "./templates/AccordionTutorialJsx"; -import ts from "./templates/AccordionTutorialTsx"; -import Accordion from "./AccordionTutorial.component"; - -export default { - component: Accordion, - title: "Accordion/Tutorial", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts }), - }, - argTypes: createControls({ - ignore: [ - "selectedId", - "wrap", - "baseId", - "unstable_virtual", - "rtl", - "orientation", - "currentId", - "shift", - "unstable_includesBaseElement", - "onSelectedIdChange", - "shouldUpdate", - ], - }), -} as Meta; - -export const Default: Story = args => ; diff --git a/src/breadcrumbs/BreadcrumbLink.ts b/src/breadcrumbs/BreadcrumbLink.ts deleted file mode 100644 index 99620a8fd..000000000 --- a/src/breadcrumbs/BreadcrumbLink.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { LinkHTMLProps, LinkOptions, useLink } from "../link"; -import { createComponent, createHook } from "../system"; - -import { BREADCRUMB_LINK_KEYS } from "./__keys"; - -export const useBreadcrumbLink = createHook< - BreadcrumbLinkOptions, - BreadcrumbLinkHTMLProps ->({ - name: "BreadcrumbLink", - compose: useLink, - keys: BREADCRUMB_LINK_KEYS, - - useProps({ isCurrent }, htmlProps) { - return { "aria-current": isCurrent && "page", ...htmlProps }; - }, -}); - -export const BreadcrumbLink = createComponent({ - as: "a", - memo: true, - useHook: useBreadcrumbLink, -}); - -export type BreadcrumbLinkOptions = { - /** - * If true, sets `aria-current: "page"` - */ - isCurrent?: boolean; -} & LinkOptions; - -export type BreadcrumbLinkHTMLProps = LinkHTMLProps; - -export type BreadcrumbLinkProps = BreadcrumbLinkOptions & - BreadcrumbLinkHTMLProps; diff --git a/src/breadcrumbs/Breadcrumbs.ts b/src/breadcrumbs/Breadcrumbs.ts deleted file mode 100644 index f533e53d1..000000000 --- a/src/breadcrumbs/Breadcrumbs.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createComponent, createHook, useCreateElement } from "reakit-system"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { useWarning } from "reakit-warning"; - -export const useBreadcrumbs = createHook< - BreadcrumbsOptions, - BreadcrumbsHTMLProps ->({ - name: "Breadcrumb", - compose: useRole, -}); - -export const Breadcrumbs = createComponent({ - as: "nav", - memo: true, - useHook: useBreadcrumbs, - useCreateElement: (type, props, children) => { - useWarning( - !props["aria-label"] && !props["aria-labelledby"], - "You should provide either `aria-label` or `aria-labelledby` props.", - "See https://www.w3.org/TR/wai-aria-practices-1.1/#wai-aria-roles-states-and-properties-2", - ); - return useCreateElement(type, props, children); - }, -}); - -export type BreadcrumbsOptions = RoleOptions; - -export type BreadcrumbsHTMLProps = RoleHTMLProps; - -export type BreadcrumbProps = BreadcrumbsOptions & BreadcrumbsHTMLProps; diff --git a/src/breadcrumbs/__keys.ts b/src/breadcrumbs/__keys.ts deleted file mode 100644 index a554edec2..000000000 --- a/src/breadcrumbs/__keys.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Automatically generated -export const BREADCRUMB_LINK_KEYS = ["isCurrent"] as const; -export const BREADCRUMBS_KEYS = [] as const; diff --git a/src/breadcrumbs/__tests__/Breadcrumbs.test.tsx b/src/breadcrumbs/__tests__/Breadcrumbs.test.tsx deleted file mode 100644 index 619841218..000000000 --- a/src/breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import * as React from "react"; -import { axe, render } from "reakit-test-utils"; - -import { BreadcrumbLink, Breadcrumbs } from "../index"; - -const BreadcrumbComp = () => { - return ( - - - - - WAI-ARIA Authoring Practices 1.1 - - - - - Design Patterns - - - - - Breadcrumb Pattern - - - - - Breadcrumb Example - - - - - ); -}; - -describe("Breadcrumb", () => { - it("should render correctly", () => { - const { asFragment } = render(); - - expect(asFragment()).toMatchSnapshot(); - }); - - test("Breadcrumb renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap b/src/breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap deleted file mode 100644 index 91e68d16a..000000000 --- a/src/breadcrumbs/__tests__/__snapshots__/Breadcrumbs.test.tsx.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Breadcrumb should render correctly 1`] = ` - - - - - - WAI-ARIA Authoring Practices 1.1 - - - - - Design Patterns - - - - - Breadcrumb Pattern - - - - - Breadcrumb Example - - - - - -`; diff --git a/src/breadcrumbs/breadcrumb-link.ts b/src/breadcrumbs/breadcrumb-link.ts new file mode 100644 index 000000000..b0cc4c5c4 --- /dev/null +++ b/src/breadcrumbs/breadcrumb-link.ts @@ -0,0 +1,39 @@ +import { CommandOptions } from "ariakit"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Props } from "ariakit-utils/types"; + +import { useLink } from "../link"; + +export const useBreadcrumbLink = createHook( + ({ isCurrentPage, ...props }) => { + props = { + "aria-current": isCurrentPage && "page", + ...props, + }; + + props = useLink(props); + + return props; + }, +); + +export const BreadcrumbLink = createComponent(props => { + const htmlProps = useBreadcrumbLink(props); + + return createElement("a", htmlProps); +}); + +export type BreadcrumbLinkOptions = CommandOptions & { + /** + * If true, sets `aria-current: "page"` + */ + isCurrentPage?: boolean; +}; + +export type BreadcrumbLinkProps = Props< + BreadcrumbLinkOptions +>; diff --git a/src/breadcrumbs/breadcrumbs-base.ts b/src/breadcrumbs/breadcrumbs-base.ts new file mode 100644 index 000000000..17af2c3a7 --- /dev/null +++ b/src/breadcrumbs/breadcrumbs-base.ts @@ -0,0 +1,27 @@ +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +export const useBreadcrumbs = createHook(({ ...props }) => { + props = { + "aria-label": "breadcrumbs", + ...props, + }; + + return props; +}); + +export const Breadcrumbs = createComponent(props => { + const htmlProps = useBreadcrumbs(props); + + return createElement("nav", htmlProps); +}); + +export type BreadcrumbsOptions = Options & {}; + +export type BreadcrumbsProps = Props< + BreadcrumbsOptions +>; diff --git a/src/breadcrumbs/index.ts b/src/breadcrumbs/index.ts index ce9ce43cc..1797d71ad 100644 --- a/src/breadcrumbs/index.ts +++ b/src/breadcrumbs/index.ts @@ -1,3 +1,2 @@ -export * from "./__keys"; -export * from "./BreadcrumbLink"; -export * from "./Breadcrumbs"; +export * from "./breadcrumb-link"; +export * from "./breadcrumbs-base"; diff --git a/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx b/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx index 54a3984cf..5a88c8647 100644 --- a/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx +++ b/src/breadcrumbs/stories/BreadcrumbsBasic.component.tsx @@ -1,13 +1,12 @@ import * as React from "react"; -import { - BreadcrumbLink, - Breadcrumbs as RenderlesskitBreadcrumbs, -} from "../../index"; +import { BreadcrumbLink, Breadcrumbs, BreadcrumbsProps } from "../../index"; -export const Breadcrumbs = () => { +export type BreadcrumbsBasicProps = BreadcrumbsProps & {}; + +export const BreadcrumbsBasic: React.FC = props => { return ( - + @@ -21,7 +20,7 @@ export const Breadcrumbs = () => { Breadcrumb Pattern @@ -33,8 +32,8 @@ export const Breadcrumbs = () => { - + ); }; -export default Breadcrumbs; +export default BreadcrumbsBasic; diff --git a/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx b/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx index 27a4cfbbf..9fffe76a2 100644 --- a/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx +++ b/src/breadcrumbs/stories/BreadcrumbsBasic.stories.tsx @@ -1,23 +1,23 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import css from "./templates/BreadcrumbsBasicCss"; import js from "./templates/BreadcrumbsBasicJsx"; import ts from "./templates/BreadcrumbsBasicTsx"; -import Breadcrumbs from "./BreadcrumbsBasic.component"; +import { BreadcrumbsBasic } from "./BreadcrumbsBasic.component"; import "./BreadcrumbsBasic.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: Breadcrumbs, title: "Breadcrumbs/Basic", + component: BreadcrumbsBasic, parameters: { layout: "centered", - preview: createPreviewTabs({ js, ts, css }), - options: { showPanel: false }, + preview: createPreviewTabs({ js, ts }), }, } as Meta; -export const Default: Story = args => ; +export const Default: Story = {}; diff --git a/src/calendar/Calendar.ts b/src/calendar/Calendar.ts deleted file mode 100644 index 98ba93a9c..000000000 --- a/src/calendar/Calendar.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendar = createHook({ - name: "Calendar", - compose: useRole, - keys: CALENDAR_KEYS, - - useProps({ calendarId }, htmlProps) { - return { - role: "group", - "aria-labelledby": calendarId, - ...htmlProps, - }; - }, -}); - -export const Calendar = createComponent({ - as: "div", - memo: true, - useHook: useCalendar, -}); - -export type CalendarOptions = RoleOptions & - Pick; - -export type CalendarHTMLProps = RoleHTMLProps; - -export type CalendarProps = CalendarOptions & CalendarHTMLProps; diff --git a/src/calendar/CalendarButton.ts b/src/calendar/CalendarButton.ts deleted file mode 100644 index a74265945..000000000 --- a/src/calendar/CalendarButton.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_BUTTON_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendarButton = createHook< - CalendarButtonOptions, - CalendarButtonHTMLProps ->({ - name: "CalendarButton", - compose: useButton, - keys: CALENDAR_BUTTON_KEYS, - - useProps(options, { onClick: htmlOnClick, ...htmlProps }) { - const { - focusNextMonth, - focusPreviousMonth, - focusPreviousYear, - focusNextYear, - goto, - } = options; - - const HANDLER_TYPES = { - nextMonth: { - handler: focusNextMonth, - ariaLabel: "Next Month", - }, - previousMonth: { - handler: focusPreviousMonth, - ariaLabel: "Previous Month", - }, - nextYear: { - handler: focusNextYear, - ariaLabel: "Next Year", - }, - previousYear: { - handler: focusPreviousYear, - ariaLabel: "Previous Year", - }, - }; - - return { - "aria-label": HANDLER_TYPES[goto]?.ariaLabel, - onClick: callAllHandlers(htmlOnClick, HANDLER_TYPES[goto]?.handler), - ...htmlProps, - }; - }, -}); - -export const CalendarButton = createComponent({ - as: "button", - memo: true, - useHook: useCalendarButton, -}); - -export type CalendarButtonOptions = ButtonOptions & - Pick< - CalendarStateReturn, - | "focusNextMonth" - | "focusPreviousMonth" - | "focusPreviousYear" - | "focusNextYear" - > & { - goto: CalendarGoto; - }; - -export type CalendarButtonHTMLProps = ButtonHTMLProps; - -export type CalendarButtonProps = CalendarButtonOptions & - CalendarButtonHTMLProps; - -export type CalendarGoto = - | "nextMonth" - | "previousMonth" - | "nextYear" - | "previousYear"; diff --git a/src/calendar/CalendarCell.ts b/src/calendar/CalendarCell.ts deleted file mode 100644 index 863ea2bbc..000000000 --- a/src/calendar/CalendarCell.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import { useCallback } from "react"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; -import { - ariaAttr, - dataAttr, - getDaysInMonth, - isSameDay, - isWeekend, -} from "../utils"; - -import { CALENDAR_CELL_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; -import { RangeCalendarStateReturn } from "./RangeCalendarState"; - -export const useCalendarCell = createHook< - CalendarCellOptions, - CalendarCellHTMLProps ->({ - name: "CalendarCell", - compose: useRole, - keys: CALENDAR_CELL_KEYS, - - useProps(options, { onMouseEnter: htmlOnMouseEnter, ...htmlProps }) { - const { isDisabled, highlightDate, date } = options; - const onMouseEnter = useCallback(() => { - if (isDisabled) return; - - highlightDate?.(date); - }, [date, highlightDate, isDisabled]); - - return { - role: "gridcell", - "data-weekend": dataAttr(isWeekend(date)), - onMouseEnter: options.isRangeCalendar - ? callAllHandlers(htmlOnMouseEnter, onMouseEnter) - : htmlOnMouseEnter, - ...getCalendarCellProps(options), - ...htmlProps, - }; - }, -}); - -export const CalendarCell = createComponent({ - as: "div", - memo: true, - useHook: useCalendarCell, -}); - -const getCalendarCellProps = (options: CalendarCellOptions) => { - const { date, dateValue, highlightedRange, currentMonth } = options; - - if (options.isRangeCalendar) { - const isSelected = highlightedRange - ? date >= highlightedRange.start && date <= highlightedRange.end - : false; - - const isRangeStart = isSelected && date.getDate() === 1; - const isRangeEnd = - isSelected && date.getDate() === getDaysInMonth(currentMonth); - const isSelectionStart = highlightedRange - ? isSameDay(date, highlightedRange.start) - : false; - const isSelectionEnd = highlightedRange - ? isSameDay(date, highlightedRange.end) - : false; - - return { - "aria-selected": ariaAttr(isSelected), - "data-is-range-selection": dataAttr(isSelected), - "data-is-range-end": dataAttr(isRangeEnd), - "data-is-range-start": dataAttr(isRangeStart), - "data-is-selection-end": dataAttr(isSelectionEnd), - "data-is-selection-start": dataAttr(isSelectionStart), - }; - } - - const isSelected = dateValue ? isSameDay(date, dateValue) : false; - - return { - "aria-selected": ariaAttr(isSelected), - }; -}; - -export type CalendarCellOptions = RoleOptions & - Pick< - CalendarStateReturn, - "dateValue" | "isDisabled" | "currentMonth" | "isRangeCalendar" - > & - Partial< - Pick - > & { - date: Date; - }; - -export type CalendarCellHTMLProps = RoleHTMLProps; - -export type CalendarCellProps = CalendarCellOptions & CalendarCellHTMLProps; diff --git a/src/calendar/CalendarCellButton.ts b/src/calendar/CalendarCellButton.ts deleted file mode 100644 index 0caa8aa42..000000000 --- a/src/calendar/CalendarCellButton.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Aria [useCalendarBase](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useCalendarBase.ts) - * to work with Reakit System - */ -import * as React from "react"; -import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; -import { ensureFocus, useForkRef } from "reakit-utils"; -import { callAllHandlers } from "@chakra-ui/utils"; -import { useDateFormatter } from "@react-aria/i18n"; - -import { createComponent, createHook } from "../system"; -import { isSameDay } from "../utils"; - -import { CALENDAR_CELL_BUTTON_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; -import { RangeCalendarStateReturn } from "./RangeCalendarState"; - -export const useCalendarCellButton = createHook< - CalendarCellButtonOptions, - CalendarCellButtonHTMLProps ->({ - name: "CalendarCellButton", - compose: useButton, - keys: CALENDAR_CELL_BUTTON_KEYS, - - useOptions(options, { disabled }) { - const { - isDisabled: isDisabledOption, - date, - month, - isInvalidDateRange, - } = options; - const isCurrentMonth = date.getMonth() === month; - const isDisabled = - isDisabledOption || !isCurrentMonth || isInvalidDateRange(date); - const truelyDisabled = disabled || isDisabled; - - return { disabled: truelyDisabled, ...options }; - }, - - useProps( - options, - { onFocus: htmlOnFocus, onClick: htmlOnClick, ref: htmlRef, ...htmlProps }, - ) { - const { - date, - disabled, - dateValue, - selectDate, - anchorDate, - focusedDate, - isDisabled, - setFocusedDate, - isFocused: isFocusedOption, - } = options; - - const ref = React.useRef(null); - const isSelected = dateValue ? isSameDay(date, dateValue) : false; - const isFocused = - isFocusedOption && focusedDate && isSameDay(date, focusedDate); - const isToday = isSameDay(date, new Date()); - - // Focus the button in the DOM when the state updates. - React.useEffect(() => { - if (isFocused && ref.current) { - ensureFocus(ref.current); - } - }, [date, focusedDate, isFocused, ref]); - - const onClick = React.useCallback(() => { - if (disabled) return; - - selectDate(date); - setFocusedDate(date); - }, [date, disabled, selectDate, setFocusedDate]); - - const onFocus = React.useCallback(() => { - if (disabled) return; - - setFocusedDate(date); - }, [date, disabled, setFocusedDate]); - - const dateFormatter = useDateFormatter({ - weekday: "long", - day: "numeric", - month: "long", - year: "numeric", - }); - - // aria-label should be localize Day of week, Month, Day and Year without Time. - function getAriaLabel() { - let ariaLabel = dateFormatter.format(date); - const isTodayLabel = isToday ? "Today, " : ""; - const isSelctedLabel = isSelected ? " selected" : ""; - ariaLabel = `${isTodayLabel}${ariaLabel}${isSelctedLabel}`; - - // When a cell is focused and this is a range calendar, add a prompt to help - // screenreader users know that they are in a range selection mode. - if (options.isRangeCalendar && isFocused && !isDisabled) { - let rangeSelectionPrompt = ""; - - // If selection has started add "click to finish selecting range" - if (anchorDate) { - rangeSelectionPrompt = "click to finish selecting range"; - // Otherwise, add "click to start selecting range" prompt - } else { - rangeSelectionPrompt = "click to start selecting range"; - } - - // Append to aria-label - if (rangeSelectionPrompt) { - ariaLabel = `${ariaLabel} (${rangeSelectionPrompt})`; - } - } - - return ariaLabel; - } - - return { - children: useDateFormatter({ day: "numeric" }).format(date), - "aria-label": getAriaLabel(), - tabIndex: !disabled ? (isSameDay(date, focusedDate) ? 0 : -1) : undefined, - ref: useForkRef(ref, htmlRef), - onClick: callAllHandlers(htmlOnClick, onClick), - onFocus: callAllHandlers(htmlOnFocus, onFocus), - ...htmlProps, - }; - }, -}); - -export const CalendarCellButton = createComponent({ - as: "span", - memo: true, - useHook: useCalendarCellButton, -}); - -export type CalendarCellButtonOptions = ButtonOptions & - Partial> & - Pick< - CalendarStateReturn, - | "focusedDate" - | "selectDate" - | "setFocusedDate" - | "isDisabled" - | "month" - | "dateValue" - | "isFocused" - | "isRangeCalendar" - | "isInvalidDateRange" - > & { - date: Date; - }; - -export type CalendarCellButtonHTMLProps = ButtonHTMLProps; - -export type CalendarCellButtonProps = CalendarCellButtonOptions & - CalendarCellButtonHTMLProps; diff --git a/src/calendar/CalendarGrid.ts b/src/calendar/CalendarGrid.ts deleted file mode 100644 index 7658fc400..000000000 --- a/src/calendar/CalendarGrid.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useWeekStart](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/calendar/src/useWeekStart.ts) - * to work with Reakit System - */ -import { KeyboardEvent, useRef } from "react"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { createOnKeyDown, useForkRef } from "reakit-utils"; -import { callAllHandlers } from "@chakra-ui/utils"; -import { chain } from "@react-aria/utils"; - -import { createComponent, createHook } from "../system"; -import { ariaAttr } from "../utils"; - -import { CALENDAR_GRID_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; -import { RangeCalendarStateReturn } from "./RangeCalendarState"; - -export const useCalendarGrid = createHook< - CalendarGridOptions, - CalendarGridHTMLProps ->({ - name: "CalendarGrid", - compose: useRole, - keys: CALENDAR_GRID_KEYS, - - useProps( - options, - { - ref: htmlRef, - onKeyDown: htmlOnKeyDown, - onBlur: htmlOnFocus, - onBlur: htmlOnBlur, - ...htmlProps - }, - ) { - const { - isReadOnly, - isDisabled, - setFocused, - selectFocusedDate, - focusPreviousYear, - focusPreviousMonth, - focusNextYear, - focusNextMonth, - focusEndOfMonth, - focusStartOfMonth, - focusNextDay, - focusPreviousDay, - focusNextWeek, - focusPreviousWeek, - calendarId, - setAnchorDate, - } = options; - const ref = useRef(null); - - const onKeyDown = createOnKeyDown({ - onKeyDown: htmlOnKeyDown, - preventDefault: true, - keyMap: (event: KeyboardEvent) => { - const shift = event.shiftKey; - - return { - " ": selectFocusedDate, - Enter: selectFocusedDate, - End: focusEndOfMonth, - Home: focusStartOfMonth, - ArrowLeft: focusPreviousDay, - ArrowUp: focusPreviousWeek, - ArrowRight: focusNextDay, - ArrowDown: focusNextWeek, - PageUp: () => { - shift ? focusPreviousYear() : focusPreviousMonth(); - }, - PageDown: () => { - shift ? focusNextYear() : focusNextMonth(); - }, - }; - }, - }); - - let rangeCalendarProps = {}; - - if (options.isRangeCalendar) { - const onRangeKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case "Escape": - // Cancel the selection. - setAnchorDate?.(null); - break; - } - }; - - rangeCalendarProps = { - "aria-multiselectable": true, - onKeyDown: callAllHandlers( - htmlOnKeyDown, - chain(onKeyDown, onRangeKeyDown), - ), - }; - } - - return { - ref: useForkRef(ref, htmlRef), - role: "grid", - "aria-labelledby": calendarId, - "aria-readonly": ariaAttr(isReadOnly), - "aria-disabled": ariaAttr(isDisabled), - onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), - onFocus: callAllHandlers(htmlOnFocus, () => setFocused(true)), - onBlur: callAllHandlers(htmlOnBlur, () => setFocused(false)), - ...rangeCalendarProps, - ...htmlProps, - }; - }, -}); - -export const CalendarGrid = createComponent({ - as: "div", - memo: true, - useHook: useCalendarGrid, -}); - -export type CalendarGridOptions = RoleOptions & - Pick< - CalendarStateReturn, - | "calendarId" - | "isReadOnly" - | "isDisabled" - | "setFocused" - | "selectFocusedDate" - | "focusPreviousYear" - | "focusPreviousMonth" - | "focusNextYear" - | "focusNextMonth" - | "focusEndOfMonth" - | "focusStartOfMonth" - | "focusNextDay" - | "focusPreviousDay" - | "focusNextWeek" - | "focusPreviousWeek" - | "isRangeCalendar" - > & - Partial>; - -export type CalendarGridHTMLProps = RoleHTMLProps; - -export type CalendarGridProps = CalendarGridOptions & CalendarGridHTMLProps; diff --git a/src/calendar/CalendarHeader.ts b/src/calendar/CalendarHeader.ts deleted file mode 100644 index 071f959ee..000000000 --- a/src/calendar/CalendarHeader.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { useDateFormatter } from "@react-aria/i18n"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_HEADER_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendarHeader = createHook< - CalendarHeaderOptions, - CalendarHeaderHTMLProps ->({ - name: "CalendarHeader", - compose: useRole, - keys: CALENDAR_HEADER_KEYS, - - useProps( - { format = { month: "long", year: "numeric" }, currentMonth, calendarId }, - htmlProps, - ) { - return { - id: calendarId, - children: useDateFormatter(format).format(currentMonth), - "aria-live": "polite", - ...htmlProps, - }; - }, -}); - -export const CalendarHeader = createComponent({ - as: "h2", - memo: true, - useHook: useCalendarHeader, -}); - -export type CalendarHeaderOptions = RoleOptions & - Pick & { - format?: Intl.DateTimeFormatOptions; - }; - -export type CalendarHeaderHTMLProps = RoleHTMLProps; - -export type CalendarHeaderProps = CalendarHeaderOptions & - CalendarHeaderHTMLProps; diff --git a/src/calendar/CalendarState.ts b/src/calendar/CalendarState.ts deleted file mode 100644 index 22e2a14b2..000000000 --- a/src/calendar/CalendarState.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useCalendarState](https://github.com/adobe/react-spectrum/tree/main/packages/%40react-stately/calendar) - * to work with Reakit System - */ - -import * as React from "react"; -import { unstable_useId as useId } from "reakit"; -import { useUpdateEffect } from "@chakra-ui/hooks"; -import { useDateFormatter } from "@react-aria/i18n"; -import { InputBase } from "@react-types/shared"; - -import { - addDays, - addMonths, - addWeeks, - addYears, - endOfMonth, - getDaysInMonth, - isSameMonth, - startOfDay, - startOfMonth, - subDays, - subMonths, - subWeeks, - subYears, - toUTCString, - useControllableState, -} from "../utils"; -import { announce } from "../utils/LiveAnnouncer"; - -import { generateDaysInMonthArray, useWeekDays, useWeekStart } from "./helpers"; - -export function useCalendarState( - props: CalendarInitialState = {}, -): CalendarStateReturn { - const { - value: initialValue, - defaultValue = toUTCString(new Date()), - onChange, - minValue, - maxValue, - isDisabled = false, - isReadOnly = false, - autoFocus = false, - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const date = React.useMemo(() => new Date(value), [value]); - const minDateValue = React.useMemo( - () => (minValue ? new Date(minValue) : new Date(-864e13)), - [minValue], - ); - const maxDateValue = React.useMemo( - () => (maxValue ? new Date(maxValue) : new Date(864e13)), - [maxValue], - ); - const [currentMonth, setCurrentMonth] = React.useState(date); - const [focusedDate, setFocusedDate] = React.useState(date); - const [isFocused, setFocused] = React.useState(autoFocus); - const month = currentMonth.getMonth(); - const year = currentMonth.getFullYear(); - const weekStart = useWeekStart(); - const weekDays = useWeekDays(weekStart); - - let monthStartsAt = (startOfMonth(currentMonth).getDay() - weekStart) % 7; - if (monthStartsAt < 0) { - monthStartsAt += 7; - } - - const days = getDaysInMonth(currentMonth); - const weeksInMonth = Math.ceil((monthStartsAt + days) / 7); - - // Get 2D Date arrays in 7 days a week format - const daysInMonth = React.useMemo( - () => generateDaysInMonthArray(month, monthStartsAt, weeksInMonth, year), - [month, monthStartsAt, weeksInMonth, year], - ); - - function isInvalidDateRange(value: Date) { - const min = new Date(minDateValue); - const max = new Date(maxDateValue); - - return value < min || value > max; - } - - // Sets focus to a specific cell date - function focusCell(date: Date) { - if (isInvalidDateRange(date)) return; - - if (!isSameMonth(date, currentMonth)) { - setCurrentMonth(startOfMonth(date)); - } - - setFocusedDate(date); - } - - const dateFormatter = useDateFormatter({ dateStyle: "full" }); - - const announceSelectedDate = React.useCallback( - (value: Date) => { - if (!value) return; - - announce(`Selected Date: ${dateFormatter.format(value)}`); - }, - [dateFormatter], - ); - - const setDate = React.useCallback( - (value: Date) => { - if (!isDisabled && !isReadOnly) { - setValue(toUTCString(value)); - announceSelectedDate(value); - } - }, - [announceSelectedDate, isDisabled, isReadOnly, setValue], - ); - - // TODO - // This runs only once when the component is mounted - // Controlled state doesn't change the claender position - // React.useEffect(() => { - // const clampedDate = clamp(date, { - // start: minDateValue, - // end: maxDateValue, - // }); - // setDate(clampedDate); - // setCurrentMonth(clampedDate); - // setFocusedDate(clampedDate); - // }, [date, maxDateValue, minDateValue, setDate]); - - const monthFormatter = useDateFormatter({ month: "long", year: "numeric" }); - - // Announce when the current month changes - useUpdateEffect(() => { - // announce the new month with a change from the Previous or Next button - if (!isFocused) { - announce(monthFormatter.format(currentMonth)); - } - // handle an update to the current month from the Previous or Next button - // rather than move focus, we announce the new month value - }, [currentMonth]); - - const { id: calendarId } = useId({ id: props.id, baseId: "calendar" }); - - return { - dateValue: date, - setDateValue: setDate, - calendarId, - month, - year, - weekStart, - weekDays, - daysInMonth, - isDisabled, - isFocused, - isReadOnly, - setFocused, - currentMonth, - setCurrentMonth, - focusedDate, - focusCell, - setFocusedDate, - focusNextDay() { - focusCell(addDays(focusedDate, 1)); - }, - focusPreviousDay() { - focusCell(subDays(focusedDate, 1)); - }, - focusNextWeek() { - focusCell(addWeeks(focusedDate, 1)); - }, - focusPreviousWeek() { - focusCell(subWeeks(focusedDate, 1)); - }, - focusNextMonth() { - focusCell(addMonths(focusedDate, 1)); - }, - focusPreviousMonth() { - focusCell(subMonths(focusedDate, 1)); - }, - focusStartOfMonth() { - focusCell(startOfMonth(focusedDate)); - }, - focusEndOfMonth() { - focusCell(endOfMonth(startOfDay(focusedDate))); - }, - focusNextYear() { - focusCell(addYears(focusedDate, 1)); - }, - focusPreviousYear() { - focusCell(subYears(focusedDate, 1)); - }, - selectFocusedDate() { - setDate(focusedDate); - }, - selectDate(date: Date) { - setDate(date); - }, - isInvalidDateRange, - isRangeCalendar: false, - }; -} - -export type CalendarState = { - /** - * Id for the Calendar Header - */ - calendarId: string | undefined; - /** - * Selected Date value - */ - dateValue: Date; - /** - * Month of the current date value - */ - month: number; - /** - * Year of the current date value - */ - year: number; - /** - * Start of the week for the current date value - */ - weekStart: number; - /** - * Generated week days for CalendarWeekTitle based on weekStart - */ - weekDays: { - title: string; - abbr: string; - }[]; - /** - * Generated days in the current month - */ - daysInMonth: Date[][]; - /** - * `true` if the calendar is disabled - */ - isDisabled: boolean; - /** - * `true` if the calendar is focused - */ - isFocused: boolean; - /** - * `true` if the calendar is only readonly - */ - isReadOnly: boolean; - /** - * Month of the current Date - */ - currentMonth: Date; - /** - * Date value that is currently focused - */ - focusedDate: Date; - /** - * Informs if the given date is within the min & max date. - */ - isInvalidDateRange: (value: Date) => boolean; - /** - * `true` if the calendar is used as RangeCalendar - */ - isRangeCalendar: boolean; -}; - -export type CalendarActions = { - /** - * Sets `isFocused` - */ - setFocused: React.Dispatch>; - /** - * Sets `currentMonth` - */ - setCurrentMonth: React.Dispatch>; - /** - * Sets `focusedDate` - */ - setFocusedDate: React.Dispatch>; - /** - * Sets `dateValue` - */ - setDateValue: (value: Date) => void; - /** - * Focus the cell of the specified date - */ - focusCell: (value: Date) => void; - /** - * Focus the cell next to the current date - */ - focusNextDay: () => void; - /** - * Focus the cell prev to the current date - */ - focusPreviousDay: () => void; - /** - * Focus the cell one week next to the current date - */ - focusNextWeek: () => void; - /** - * Focus the cell one week prev to the current date - */ - focusPreviousWeek: () => void; - /** - * Focus the cell one month next to the current date - */ - focusNextMonth: () => void; - /** - * Focus the cell one month prev to the current date - */ - focusPreviousMonth: () => void; - /** - * Focus the cell of the first day of the month - */ - focusStartOfMonth: () => void; - /** - * Focus the cell of the last day of the month - */ - focusEndOfMonth: () => void; - /** - * Focus the cell of the date one year from the current date - */ - focusNextYear: () => void; - /** - * Focus the cell of the date one year before the current date - */ - focusPreviousYear: () => void; - /** - * Selects the `focusedDate` - */ - selectFocusedDate: () => void; - /** - * sets `dateValue` - */ - selectDate: (value: Date) => void; -}; - -type ValueBase = { - /** The current date (controlled). */ - value?: string; - /** The default date (uncontrolled). */ - defaultValue?: string; - /** Handler that is called when the date changes. */ - onChange?: (value: string) => void; -}; - -type RangeValueMinMax = { - /** The lowest date allowed. */ - minValue?: string; - /** The highest date allowed. */ - maxValue?: string; -}; - -export type CalendarInitialState = ValueBase & - RangeValueMinMax & - InputBase & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - /** - * Id for the calendar grid - */ - id?: string; - }; - -export type CalendarStateReturn = CalendarState & CalendarActions; diff --git a/src/calendar/CalendarWeekTitle.ts b/src/calendar/CalendarWeekTitle.ts deleted file mode 100644 index 8276c7750..000000000 --- a/src/calendar/CalendarWeekTitle.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; - -import { createComponent, createHook } from "../system"; - -import { CALENDAR_WEEK_TITLE_KEYS } from "./__keys"; -import { CalendarStateReturn } from "./CalendarState"; - -export const useCalendarWeekTitle = createHook< - CalendarWeekTitleOptions, - CalendarWeekTitleHTMLProps ->({ - name: "CalendarWeekTitle", - compose: useRole, - keys: CALENDAR_WEEK_TITLE_KEYS, - - useProps({ dayIndex, weekDays }, htmlProps) { - return { - "aria-label": weekDays[dayIndex]?.title, - ...htmlProps, - }; - }, -}); - -export const CalendarWeekTitle = createComponent({ - as: "div", - memo: true, - useHook: useCalendarWeekTitle, -}); - -export type CalendarWeekTitleOptions = RoleOptions & - Pick & { - dayIndex: number; - }; - -export type CalendarWeekTitleHTMLProps = RoleHTMLProps; - -export type CalendarWeekTitleProps = CalendarWeekTitleOptions & - CalendarWeekTitleHTMLProps; diff --git a/src/calendar/RangeCalendarState.ts b/src/calendar/RangeCalendarState.ts deleted file mode 100644 index 60bce086c..000000000 --- a/src/calendar/RangeCalendarState.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useRangeCalendar](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/calendar/src/useRangeCalendar.ts) - * to work with Reakit System - */ -import * as React from "react"; -import { useDateFormatter } from "@react-aria/i18n"; -import { InputBase, RangeValue } from "@react-types/shared"; - -import { - addDays, - isSameDay, - toUTCRangeString, - toUTCString, - useControllableState, -} from "../utils"; -import { announce } from "../utils/LiveAnnouncer"; - -import { - CalendarActions, - CalendarState, - useCalendarState, -} from "./CalendarState"; -import { makeRange } from "./helpers"; - -export function useRangeCalendarState( - props: RangeCalendarInitialState = {}, -): RangeCalendarStateReturn { - const { - value: initialValue, - defaultValue = { - start: toUTCString(new Date()), - end: toUTCString(addDays(new Date(), 1)), - }, - onChange, - ...calendarProps - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const dateRange: RangeValue = React.useMemo( - () => ({ - start: new Date(value.start), - end: new Date(value.end), - }), - [value.end, value.start], - ); - const [anchorDate, setAnchorDate] = React.useState(null); - const [lastSelectedDate, setLastSelectedDate] = React.useState( - dateRange.end, - ); - const calendar = useCalendarState({ - ...calendarProps, - value: toUTCString(lastSelectedDate), - }); - - const highlightedRange = anchorDate - ? makeRange(anchorDate, calendar.focusedDate) - : value && dateRange && makeRange(dateRange.start, dateRange.end); - - const dateFormatter = useDateFormatter({ dateStyle: "full" }); - - const announceRange = React.useCallback(() => { - if (!highlightedRange) return; - - if (isSameDay(highlightedRange.start, highlightedRange.end)) { - announce( - `Selected range, from ${dateFormatter.format( - highlightedRange.start, - )} to ${dateFormatter.format(highlightedRange.start)}`, - ); - } else { - announce( - `Selected range from ${dateFormatter.format( - highlightedRange.start, - )} to ${dateFormatter.format(highlightedRange.start)}`, - ); - } - }, [dateFormatter, highlightedRange]); - - const selectDate = React.useCallback( - (date: Date) => { - if (props.isReadOnly) return; - - setLastSelectedDate(date); - if (!anchorDate) { - setAnchorDate(date); - announce(`Starting range from ${dateFormatter.format(date)}`); - } else { - setValue(toUTCRangeString(makeRange(anchorDate, date))); - announceRange(); - setAnchorDate(null); - } - }, - [anchorDate, announceRange, dateFormatter, props.isReadOnly, setValue], - ); - - const setDateValue = React.useCallback( - (value: RangeValue) => { - setValue(toUTCRangeString(value)); - }, - [setValue], - ); - - return { - ...calendar, - dateRangeValue: dateRange, - setDateRangeValue: setDateValue, - anchorDate, - setAnchorDate, - highlightedRange, - selectDate, - selectFocusedDate() { - selectDate(calendar.focusedDate); - }, - highlightDate(date: Date) { - if (!anchorDate) return; - calendar.setFocusedDate(date); - }, - isRangeCalendar: true, - }; -} - -export type RangeCalendarState = CalendarState & { - dateRangeValue: RangeValue | null; - anchorDate: Date | null; - highlightedRange: RangeValue | null; - isRangeCalendar: boolean; -}; - -export type RangeCalendarActions = CalendarActions & { - setDateRangeValue: (value: RangeValue) => void; - setAnchorDate: React.Dispatch>; - selectDate: (date: Date) => void; - selectFocusedDate: () => void; - highlightDate: (date: Date) => void; -}; - -type Range = { - /** The start value of the range. */ - start: string; - /** The end value of the range. */ - end: string; -}; - -type RangeValueBase = { - /** The current value (controlled). */ - value?: Range; - /** The default value (uncontrolled). */ - defaultValue?: Range; - /** Handler that is called when the value changes. */ - onChange?: (value: Range) => void; -}; - -type RangeValueMinMax = { - /** The smallest value allowed. */ - minValue?: string; - /** The largest value allowed. */ - maxValue?: string; -}; - -export type RangeCalendarInitialState = RangeValueBase & - RangeValueMinMax & - InputBase & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - /** - * Id for the calendar grid - */ - id?: string; - }; - -export type RangeCalendarStateReturn = RangeCalendarState & - RangeCalendarActions; diff --git a/src/calendar/__keys.ts b/src/calendar/__keys.ts deleted file mode 100644 index 0666156d3..000000000 --- a/src/calendar/__keys.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Automatically generated -export const USE_CALENDAR_STATE_KEYS = [ - "value", - "defaultValue", - "onChange", - "minValue", - "maxValue", - "isDisabled", - "isReadOnly", - "autoFocus", - "id", -] as const; -export const CALENDAR_STATE_KEYS = [ - "calendarId", - "dateValue", - "month", - "year", - "weekStart", - "weekDays", - "daysInMonth", - "isDisabled", - "isFocused", - "isReadOnly", - "currentMonth", - "focusedDate", - "isInvalidDateRange", - "isRangeCalendar", - "setFocused", - "setCurrentMonth", - "setFocusedDate", - "setDateValue", - "focusCell", - "focusNextDay", - "focusPreviousDay", - "focusNextWeek", - "focusPreviousWeek", - "focusNextMonth", - "focusPreviousMonth", - "focusStartOfMonth", - "focusEndOfMonth", - "focusNextYear", - "focusPreviousYear", - "selectFocusedDate", - "selectDate", -] as const; -export const USE_RANGE_CALENDAR_STATE_KEYS = USE_CALENDAR_STATE_KEYS; -export const RANGE_CALENDAR_STATE_KEYS = [ - ...CALENDAR_STATE_KEYS, - "dateRangeValue", - "anchorDate", - "highlightedRange", - "setDateRangeValue", - "setAnchorDate", - "highlightDate", -] as const; -export const CALENDAR_KEYS = RANGE_CALENDAR_STATE_KEYS; -export const CALENDAR_BUTTON_KEYS = [...CALENDAR_KEYS, "goto"] as const; -export const CALENDAR_CELL_KEYS = [...CALENDAR_KEYS, "date"] as const; -export const CALENDAR_CELL_BUTTON_KEYS = CALENDAR_CELL_KEYS; -export const CALENDAR_GRID_KEYS = CALENDAR_KEYS; -export const CALENDAR_HEADER_KEYS = [...CALENDAR_GRID_KEYS, "format"] as const; -export const CALENDAR_WEEK_TITLE_KEYS = [ - ...CALENDAR_GRID_KEYS, - "dayIndex", -] as const; diff --git a/src/calendar/__tests__/Calendar.test.tsx b/src/calendar/__tests__/Calendar.test.tsx deleted file mode 100644 index 84d2c6e8f..000000000 --- a/src/calendar/__tests__/Calendar.test.tsx +++ /dev/null @@ -1,268 +0,0 @@ -/* eslint-disable testing-library/prefer-explicit-assert */ -import * as React from "react"; -import { axe, press, render, screen } from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; -import MockDate from "mockdate"; - -import { repeat } from "../../utils/test-utils"; -import { - Calendar as CalendarWrapper, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarInitialState, - CalendarWeekTitle, - useCalendarState, -} from "../index"; - -export const CalendarComp: React.FC = props => { - const state = useCalendarState(props); - - return ( - - - - previous year - - - previous month - - - - next month - - - next year - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week: any[], weekIndex: React.Key) => ( - - {week.map((day: Date, dayIndex: React.Key) => ( - - - - ))} - - ))} - - - - ); -}; - -beforeEach(() => { - // You SHALL Freeze 🧙 - MockDate.set(new Date(2020, 9, 29)); -}); - -afterEach(() => { - cleanup(); - MockDate.reset(); -}); - -describe("Calendar", () => { - it("should render correctly", () => { - const { getByTestId: testId } = render(); - - expect(testId("testid-weekDays").children).toHaveLength(7); - expect(testId("testid-current-year")).toHaveTextContent(/^october 2020$/i); - }); - - it("should have proper calendar header keyboard navigation", () => { - render(); - - const currentYear = screen.getByTestId("testid-current-year"); - const { getByText: text } = screen; - - expect(currentYear).toHaveTextContent(/^october 2020$/i); - press.Tab(); - press.Enter(); - expect(text(/previous year/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^october 2019$/i); - - press.Tab(); - press.Enter(); - expect(text(/previous month/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^september 2019$/i); - - press.Tab(); - press.Enter(); - expect(text(/next month/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^october 2019$/i); - - press.Tab(); - press.Enter(); - expect(text(/next year/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^october 2020$/i); - }); - - it("should proper grid navigation", () => { - render(); - const currentYear = screen.getByTestId("testid-current-year"); - - const { getByLabelText: label } = screen; - - expect(currentYear).toHaveTextContent(/^october 2020$/i); - - // Tab to go inside the calendar dates - repeat(press.Tab, 5); - expect(label(/wednesday, october 7, 2020 selected/i)).toHaveFocus(); - - // Let's navigate to 30 - repeat(press.ArrowDown, 2); - repeat(press.ArrowRight, 2); - press.ArrowDown(); - - expect(label(/^friday, october 30, 2020$/i)).toHaveFocus(); - - // Let's go to next month - press.ArrowDown(); - expect(label(/^friday, november 6, 2020$/i)).toHaveFocus(); - expect(currentYear).toHaveTextContent(/^november 2020$/i); - - // Grid navigation pageup/down - press.PageUp(); - expect(currentYear).toHaveTextContent(/^october 2020$/i); - - press.PageUp(null, { shiftKey: true }); - expect(currentYear).toHaveTextContent(/^october 2019$/i); - }); - - test("should have min/max values", () => { - render( - , - ); - const { getByLabelText: label } = screen; - - // Tab to go inside the calendar dates - repeat(press.Tab, 5); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - // try to go outside the min max value - repeat(press.ArrowUp, 4); - expect(label(/^saturday, october 31, 2020$/i)).toHaveFocus(); - - repeat(press.ArrowDown, 3); - expect(label(/^saturday, november 14, 2020$/i)).toHaveFocus(); - }); - - test("should be able to go to prev/next month when min/max values are set", () => { - render( - , - ); - const { getByLabelText: label } = screen; - - // Tab to go inside the calendar dates - repeat(press.Tab, 5); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - // Should not be able to go to next/prev months - press.PageUp(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageDown(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - // Should not be able to go to next/prev years - press.PageDown(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - }); - - test("should not be able to go to prev/next year when min/max values are set", () => { - render( - , - ); - - const { getByLabelText: label } = screen; - - repeat(press.Tab, 5); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageDown(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageDown(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - - press.PageUp(null, { shiftKey: true }); - expect(label(/^saturday, november 7, 2020 selected$/i)).toHaveFocus(); - }); - - it("should have proper aria-label for calendar cell button", () => { - MockDate.set("2020-11-07"); - render(); - - screen.getByRole("button", { - name: /^today, saturday, november 7, 2020 selected$/i, - }); - - repeat(press.Tab, 5); - press.ArrowRight(); - press.Enter(); - screen.getByRole("button", { - name: /sunday, november 8, 2020 selected/i, - }); - - repeat(press.ArrowLeft, 2); - press.Enter(); - screen.getByRole("button", { - name: /friday, november 6, 2020 selected/i, - }); - - MockDate.reset(); - }); - - test("Calendar renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/calendar/__tests__/RangeCalendar.test.tsx b/src/calendar/__tests__/RangeCalendar.test.tsx deleted file mode 100644 index a703d0b16..000000000 --- a/src/calendar/__tests__/RangeCalendar.test.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import * as React from "react"; -import { axe, press, render } from "reakit-test-utils"; -import { cleanup, screen } from "@testing-library/react"; -import MockDate from "mockdate"; - -import { announce, destroyAnnouncer } from "../../utils/LiveAnnouncer"; -import { - isEndSelection, - isStartSelection, - repeat, -} from "../../utils/test-utils"; -import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - RangeCalendarInitialState, - useRangeCalendarState, -} from "../index"; - -jest.mock("../../utils/LiveAnnouncer"); - -afterEach(cleanup); - -beforeEach(() => { - destroyAnnouncer(); -}); - -const RangeCalendarComp: React.FC = props => { - const state = useRangeCalendarState(props); - - return ( - - - - previous year - - - previous month - - - - next month - - - next year - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - - ); -}; - -describe("RangeCalendar", () => { - it("should render correctly", () => { - const { getByTestId: testId } = render( - , - ); - - expect(testId("testid-week-days").children).toHaveLength(7); - expect(testId("testid-current-year")).toHaveTextContent("October 2020"); - }); - - it("should have proper initial start and end ranges", () => { - const { baseElement } = render( - , - ); - - const start = baseElement.querySelector("[data-is-selection-start]"); - // If anyone is reading this code from future - // Note that this will fail again on 15th october 2050. - const anyMiddleDate = screen.getByLabelText(/Saturday, October 15, 2050/); - const end = baseElement.querySelector("[data-is-selection-end]"); - - expect(start).toHaveTextContent("7"); - expect(anyMiddleDate.parentElement).toHaveAttribute( - "data-is-range-selection", - ); - expect(end).toHaveTextContent("30"); - }); - - it("should announce selected range after finishing selection", () => { - const { getByLabelText: label } = render( - , - ); - - repeat(press.Tab, 5); - expect(label(/Wednesday, October 30, 2019 selected/)).toHaveFocus(); - - press.ArrowUp(); - press.ArrowRight(); - press.Enter(label(/Thursday, October 24, 2019/)); - - expect(announce).toHaveBeenLastCalledWith( - "Starting range from Thursday, October 24, 2019", - ); - - press.ArrowRight(); - press.Enter(label(/Friday, October 25, 2019/)); - - expect(announce).toHaveBeenLastCalledWith( - "Selected range from Thursday, October 24, 2019 to Thursday, October 24, 2019", - ); - expect(announce).toHaveBeenCalledTimes(2); - }); - - it("should be able to select ranges with keyboard navigation", () => { - MockDate.set("2020-10-07"); - const { baseElement } = render( - , - ); - - expect(screen.getByTestId("testid-current-year")).toHaveTextContent( - /October 2020/i, - ); - - repeat(press.Tab, 5); - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 selected \(click to start selecting range\)$/, - ), - ).toHaveFocus(); - - press.ArrowUp(); // go to down just for some variety - press.Enter(); // start the selection, currently the start and end should be the same date - expect( - baseElement.querySelector("[data-is-selection-start]"), - ).toHaveTextContent("23"); - press.ArrowDown(); - expect( - baseElement.querySelector("[data-is-selection-end]"), - ).toHaveTextContent("30"); - - // finish the selection - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 \(click to finish selecting range\)$/, - ), - ).toHaveFocus(); - - // check if the selection is actually finished or not - press.Enter(); - const selectedDate = screen.getByLabelText( - /^Friday, October 30, 2020 selected \(click to start selecting range\)$/, - ); - expect(selectedDate).toHaveFocus(); - expect(selectedDate?.parentElement).toHaveAttribute( - "data-is-range-selection", - ); - - press.ArrowRight(); - const nextDate = screen.getByLabelText( - /^Saturday, October 31, 2020 \(click to start selecting range\)$/, - ); - expect(nextDate).toHaveFocus(); - expect(nextDate?.parentElement).not.toHaveAttribute( - "data-is-range-selection", - ); - - // Verify selection ranges - const end = baseElement.querySelector("[data-is-selection-end]"); - expect(end).toHaveTextContent("30"); - - const start = baseElement.querySelector("[data-is-selection-start]"); - expect(start).toHaveTextContent("23"); - }); - - it("should be able to cancel selection", () => { - render( - , - ); - - expect(screen.getByTestId("testid-current-year")).toHaveTextContent( - /October 2020/i, - ); - - repeat(press.Tab, 5); - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 selected \(click to start selecting range\)$/, - ), - ).toHaveFocus(); - - press.ArrowUp(); - press.Enter(); // start the selection - - // Now we choose the end date, let's choose 30 - press.ArrowDown(); - expect( - screen.getByLabelText( - /^Friday, October 30, 2020 \(click to finish selecting range\)$/i, - ), - ).toHaveFocus(); - - press.Escape(); - isStartSelection(screen.getByLabelText(/Wednesday, October 7, 2020/)); - isEndSelection(screen.getByLabelText(/Friday, October 30, 2020/)); - }); - - test("RangeCalendar renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/calendar/__tests__/__snapshots__/utils.test.tsx.snap b/src/calendar/__tests__/__snapshots__/utils.test.tsx.snap deleted file mode 100644 index b503da53a..000000000 --- a/src/calendar/__tests__/__snapshots__/utils.test.tsx.snap +++ /dev/null @@ -1,135 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Calendar Utils generateDaysInMonthArray 1`] = ` -Array [ - Array [ - 2020-02-01T00:00:00.000Z, - 2020-02-02T00:00:00.000Z, - 2020-02-03T00:00:00.000Z, - 2020-02-04T00:00:00.000Z, - 2020-02-05T00:00:00.000Z, - 2020-02-06T00:00:00.000Z, - 2020-02-07T00:00:00.000Z, - ], - Array [ - 2020-02-08T00:00:00.000Z, - 2020-02-09T00:00:00.000Z, - 2020-02-10T00:00:00.000Z, - 2020-02-11T00:00:00.000Z, - 2020-02-12T00:00:00.000Z, - 2020-02-13T00:00:00.000Z, - 2020-02-14T00:00:00.000Z, - ], - Array [ - 2020-02-15T00:00:00.000Z, - 2020-02-16T00:00:00.000Z, - 2020-02-17T00:00:00.000Z, - 2020-02-18T00:00:00.000Z, - 2020-02-19T00:00:00.000Z, - 2020-02-20T00:00:00.000Z, - 2020-02-21T00:00:00.000Z, - ], - Array [ - 2020-02-22T00:00:00.000Z, - 2020-02-23T00:00:00.000Z, - 2020-02-24T00:00:00.000Z, - 2020-02-25T00:00:00.000Z, - 2020-02-26T00:00:00.000Z, - 2020-02-27T00:00:00.000Z, - 2020-02-28T00:00:00.000Z, - ], - Array [ - 2020-02-29T00:00:00.000Z, - 2020-03-01T00:00:00.000Z, - 2020-03-02T00:00:00.000Z, - 2020-03-03T00:00:00.000Z, - 2020-03-04T00:00:00.000Z, - 2020-03-05T00:00:00.000Z, - 2020-03-06T00:00:00.000Z, - ], - Array [ - 2020-03-07T00:00:00.000Z, - 2020-03-08T00:00:00.000Z, - 2020-03-09T00:00:00.000Z, - 2020-03-10T00:00:00.000Z, - 2020-03-11T00:00:00.000Z, - 2020-03-12T00:00:00.000Z, - 2020-03-13T00:00:00.000Z, - ], - Array [ - 2020-03-14T00:00:00.000Z, - 2020-03-15T00:00:00.000Z, - 2020-03-16T00:00:00.000Z, - 2020-03-17T00:00:00.000Z, - 2020-03-18T00:00:00.000Z, - 2020-03-19T00:00:00.000Z, - 2020-03-20T00:00:00.000Z, - ], -] -`; - -exports[`Calendar Utils useWeekDays 1`] = ` -Array [ - Object { - "abbr": "Sun", - "title": "Sunday", - }, - Object { - "abbr": "Mon", - "title": "Monday", - }, - Object { - "abbr": "Tue", - "title": "Tuesday", - }, - Object { - "abbr": "Wed", - "title": "Wednesday", - }, - Object { - "abbr": "Thu", - "title": "Thursday", - }, - Object { - "abbr": "Fri", - "title": "Friday", - }, - Object { - "abbr": "Sat", - "title": "Saturday", - }, -] -`; - -exports[`Calendar Utils useWeekDays 2`] = ` -Array [ - Object { - "abbr": "Tue", - "title": "Tuesday", - }, - Object { - "abbr": "Wed", - "title": "Wednesday", - }, - Object { - "abbr": "Thu", - "title": "Thursday", - }, - Object { - "abbr": "Fri", - "title": "Friday", - }, - Object { - "abbr": "Sat", - "title": "Saturday", - }, - Object { - "abbr": "Sun", - "title": "Sunday", - }, - Object { - "abbr": "Mon", - "title": "Monday", - }, -] -`; diff --git a/src/calendar/__tests__/utils.test.tsx b/src/calendar/__tests__/utils.test.tsx deleted file mode 100644 index 963a84f5a..000000000 --- a/src/calendar/__tests__/utils.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; -import MockDate from "mockdate"; - -import { generateDaysInMonthArray, makeRange, useWeekDays } from "../helpers"; - -describe("Calendar Utils", () => { - test("makeRange", () => { - const range = makeRange( - new Date(1999, 4, 4, 0, 0), - new Date(2020, 4, 4, 0, 0), - ); - expect(range.start).toMatchInlineSnapshot(`1999-05-03T18:30:00.000Z`); - expect(range.end).toMatchInlineSnapshot(`2020-05-03T18:30:00.000Z`); - }); - - test("useWeekDays", () => { - // MIND THE BLOCK SCOPE! - { - const { - result: { current }, - } = renderHook(() => useWeekDays(0)); - - expect(current).toMatchSnapshot(); - } - { - const { - result: { current }, - } = renderHook(() => useWeekDays(2)); - - expect(current).toMatchSnapshot(); - } - }); - - test("generateDaysInMonthArray", () => { - MockDate.set(new Date("2020-02-01T11:30:00.000Z")); - const days = generateDaysInMonthArray(1, 0, 7, 2020); - - expect(days).toMatchSnapshot(); - - MockDate.reset(); - }); -}); diff --git a/src/calendar/calendar-base-state.ts b/src/calendar/calendar-base-state.ts new file mode 100644 index 000000000..e2fe6a081 --- /dev/null +++ b/src/calendar/calendar-base-state.ts @@ -0,0 +1,32 @@ +import { Calendar, DateDuration } from "@internationalized/date"; +import { CalendarState, useCalendarState } from "@react-stately/calendar"; +import { CalendarProps, DateValue } from "@react-types/calendar"; + +export function useCalendarBaseState( + props: CalendarBaseStateProps, +): CalendarBaseState { + const state = useCalendarState(props); + + return state; +} + +export type CalendarBaseState = CalendarState; + +export type CalendarBaseStateProps = CalendarProps & { + /** The locale to display and edit the value according to. */ + locale: string; + /** + * A function that creates a [Calendar](../internationalized/date/Calendar.html) + * object for a given calendar identifier. Such a function may be imported from the + * `@internationalized/date` package, or manually implemented to include support for + * only certain calendars. + */ + createCalendar: (name: string) => Calendar; + /** + * The amount of days that will be displayed at once. This affects how pagination works. + * @default {months: 1} + */ + visibleDuration?: DateDuration; + /** Determines how to align the initial selection relative to the visible date range. */ + selectionAlignment?: "start" | "center" | "end"; +}; diff --git a/src/calendar/calendar-base.ts b/src/calendar/calendar-base.ts new file mode 100644 index 000000000..68d27b985 --- /dev/null +++ b/src/calendar/calendar-base.ts @@ -0,0 +1,32 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; + +export const useCalendar = createHook( + ({ state, ...props }) => { + props = mergeProps(state.calendarProps, props); + + return props; + }, +); + +export const Calendar = createComponent(props => { + const htmlProps = useCalendar(props); + + return createElement("div", htmlProps); +}); + +export type CalendarOptions = Options & { + /** + * Object returned by the `useCalendarState` hook. + */ + state: CalendarState; +}; + +export type CalendarProps = Props>; diff --git a/src/calendar/calendar-cell-button.ts b/src/calendar/calendar-cell-button.ts new file mode 100644 index 000000000..a7357e269 --- /dev/null +++ b/src/calendar/calendar-cell-button.ts @@ -0,0 +1,38 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarCellState } from "./calendar-cell-state"; + +export const useCalendarCellButton = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.buttonProps, props); + + return props; + }, +); + +export const CalendarCellButton = createComponent( + props => { + const htmlProps = useCalendarCellButton(props); + + return createElement("span", htmlProps); + }, +); + +export type CalendarCellButtonOptions = Options & { + /** + * Object returned by the `useCalendarCellButtonState` hook. + */ + state: CalendarCellState; +}; + +export type CalendarCellButtonProps = Props< + CalendarCellButtonOptions +>; diff --git a/src/calendar/calendar-cell-state.ts b/src/calendar/calendar-cell-state.ts new file mode 100644 index 000000000..df48eb531 --- /dev/null +++ b/src/calendar/calendar-cell-state.ts @@ -0,0 +1,75 @@ +import { HTMLAttributes, RefObject, useRef } from "react"; +import { CalendarDate } from "@internationalized/date"; +import { useCalendarCell } from "@react-aria/calendar"; + +import { CalendarBaseState } from "./calendar-base-state"; +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export function useCalendarCellState({ + state, + ...props +}: CalendarCellStateProps): CalendarCellState { + const ref = useRef(null); + const calendarCellProps = useCalendarCell(props, state, ref); + + return { ...calendarCellProps, ref, baseState: state, date: props.date }; +} + +export type CalendarCellState = { + /** Props for the grid cell element (e.g. ``). */ + cellProps: HTMLAttributes; + /** Props for the button element within the cell. */ + buttonProps: HTMLAttributes; + /** Whether the cell is currently being pressed. */ + isPressed: boolean; + /** Whether the cell is selected. */ + isSelected: boolean; + /** Whether the cell is focused. */ + isFocused: boolean; + /** + * Whether the cell is disabled, according to the calendar's `minValue`, `maxValue`, and `isDisabled` props. + * Disabled dates are not focusable, and cannot be selected by the user. They are typically + * displayed with a dimmed appearance. + */ + isDisabled: boolean; + /** + * Whether the cell is unavailable, according to the calendar's `isDateUnavailable` prop. Unavailable dates remain + * focusable, but cannot be selected by the user. They should be displayed with a visual affordance to indicate they + * are unavailable, such as a different color or a strikethrough. + * + * Note that because they are focusable, unavailable dates must meet a 4.5:1 color contrast ratio, + * [as defined by WCAG](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html). + */ + isUnavailable: boolean; + /** + * Whether the cell is outside the visible range of the calendar. + * For example, dates before the first day of a month in the same week. + */ + isOutsideVisibleRange: boolean; + /** The day number formatted according to the current locale. */ + formattedDate: string; + /** + * Reference for the button element within the cell inside the table + */ + ref: RefObject; + /** + * Object returned by the `useSliderState` hook. + */ + baseState: CalendarBaseState | RangeCalendarBaseState; + /** The date that this cell represents. */ + date: CalendarDate; +}; + +export type CalendarCellStateProps = { + /** The date that this cell represents. */ + date: CalendarDate; + /** + * Whether the cell is disabled. By default, this is determined by the + * Calendar's `minValue`, `maxValue`, and `isDisabled` props. + */ + isDisabled?: boolean; + /** + * Object returned by the `useSliderState` hook. + */ + state: CalendarBaseState | RangeCalendarBaseState; +}; diff --git a/src/calendar/calendar-cell.ts b/src/calendar/calendar-cell.ts new file mode 100644 index 000000000..ad1d14683 --- /dev/null +++ b/src/calendar/calendar-cell.ts @@ -0,0 +1,82 @@ +import { ariaAttr } from "@chakra-ui/utils"; +import { getDayOfWeek, isSameDay } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarCellState } from "./calendar-cell-state"; +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export const useCalendarCell = createHook( + ({ state, ...props }) => { + const { baseState } = state; + + const isLastSelectedBeforeDisabled = + !state.isDisabled && + baseState.isCellUnavailable(state.date.add({ days: 1 })); + const isFirstSelectedAfterDisabled = + !state.isDisabled && + baseState.isCellUnavailable(state.date.subtract({ days: 1 })); + let highlightedRange = + "highlightedRange" in baseState && + (baseState as RangeCalendarBaseState).highlightedRange; + let isSelectionStart = + state.isSelected && + highlightedRange && + isSameDay(state.date, highlightedRange.start); + let isSelectionEnd = + state.isSelected && + highlightedRange && + isSameDay(state.date, highlightedRange.end); + const { locale } = useLocale(); + const dayOfWeek = getDayOfWeek(state.date, locale); + let isRangeStart = + state.isSelected && + (isFirstSelectedAfterDisabled || dayOfWeek === 0 || state.date.day === 1); + const isRangeEnd = + state.isSelected && + (isLastSelectedBeforeDisabled || + dayOfWeek === 6 || + state.date.day === + baseState.visibleRange.start.calendar.getDaysInMonth( + baseState.visibleRange.start, + )); + + props = { + "data-is-range-selection": ariaAttr( + state.isSelected && "highlightedRange" in baseState, + ), + "data-is-range-end": ariaAttr(isRangeEnd), + "data-is-range-start": ariaAttr(isRangeStart), + "data-is-selection-end": ariaAttr(isSelectionEnd), + "data-is-selection-start": ariaAttr(isSelectionStart), + ...props, + }; + + props = mergeProps(state.cellProps, props); + + return props; + }, +); + +export const CalendarCell = createComponent(props => { + const htmlProps = useCalendarCell(props); + + return createElement("td", htmlProps); +}); + +export type CalendarCellOptions = Options & { + /** + * Object returned by the `useCalendarCellState` hook. + */ + state: CalendarCellState; +}; + +export type CalendarCellProps = Props< + CalendarCellOptions +>; diff --git a/src/calendar/calendar-grid-state.ts b/src/calendar/calendar-grid-state.ts new file mode 100644 index 000000000..6ef75bbe7 --- /dev/null +++ b/src/calendar/calendar-grid-state.ts @@ -0,0 +1,35 @@ +import { CalendarDate } from "@internationalized/date"; +import { CalendarGridAria, useCalendarGrid } from "@react-aria/calendar"; + +import { CalendarBaseState } from "./calendar-base-state"; +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export function useCalendarGridState({ + state, + ...props +}: CalendarGridStateProps): CalendarGridState { + const calendarGridProps = useCalendarGrid(props, state); + + return calendarGridProps; +} + +export type CalendarGridState = CalendarGridAria; + +export type CalendarGridStateProps = { + /** + * The first date displayed in the calendar grid. + * Defaults to the first visible date in the calendar. + * Override this to display multiple date grids in a calendar. + */ + startDate?: CalendarDate; + /** + * The last date displayed in the calendar grid. + * Defaults to the last visible date in the calendar. + * Override this to display multiple date grids in a calendar. + */ + endDate?: CalendarDate; + /** + * Object returned by the `useSliderState` hook. + */ + state: CalendarBaseState | RangeCalendarBaseState; +}; diff --git a/src/calendar/calendar-grid.ts b/src/calendar/calendar-grid.ts new file mode 100644 index 000000000..b0a2ecbf3 --- /dev/null +++ b/src/calendar/calendar-grid.ts @@ -0,0 +1,34 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarGridState } from "./calendar-grid-state"; + +export const useCalendarGrid = createHook( + ({ state, ...props }) => { + props = mergeProps(state.gridProps, props); + + return props; + }, +); + +export const CalendarGrid = createComponent(props => { + const htmlProps = useCalendarGrid(props); + + return createElement("table", htmlProps); +}); + +export type CalendarGridOptions = Options & { + /** + * Object returned by the `useCalendarGridState` hook. + */ + state: CalendarGridState; +}; + +export type CalendarGridProps = Props< + CalendarGridOptions +>; diff --git a/src/calendar/calendar-next-button.ts b/src/calendar/calendar-next-button.ts new file mode 100644 index 000000000..50da24998 --- /dev/null +++ b/src/calendar/calendar-next-button.ts @@ -0,0 +1,43 @@ +import { useRef } from "react"; +import { useButton } from "@react-aria/button"; +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; +import { RangeCalendarState } from "./range-calendar-state"; + +export const useCalendarNextButton = createHook( + ({ state, ...props }) => { + const ref = useRef(null); + props = { ...props, ref: useForkRef(ref, props.ref) }; + const { buttonProps } = useButton(state.nextButtonProps, ref); + props = mergeProps(buttonProps, props); + + return props; + }, +); + +export const CalendarNextButton = createComponent( + props => { + const htmlProps = useCalendarNextButton(props); + + return createElement("button", htmlProps); + }, +); + +export type CalendarNextButtonOptions = Options & { + /** + * Object returned by the `useCalendarNextButtonState` hook. + */ + state: CalendarState | RangeCalendarState; +}; + +export type CalendarNextButtonProps = Props< + CalendarNextButtonOptions +>; diff --git a/src/calendar/calendar-prev-button.ts b/src/calendar/calendar-prev-button.ts new file mode 100644 index 000000000..fe999f3d1 --- /dev/null +++ b/src/calendar/calendar-prev-button.ts @@ -0,0 +1,42 @@ +import { useRef } from "react"; +import { useButton } from "@react-aria/button"; +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; +import { RangeCalendarState } from "./range-calendar-state"; + +export const useCalendarPreviousButton = + createHook(({ state, ...props }) => { + const ref = useRef(null); + props = { ...props, ref: useForkRef(ref, props.ref) }; + const { buttonProps } = useButton(state.prevButtonProps, ref); + props = mergeProps(buttonProps, props); + + return props; + }); + +export const CalendarPreviousButton = + createComponent(props => { + const htmlProps = useCalendarPreviousButton(props); + + return createElement("button", htmlProps); + }); + +export type CalendarPreviousButtonOptions = + Options & { + /** + * Object returned by the `useCalendarPreviousButtonState` hook. + */ + state: CalendarState | RangeCalendarState; + }; + +export type CalendarPreviousButtonProps = Props< + CalendarPreviousButtonOptions +>; diff --git a/src/calendar/calendar-state.ts b/src/calendar/calendar-state.ts new file mode 100644 index 000000000..e87001c06 --- /dev/null +++ b/src/calendar/calendar-state.ts @@ -0,0 +1,33 @@ +import { HTMLAttributes } from "react"; +import { useCalendar } from "@react-aria/calendar"; +import { AriaButtonProps } from "@react-types/button"; +import { CalendarProps, DateValue } from "@react-types/calendar"; + +import { CalendarBaseState } from "./calendar-base-state"; + +export function useCalendarState({ + state, + ...props +}: CalendarStateProps): CalendarState { + const calendarProps = useCalendar(props, state); + + return calendarProps; +} + +export type CalendarState = { + /** Props for the calendar grouping element. */ + calendarProps: HTMLAttributes; + /** Props for the next button. */ + nextButtonProps: AriaButtonProps; + /** Props for the previous button. */ + prevButtonProps: AriaButtonProps; + /** A description of the visible date range, for use in the calendar title. */ + title: string; +}; + +export type CalendarStateProps = CalendarProps & { + /** + * Object returned by the `useSliderState` hook. + */ + state: CalendarBaseState; +}; diff --git a/src/calendar/calendar-title.ts b/src/calendar/calendar-title.ts new file mode 100644 index 000000000..780c570f5 --- /dev/null +++ b/src/calendar/calendar-title.ts @@ -0,0 +1,33 @@ +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { CalendarState } from "./calendar-state"; + +export const useCalendarTitle = createHook( + ({ state, ...props }) => { + props = { children: state.title, ...props }; + + return props; + }, +); + +export const CalendarTitle = createComponent(props => { + const htmlProps = useCalendarTitle(props); + + return createElement("h2", htmlProps); +}); + +export type CalendarTitleOptions = Options & { + /** + * Object returned by the `useCalendarTitleState` hook. + */ + state: CalendarState; +}; + +export type CalendarTitleProps = Props< + CalendarTitleOptions +>; diff --git a/src/calendar/helpers/index.ts b/src/calendar/helpers/index.ts deleted file mode 100644 index e8c125f9a..000000000 --- a/src/calendar/helpers/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * for these utils inspiration - */ -import { useDateFormatter } from "@react-aria/i18n"; -import { RangeValue } from "@react-types/shared"; - -import { setDay, toUTCString } from "../../utils"; - -export function useWeekDays(weekStart: number) { - const dayFormatter = useDateFormatter({ weekday: "short" }); - const dayFormatterLong = useDateFormatter({ weekday: "long" }); - - return [0, 1, 2, 3, 4, 5, 6].map(index => { - const dateDay = setDay(Date.now(), (index + weekStart) % 7); - - const day = dayFormatter.format(dateDay); - const dayLong = dayFormatterLong.format(dateDay); - return { title: dayLong, abbr: day } as const; - }); -} - -export function generateDaysInMonthArray( - month: number, - monthStartsAt: number, - weeksInMonth: number, - year: number, -) { - return Array(weeksInMonth) - .fill(1) - .reduce((weeks: Date[][], _, weekIndex) => { - const daysInWeek = [0, 1, 2, 3, 4, 5, 6].reduce( - (days: Date[], dayIndex) => { - const day = weekIndex * 7 + dayIndex - monthStartsAt + 2; - const utcDate = toUTCString(new Date(year, month, day)); - const cellDate = new Date(utcDate); - - return [...days, cellDate]; - }, - [], - ); - - return [...weeks, daysInWeek]; - }, []); -} - -export function makeRange(start: Date, end: Date): RangeValue { - if (end < start) { - [start, end] = [end, start]; - } - - return { start, end }; -} - -export * from "./useWeekStart"; diff --git a/src/calendar/helpers/useWeekStart.ts b/src/calendar/helpers/useWeekStart.ts deleted file mode 100644 index e4ea8ceca..000000000 --- a/src/calendar/helpers/useWeekStart.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useWeekStart](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/calendar/src/useWeekStart.ts) - * to work with Reakit System - */ -import { useLocale } from "@react-aria/i18n"; - -// Data from https://github.com/unicode-cldr/cldr-core/blob/master/supplemental/weekData.json -// Locales starting on Sunday have been removed for compression. -const data: Record = { - "001": 1, - AD: 1, - AE: 6, - AF: 6, - AI: 1, - AL: 1, - AM: 1, - AN: 1, - AT: 1, - AX: 1, - AZ: 1, - BA: 1, - BE: 1, - BG: 1, - BH: 6, - BM: 1, - BN: 1, - BY: 1, - CH: 1, - CL: 1, - CM: 1, - CR: 1, - CY: 1, - CZ: 1, - DE: 1, - DJ: 6, - DK: 1, - DZ: 6, - EC: 1, - EE: 1, - EG: 6, - ES: 1, - FI: 1, - FJ: 1, - FO: 1, - FR: 1, - GB: 1, - GE: 1, - GF: 1, - GP: 1, - GR: 1, - HR: 1, - HU: 1, - IE: 1, - IQ: 6, - IR: 6, - IS: 1, - IT: 1, - JO: 6, - KG: 1, - KW: 6, - KZ: 1, - LB: 1, - LI: 1, - LK: 1, - LT: 1, - LU: 1, - LV: 1, - LY: 6, - MC: 1, - MD: 1, - ME: 1, - MK: 1, - MN: 1, - MQ: 1, - MV: 5, - MY: 1, - NL: 1, - NO: 1, - NZ: 1, - OM: 6, - PL: 1, - QA: 6, - RE: 1, - RO: 1, - RS: 1, - RU: 1, - SD: 6, - SE: 1, - SI: 1, - SK: 1, - SM: 1, - SY: 6, - TJ: 1, - TM: 1, - TR: 1, - UA: 1, - UY: 1, - UZ: 1, - VA: 1, - VN: 1, - XK: 1, -}; - -export function useWeekStart() { - const region = useRegion(); - return data[region] || 0; -} - -function useRegion(): string { - const { locale } = useLocale(); - - // If the Intl.Locale API is available, use it to get the region for the locale. - // @ts-ignore - if (Intl.Locale) { - // @ts-ignore - return new Intl.Locale(locale).maximize().region; - } - - // If not, just try splitting the string. - return locale.split("-")[1]; -} diff --git a/src/calendar/index.ts b/src/calendar/index.ts index d69459175..6fa20b6f3 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -1,10 +1,14 @@ -export * from "./__keys"; -export * from "./Calendar"; -export * from "./CalendarButton"; -export * from "./CalendarCell"; -export * from "./CalendarCellButton"; -export * from "./CalendarGrid"; -export * from "./CalendarHeader"; -export * from "./CalendarState"; -export * from "./CalendarWeekTitle"; -export * from "./RangeCalendarState"; +export * from "./calendar-base"; +export * from "./calendar-base-state"; +export * from "./calendar-cell"; +export * from "./calendar-cell-button"; +export * from "./calendar-cell-state"; +export * from "./calendar-grid"; +export * from "./calendar-grid-state"; +export * from "./calendar-next-button"; +export * from "./calendar-prev-button"; +export * from "./calendar-state"; +export * from "./calendar-title"; +export * from "./range-calendar"; +export * from "./range-calendar-base-state"; +export * from "./range-calendar-state"; diff --git a/src/calendar/range-calendar-base-state.ts b/src/calendar/range-calendar-base-state.ts new file mode 100644 index 000000000..bc627a3f4 --- /dev/null +++ b/src/calendar/range-calendar-base-state.ts @@ -0,0 +1,33 @@ +import { Calendar, DateDuration } from "@internationalized/date"; +import { + RangeCalendarState, + useRangeCalendarState, +} from "@react-stately/calendar"; +import { DateValue, RangeCalendarProps } from "@react-types/calendar"; + +export function useRangeCalendarBaseState( + props: RangeCalendarBaseStateProps, +): RangeCalendarBaseState { + const state = useRangeCalendarState(props); + + return state; +} + +export type RangeCalendarBaseState = RangeCalendarState; + +export type RangeCalendarBaseStateProps = RangeCalendarProps & { + /** The locale to display and edit the value according to. */ + locale: string; + /** + * A function that creates a [Calendar](../internationalized/date/Calendar.html) + * object for a given calendar identifier. Such a function may be imported from the + * `@internationalized/date` package, or manually implemented to include support for + * only certain calendars. + */ + createCalendar: (name: string) => Calendar; + /** + * The amount of days that will be displayed at once. This affects how pagination works. + * @default {months: 1} + */ + visibleDuration?: DateDuration; +}; diff --git a/src/calendar/range-calendar-state.ts b/src/calendar/range-calendar-state.ts new file mode 100644 index 000000000..3e392eff3 --- /dev/null +++ b/src/calendar/range-calendar-state.ts @@ -0,0 +1,38 @@ +import { HTMLAttributes, RefObject, useRef } from "react"; +import { useRangeCalendar } from "@react-aria/calendar"; +import { AriaButtonProps } from "@react-types/button"; +import { DateValue, RangeCalendarProps } from "@react-types/calendar"; + +import { RangeCalendarBaseState } from "./range-calendar-base-state"; + +export function useRangeCalendarState({ + state, + ...props +}: RangeCalendarStateProps): RangeCalendarState { + const ref = useRef(null); + const calendarProps = useRangeCalendar(props, state, ref); + + return { ...calendarProps, ref }; +} + +export type RangeCalendarState = { + /** Props for the calendar grouping element. */ + calendarProps: HTMLAttributes; + /** Props for the next button. */ + nextButtonProps: AriaButtonProps; + /** Props for the previous button. */ + prevButtonProps: AriaButtonProps; + /** A description of the visible date range, for use in the calendar title. */ + title: string; + /** + * Reference for the calendar wrapper element within the cell inside the table + */ + ref: RefObject; +}; + +export type RangeCalendarStateProps = RangeCalendarProps & { + /** + * Object returned by the `useSliderState` hook. + */ + state: RangeCalendarBaseState; +}; diff --git a/src/calendar/range-calendar.ts b/src/calendar/range-calendar.ts new file mode 100644 index 000000000..caa2fbf37 --- /dev/null +++ b/src/calendar/range-calendar.ts @@ -0,0 +1,36 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { RangeCalendarState } from "./range-calendar-state"; + +export const useRangeCalendar = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.calendarProps, props); + + return props; + }, +); + +export const RangeCalendar = createComponent(props => { + const htmlProps = useRangeCalendar(props); + + return createElement("div", htmlProps); +}); + +export type RangeCalendarOptions = Options & { + /** + * Object returned by the `useRangeCalendarState` hook. + */ + state: RangeCalendarState; +}; + +export type RangeCalendarProps = Props< + RangeCalendarOptions +>; diff --git a/src/calendar/stories/CalendarBasic.component.tsx b/src/calendar/stories/CalendarBasic.component.tsx index e5a8604d3..a3dae5a56 100644 --- a/src/calendar/stories/CalendarBasic.component.tsx +++ b/src/calendar/stories/CalendarBasic.component.tsx @@ -1,77 +1,113 @@ import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; import { - Calendar as CalendarWrapper, - CalendarButton, + Calendar, + CalendarBaseStateProps, CalendarCell, CalendarCellButton, + CalendarCellStateProps, CalendarGrid, - CalendarHeader, - CalendarInitialState, - CalendarWeekTitle, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + useCalendarBaseState, + useCalendarCellState, + useCalendarGridState, useCalendarState, } from "../../index"; -import { - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarBasicProps = CalendarBaseStateProps & {}; -export const Calendar: React.FC = props => { - const state = useCalendarState(props); +export const CalendarBasic: React.FC = props => { + const state = useCalendarBaseState(props); + const calendar = useCalendarState({ ...props, state }); return ( - + - - - - + - - - + + + - - - - + + + + ); +}; + +export default CalendarBasic; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - + ))} + + ); }; -export default Calendar; +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarBasic.css b/src/calendar/stories/CalendarBasic.css index 32193ea4b..ff17a4c0a 100644 --- a/src/calendar/stories/CalendarBasic.css +++ b/src/calendar/stories/CalendarBasic.css @@ -38,30 +38,24 @@ border: 0; } -.calendar .prev-year, .calendar .prev-month, -.calendar .next-month, -.calendar .next-year { +.calendar .next-month { padding: 4px; width: 24px; height: 24px; color: #676d7e; } -.calendar .prev-year:focus, .calendar .prev-month:focus, -.calendar .next-month:focus, -.calendar .next-year:focus { +.calendar .next-month:focus { padding: 2px; border: 2px solid #676d7e; border-radius: 4px; outline: 0; } -.calendar .prev-year:hover, .calendar .prev-month:hover, -.calendar .next-month:hover, -.calendar .next-year:hover { +.calendar .next-month:hover { padding: 3px; border: 1px solid #676d7e; border-radius: 4px; @@ -89,7 +83,7 @@ text-align: center; } -.calendar .dates th abbr { +.calendar .dates th span { text-decoration: none; } @@ -113,7 +107,7 @@ user-select: none; } -.calendar .dates td[aria-selected] span { +.calendar .dates td[aria-selected="true"] span { border-radius: 50%; border: 2px dotted black; background-color: #fbfbff; diff --git a/src/calendar/stories/CalendarBasic.stories.tsx b/src/calendar/stories/CalendarBasic.stories.tsx index 1456163dc..6701de79a 100644 --- a/src/calendar/stories/CalendarBasic.stories.tsx +++ b/src/calendar/stories/CalendarBasic.stories.tsx @@ -1,66 +1,33 @@ import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { addMonths, addYears, subMonths, toUTCString } from "../../utils"; import css from "./templates/CalendarBasicCss"; import js from "./templates/CalendarBasicJsx"; import ts from "./templates/CalendarBasicTsx"; import jsUtils from "./templates/UtilsJsx"; import tsUtils from "./templates/UtilsTsx"; -import Calendar from "./CalendarBasic.component"; +import { CalendarBasic } from "./CalendarBasic.component"; import "./CalendarBasic.css"; +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + export default { - component: Calendar, title: "Calendar/Basic", - argTypes: { - defaultValue: { control: "date" }, - value: { control: "date" }, - minValue: { control: "date" }, - maxValue: { control: "date" }, - }, + component: CalendarBasic, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css, jsUtils, tsUtils }), }, } as Meta; -export const Default: Story = args => { - return ; -}; - -export const DefaultDate = Default.bind({}); -DefaultDate.args = { defaultValue: toUTCString(addYears(new Date(), 1)) }; - -export const MinMaxDate = Default.bind({}); -MinMaxDate.args = { - minValue: toUTCString(subMonths(new Date(), 1)), - maxValue: toUTCString(addMonths(new Date(), 1)), -}; - -export const IsDisabled = Default.bind({}); -IsDisabled.args = { defaultValue: toUTCString(new Date()), isDisabled: true }; - -export const IsReadonly = Default.bind({}); -IsReadonly.args = { defaultValue: toUTCString(new Date()), isReadOnly: true }; - -export const AutoFocus = Default.bind({}); -AutoFocus.args = { defaultValue: toUTCString(new Date()), autoFocus: true }; - -export const ControlledInput = () => { - const [value, setValue] = React.useState("2020-10-13"); +export const Default = () => { + let { locale } = useLocale(); - return ( - - setValue(e.target.value)} - value={value} - /> - - - ); + return ; }; diff --git a/src/calendar/stories/CalendarRange.component.tsx b/src/calendar/stories/CalendarRange.component.tsx index 7498e388c..246adc737 100644 --- a/src/calendar/stories/CalendarRange.component.tsx +++ b/src/calendar/stories/CalendarRange.component.tsx @@ -1,77 +1,113 @@ import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; import { - Calendar, - CalendarButton, CalendarCell, CalendarCellButton, + CalendarCellStateProps, CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - RangeCalendarInitialState, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + RangeCalendar, + RangeCalendarBaseStateProps, + useCalendarCellState, + useCalendarGridState, + useRangeCalendarBaseState, useRangeCalendarState, } from "../../index"; -import { - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarRangeProps = RangeCalendarBaseStateProps & {}; -export const RangeCalendar: React.FC = props => { - const state = useRangeCalendarState(props); +export const CalendarRange: React.FC = props => { + const state = useRangeCalendarBaseState(props); + const calendar = useRangeCalendarState({ ...props, state }); return ( - + - - - - + - - - + + + - - - - + + + + ); +}; + +export default CalendarRange; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - + ))} + + ); }; -export default RangeCalendar; +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarRange.css b/src/calendar/stories/CalendarRange.css index 706b15de9..935a89084 100644 --- a/src/calendar/stories/CalendarRange.css +++ b/src/calendar/stories/CalendarRange.css @@ -90,7 +90,7 @@ text-align: center; } -.calendar-range .dates th abbr { +.calendar-range .dates th span { text-decoration: none; } diff --git a/src/calendar/stories/CalendarRange.stories.tsx b/src/calendar/stories/CalendarRange.stories.tsx index 1711b52a8..102f97963 100644 --- a/src/calendar/stories/CalendarRange.stories.tsx +++ b/src/calendar/stories/CalendarRange.stories.tsx @@ -1,95 +1,33 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { addDays, addWeeks, subDays, subWeeks, toUTCString } from "../../utils"; import css from "./templates/CalendarRangeCss"; import js from "./templates/CalendarRangeJsx"; import ts from "./templates/CalendarRangeTsx"; import jsUtils from "./templates/UtilsJsx"; import tsUtils from "./templates/UtilsTsx"; -import RangeCalendar from "./CalendarRange.component"; +import { CalendarRange } from "./CalendarRange.component"; import "./CalendarRange.css"; +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + export default { - component: RangeCalendar, title: "Calendar/Range", - argTypes: { - minValue: { control: "date" }, - maxValue: { control: "date" }, - }, + component: CalendarRange, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css, jsUtils, tsUtils }), }, } as Meta; -export const Default: Story = args => { - return ; -}; - -export const DefaultValue = Default.bind({}); -DefaultValue.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, -}; - -export const MinMaxDate = Default.bind({}); -MinMaxDate.args = { - minValue: toUTCString(subWeeks(new Date(), 1)), - maxValue: toUTCString(addWeeks(new Date(), 1)), -}; - -export const Disabled = Default.bind({}); -Disabled.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - isDisabled: true, -}; - -export const Readonly = Default.bind({}); -Readonly.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - isReadOnly: true, -}; - -export const Autofocus = Default.bind({}); -Autofocus.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - autoFocus: true, -}; - -export const ControlledInput = () => { - const [start, setStart] = React.useState(toUTCString(subDays(new Date(), 1))); - const [end, setEnd] = React.useState(toUTCString(addDays(new Date(), 1))); +export const Default = () => { + let { locale } = useLocale(); - return ( - - setStart(e.target.value)} - value={start} - /> - setEnd(e.target.value)} value={end} /> - { - setStart(start); - setEnd(end); - }} - /> - - ); + return ; }; diff --git a/src/calendar/stories/CalendarRangeStyled.component.tsx b/src/calendar/stories/CalendarRangeStyled.component.tsx new file mode 100644 index 000000000..24d9d77fb --- /dev/null +++ b/src/calendar/stories/CalendarRangeStyled.component.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; + +import { + CalendarCell, + CalendarCellButton, + CalendarCellStateProps, + CalendarGrid, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + RangeCalendar, + RangeCalendarBaseStateProps, + useCalendarCellState, + useCalendarGridState, + useRangeCalendarBaseState, + useRangeCalendarState, +} from "../../index"; + +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarRangeStyledProps = RangeCalendarBaseStateProps & {}; + +export const CalendarRangeStyled: React.FC< + CalendarRangeStyledProps +> = props => { + const state = useRangeCalendarBaseState(props); + const calendar = useRangeCalendarState({ ...props, state }); + + return ( + + + + + + + + + + + + + ); +}; + +export default CalendarRangeStyled; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); + + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} + + ))} + + + ); +}; + +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarRangeStyled.stories.tsx b/src/calendar/stories/CalendarRangeStyled.stories.tsx new file mode 100644 index 000000000..09db680aa --- /dev/null +++ b/src/calendar/stories/CalendarRangeStyled.stories.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/CalendarRangeStyledJsx"; +import ts from "./templates/CalendarRangeStyledTsx"; +import jsUtils from "./templates/UtilsJsx"; +import tsUtils from "./templates/UtilsTsx"; +import { CalendarRangeStyled } from "./CalendarRangeStyled.component"; + +import "./tailwind.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "Calendar/RangeStyled", + component: CalendarRangeStyled, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, jsUtils, tsUtils }), + }, + decorators: [ + Story => { + document.body.id = "tailwind"; + return ; + }, + ], +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ( + + ); +}; diff --git a/src/calendar/stories/CalendarStyled.component.tsx b/src/calendar/stories/CalendarStyled.component.tsx new file mode 100644 index 000000000..e0f6e220a --- /dev/null +++ b/src/calendar/stories/CalendarStyled.component.tsx @@ -0,0 +1,128 @@ +import * as React from "react"; +import { getWeeksInMonth, startOfWeek } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { VisuallyHidden } from "ariakit"; + +import { + Calendar, + CalendarBaseStateProps, + CalendarCell, + CalendarCellButton, + CalendarCellStateProps, + CalendarGrid, + CalendarGridStateProps, + CalendarNextButton, + CalendarPreviousButton, + CalendarTitle, + useCalendarBaseState, + useCalendarCellState, + useCalendarGridState, + useCalendarState, +} from "../../index"; + +import { ChevronLeft, ChevronRight } from "./Utils.component"; + +export type CalendarStyledProps = CalendarBaseStateProps & {}; + +export const CalendarStyled: React.FC = props => { + const state = useCalendarBaseState(props); + const calendar = useCalendarState({ ...props, state }); + + return ( + + + + + + + + + + + + + ); +}; + +export default CalendarStyled; + +export type CalendarGridProps = CalendarGridStateProps & {}; + +const CalendarGridComp = (props: CalendarGridProps) => { + const { state: baseState } = props; + let { locale } = useLocale(); + let gridState = useCalendarGridState(props); + + // Find the start date of the grid, which is the beginning + // of the week the month starts in. Also get the number of + // weeks in the month so we can render the proper number of rows. + let monthStart = startOfWeek(baseState.visibleRange.start, locale); + let weeksInMonth = getWeeksInMonth(baseState.visibleRange.start, locale); + + return ( + + + + {gridState.weekDays.map((day, index) => { + return ( + + {/* Make sure screen readers read the full day name, + but we show an abbreviation visually. */} + {day} + {day} + + ); + })} + + + + {[...new Array(weeksInMonth).keys()].map(weekIndex => ( + + {[...new Array(7).keys()].map(dayIndex => ( + + ))} + + ))} + + + ); +}; + +export type CalendarCellProps = CalendarCellStateProps & {}; + +const CalendarCellComp = (props: CalendarCellProps) => { + const state = useCalendarCellState(props); + + return ( + + + {state.formattedDate} + + + ); +}; diff --git a/src/calendar/stories/CalendarStyled.stories.tsx b/src/calendar/stories/CalendarStyled.stories.tsx new file mode 100644 index 000000000..497ccf0ba --- /dev/null +++ b/src/calendar/stories/CalendarStyled.stories.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/CalendarStyledJsx"; +import ts from "./templates/CalendarStyledTsx"; +import jsUtils from "./templates/UtilsJsx"; +import tsUtils from "./templates/UtilsTsx"; +import { CalendarStyled } from "./CalendarStyled.component"; + +import "./tailwind.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "Calendar/Styled", + component: CalendarStyled, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, jsUtils, tsUtils }), + }, + decorators: [ + Story => { + document.body.id = "tailwind"; + return ; + }, + ], +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ; +}; diff --git a/src/calendar/stories/Utils.component.tsx b/src/calendar/stories/Utils.component.tsx index cb9286a68..b8b592850 100644 --- a/src/calendar/stories/Utils.component.tsx +++ b/src/calendar/stories/Utils.component.tsx @@ -1,24 +1,5 @@ import * as React from "react"; -export const DoubleChevronLeft = (props: React.SVGProps) => { - return ( - - - - ); -}; - export const ChevronLeft = (props: React.SVGProps) => { return ( ) => { export const ChevronRight = (props: React.SVGProps) => ( ); - -export const DoubleChevronRight = (props: React.SVGProps) => ( - -); diff --git a/src/calendar/stories/tailwind.css b/src/calendar/stories/tailwind.css new file mode 100644 index 000000000..a7de9a2d9 --- /dev/null +++ b/src/calendar/stories/tailwind.css @@ -0,0 +1,53 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer components { + .styled-datepicker .calendar__cell { + height: 32px; + width: 32px; + max-height: 32px; + max-width: 32px; + @apply text-sm text-center rounded-lg; + } + .styled-datepicker .calendar__cell[data-is-range-selection] { + @apply bg-blue-100 rounded-none text-gray-800 !important; + } + .styled-datepicker .calendar__cell[data-is-selection-start] { + @apply bg-blue-500 rounded-l-lg text-white !important; + } + .styled-datepicker .calendar__cell[data-is-selection-end] { + @apply bg-blue-500 rounded-r-lg text-white !important; + } + + .styled-datepicker .calendar__cell[data-is-range-selection]:focus-within { + @apply bg-blue-400 text-white !important; + } + .styled-datepicker .calendar__cell:focus-within { + @apply bg-gray-100; + } + + .styled-datepicker.calendar [data-weekend] { + @apply text-red-600; + } + + .styled-datepicker.calendar [aria-selected="true"] { + @apply text-white bg-blue-500; + } + + .styled-datepicker.calendar [aria-selected]:focus-within { + @apply bg-gray-100; + } + + .styled-datepicker.calendar [aria-selected="true"]:focus-within { + @apply text-white bg-blue-400; + } + + .styled-datepicker.calendar [aria-disabled="true"] { + @apply text-gray-500; + } + + .styled-datepicker.calendar span { + outline: none; + } +} diff --git a/src/checkbox/Checkbox.tsx b/src/checkbox/Checkbox.tsx deleted file mode 100644 index b097541df..000000000 --- a/src/checkbox/Checkbox.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import * as React from "react"; -import { ClickableHTMLProps, ClickableOptions, useClickable } from "reakit"; -import { removeIndexFromArray, useForkRef, useLiveRef } from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { createComponent, createHook } from "../system"; - -import { CHECKBOX_KEYS } from "./__keys"; -import { CheckboxStateReturn } from "./CheckboxState"; -import { fireChange, getChecked, useIndeterminateState } from "./helpers"; - -export type CheckboxOptions = ClickableOptions & - Pick, "state" | "setState"> & { - /** - * Checkbox's value is going to be used when multiple checkboxes share the - * same state. Checking a checkbox with value will add it to the state - * array. - */ - value?: string | number; - - /** - * Checkbox's checked state. If present, it's used instead of `state`. - */ - checked?: boolean; - }; - -export type CheckboxHTMLProps = ClickableHTMLProps & - React.InputHTMLAttributes & { - value?: string | number; - }; - -export type CheckboxProps = CheckboxOptions & CheckboxHTMLProps; - -export const useCheckbox = createHook({ - name: "Checkbox", - compose: useClickable, - keys: CHECKBOX_KEYS, - - useOptions(options, htmlProps) { - const { unstable_clickOnEnter = false, ...restOptions } = options; - const { value, checked } = htmlProps; - - return { - unstable_clickOnEnter, - value, - checked: getChecked({ checked, ...options }), - ...restOptions, - }; - }, - - useProps(options, htmlProps) { - const { state, setState, value, checked, disabled } = options; - const { - ref: htmlRef, - onChange: htmlOnChange, - onClick: htmlOnClick, - ...restHtmlProps - } = htmlProps; - const ref = React.useRef(null); - const [isNativeCheckbox, setIsNativeCheckbox] = React.useState(true); - const onChangeRef = useLiveRef(htmlOnChange); - const onClickRef = useLiveRef(htmlOnClick); - - React.useEffect(() => { - const element = ref.current; - - if (!element) { - warning( - true, - "Can't determine whether the element is a native checkbox because `ref` wasn't passed to the component", - ); - return; - } - - if (element.tagName !== "INPUT" || element.type !== "checkbox") { - setIsNativeCheckbox(false); - } - }, []); - - useIndeterminateState(ref, options); - - const onChange = React.useCallback( - (event: React.ChangeEvent) => { - const element = event.currentTarget; - - if (disabled) { - event.stopPropagation(); - event.preventDefault(); - - return; - } - - if (onChangeRef.current) { - // If component is NOT rendered as a native input, it will not have - // the `checked` property. So we assign it for consistency. - if (!isNativeCheckbox) { - element.checked = !element.checked; - } - - onChangeRef.current(event); - } - - if (!setState) return; - - if (typeof value === "undefined") { - setState(!checked); - } else { - const stateProp = Array.isArray(state) ? state : []; - const index = stateProp.indexOf(value); - - if (index === -1) { - setState([...stateProp, value]); - } else { - setState(removeIndexFromArray(stateProp, index)); - } - } - }, - [ - disabled, - onChangeRef, - setState, - value, - isNativeCheckbox, - checked, - state, - ], - ); - - const onClick = React.useCallback( - (event: React.MouseEvent) => { - onClickRef.current?.(event); - - if (event.defaultPrevented) return; - - if (isNativeCheckbox) return; - - fireChange(event.currentTarget, onChange); - }, - [isNativeCheckbox, onChange, onClickRef], - ); - - return { - ref: useForkRef(ref, htmlRef), - role: !isNativeCheckbox ? "checkbox" : undefined, - type: isNativeCheckbox ? "checkbox" : undefined, - value: isNativeCheckbox ? value : undefined, - checked: checked, - "aria-checked": state === "indeterminate" ? "mixed" : checked, - onChange, - onClick, - ...restHtmlProps, - }; - }, -}); - -export const Checkbox = createComponent({ - as: "input", - memo: true, - useHook: useCheckbox, -}); diff --git a/src/checkbox/CheckboxState.tsx b/src/checkbox/CheckboxState.tsx deleted file mode 100644 index 4ca481e9a..000000000 --- a/src/checkbox/CheckboxState.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useControllableState } from "../utils/index"; - -export type CheckboxState = { - /** - * Stores the state of the checkbox. - * If checkboxes that share this state have defined a `value` prop, it's - * going to be an array. - */ - state: boolean | "indeterminate" | Array; -}; - -export type CheckboxActions = { - /** - * Sets `state` for the checkbox. - */ - setState: React.Dispatch>; -}; - -export type CheckboxInitialState = { - /** - * Default State of the Checkbox for uncontrolled Checkbox. - * - * @default false - */ - defaultState?: CheckboxState["state"]; - - /** - * State of the Checkbox for controlled Checkbox.. - */ - state?: CheckboxState["state"]; - - /** - * OnChange callback for controlled Checkbox. - */ - onStateChange?: React.Dispatch>; -}; - -export type CheckboxStateReturn = CheckboxState & CheckboxActions; - -export function useCheckboxState( - props: CheckboxInitialState = {}, -): CheckboxStateReturn { - const { - // Default State should be false otherwise input state will be undefined - defaultState = false, - state: stateProp, - onStateChange, - } = props; - - const [state, setState] = useControllableState({ - defaultValue: defaultState, - value: stateProp, - onChange: onStateChange, - }); - - return { state, setState }; -} diff --git a/src/checkbox/__keys.ts b/src/checkbox/__keys.ts deleted file mode 100644 index 372208d29..000000000 --- a/src/checkbox/__keys.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Automatically generated -export const USE_CHECKBOX_STATE_KEYS = [ - "defaultState", - "state", - "onStateChange", -] as const; -export const CHECKBOX_STATE_KEYS = ["state", "setState"] as const; -export const CHECKBOX_KEYS = [ - ...CHECKBOX_STATE_KEYS, - "value", - "checked", -] as const; diff --git a/src/checkbox/helpers.tsx b/src/checkbox/helpers.tsx deleted file mode 100644 index 77b0a1ed6..000000000 --- a/src/checkbox/helpers.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react"; -import { createEvent } from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { CheckboxOptions } from "./Checkbox"; - -export function getChecked(options: CheckboxOptions) { - const { checked, value, state } = options; - if (typeof checked !== "undefined") return checked; - - if (typeof value === "undefined") return !!state; - - const stateProp = Array.isArray(state) ? state : []; - - return stateProp.indexOf(value) !== -1; -} - -export function fireChange( - element: HTMLElement, - onChange?: React.ChangeEventHandler, -) { - const event = createEvent(element, "change"); - - Object.defineProperties(event, { - type: { value: "change" }, - target: { value: element }, - currentTarget: { value: element }, - }); - - onChange?.(event as any); -} - -export function useIndeterminateState( - ref: React.RefObject, - options: CheckboxOptions, -) { - const { state } = options; - - React.useEffect(() => { - const element = ref.current; - - if (!element) { - warning( - state === "indeterminate", - "Can't set indeterminate state because `ref` wasn't passed to component.", - ); - return; - } - - if (state === "indeterminate") { - element.indeterminate = true; - } else if (element.indeterminate) { - element.indeterminate = false; - } - }, [state, ref]); -} diff --git a/src/checkbox/index.ts b/src/checkbox/index.ts deleted file mode 100644 index 95b2cc59a..000000000 --- a/src/checkbox/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./__keys"; -export * from "./Checkbox"; -export * from "./CheckboxState"; diff --git a/src/checkbox/stories/CheckboxBasic.component.tsx b/src/checkbox/stories/CheckboxBasic.component.tsx deleted file mode 100644 index f4e398794..000000000 --- a/src/checkbox/stories/CheckboxBasic.component.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from "react"; - -import { - Checkbox as RenderlesskitCheckbox, - CheckboxHTMLProps, - CheckboxInitialState, - splitStateProps, - USE_CHECKBOX_STATE_KEYS, - useCheckboxState, -} from "../../index"; - -export type CheckboxProps = CheckboxHTMLProps & CheckboxInitialState & {}; - -export const Checkbox: React.FC = props => { - const [stateProps, checkboxProps] = splitStateProps< - CheckboxInitialState, - CheckboxProps - >(props, USE_CHECKBOX_STATE_KEYS); - - const state = useCheckboxState(stateProps); - - return ; -}; - -export default Checkbox; diff --git a/src/checkbox/stories/CheckboxBasic.stories.tsx b/src/checkbox/stories/CheckboxBasic.stories.tsx deleted file mode 100644 index a6d84f96a..000000000 --- a/src/checkbox/stories/CheckboxBasic.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createControls, createPreviewTabs } from "../../../.storybook/utils"; -import { CheckboxState } from "../CheckboxState"; - -import js from "./templates/CheckboxBasicJsx"; -import ts from "./templates/CheckboxBasicTsx"; -import { Checkbox, CheckboxProps } from "./CheckboxBasic.component"; - -export default { - component: Checkbox, - title: "Checkbox/Basic", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts }), - }, - argTypes: createControls({ - ignore: [ - "unstable_system", - "unstable_clickOnEnter", - "unstable_clickOnSpace", - "wrapElement", - "focusable", - "as", - "checked", - "state", - "setState", - "onStateChange", - "value", - ], - }), -} as Meta; - -export const Default: Story = args => ; - -export const Controlled = () => { - const [value, setValue] = React.useState(true); - console.log("%cvalue", "color: #997326", value); - - return ; -}; diff --git a/src/datefield/date-segment.ts b/src/datefield/date-segment.ts new file mode 100644 index 000000000..8bc70b9bc --- /dev/null +++ b/src/datefield/date-segment.ts @@ -0,0 +1,43 @@ +import { useRef } from "react"; +import { useDateSegment as useAriaDateSegment } from "@react-aria/datepicker"; +import { mergeProps } from "@react-aria/utils"; +import { DateSegment as DateSegmentState } from "@react-stately/datepicker"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DateFieldBaseState } from "./datefield-base-state"; + +export const useDateSegment = createHook( + ({ state, segment, ...props }) => { + const ref = useRef(null); + + props = { ...props, ref: useForkRef(ref, props.ref) }; + const { segmentProps } = useAriaDateSegment(segment, state, ref); + props = mergeProps(segmentProps, props); + + return props; + }, +); + +export const DateSegment = createComponent(props => { + const htmlProps = useDateSegment(props); + + return createElement("div", htmlProps); +}); + +export type DateSegmentOptions = Options & { + segment: DateSegmentState; + /** + * Object returned by the `useDateFieldBaseState` hook. + */ + state: DateFieldBaseState; +}; + +export type DateSegmentProps = Props< + DateSegmentOptions +>; diff --git a/src/datefield/datefield-base-state.ts b/src/datefield/datefield-base-state.ts new file mode 100644 index 000000000..dd18ffd25 --- /dev/null +++ b/src/datefield/datefield-base-state.ts @@ -0,0 +1,34 @@ +import { Calendar } from "@internationalized/date"; +import { DateFieldState, useDateFieldState } from "@react-stately/datepicker"; +import { + DatePickerProps, + DateValue, + Granularity, +} from "@react-types/datepicker"; + +export function useDateFieldBaseState( + props: DateFieldBaseStateProps, +): DateFieldBaseState { + const state = useDateFieldState(props); + + return state; +} + +export type DateFieldBaseState = DateFieldState & {}; + +export type DateFieldBaseStateProps = DatePickerProps & { + /** + * The maximum unit to display in the date field. + * @default 'year' + */ + maxGranularity?: "year" | "month" | Granularity; + /** The locale to display and edit the value according to. */ + locale: string; + /** + * A function that creates a [Calendar](../internationalized/date/Calendar.html) + * object for a given calendar identifier. Such a function may be imported from the + * `@internationalized/date` package, or manually implemented to include support for + * only certain calendars. + */ + createCalendar: (name: string) => Calendar; +}; diff --git a/src/datefield/datefield-base.ts b/src/datefield/datefield-base.ts new file mode 100644 index 000000000..c107ca451 --- /dev/null +++ b/src/datefield/datefield-base.ts @@ -0,0 +1,34 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DateFieldState } from "./datefield-state"; + +export const useDateField = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.fieldProps, props); + + return props; + }, +); + +export const DateField = createComponent(props => { + const htmlProps = useDateField(props); + + return createElement("div", htmlProps); +}); + +export type DateFieldOptions = Options & { + /** + * Object returned by the `useDateFieldBaseState` hook. + */ + state: DateFieldState; +}; + +export type DateFieldProps = Props>; diff --git a/src/datefield/datefield-state.ts b/src/datefield/datefield-state.ts new file mode 100644 index 000000000..088bbbcc0 --- /dev/null +++ b/src/datefield/datefield-state.ts @@ -0,0 +1,38 @@ +import { RefObject, useRef } from "react"; +import { DateValue } from "@internationalized/date"; +import { DateFieldAria, useDateField } from "@react-aria/datepicker"; +import { AriaDatePickerProps } from "@react-types/datepicker"; + +import { DateFieldBaseState } from "./datefield-base-state"; + +export function useDateFieldState({ + state, + ...props +}: DateFieldStateProps): DateFieldState { + const ref = useRef(null); + const datefield = useDateField(props, state, ref); + + return { ...datefield, ref }; +} + +export type DateFieldState = DateFieldAria & { + /** + * Reference for the date picker's visible label element, if any. + */ + ref: RefObject; +}; + +export type DateFieldStateProps = Omit< + AriaDatePickerProps, + | "value" + | "defaultValue" + | "onChange" + | "minValue" + | "maxValue" + | "placeholderValue" +> & { + /** + * Object returned by the `useDateFieldBaseState` hook. + */ + state: DateFieldBaseState; +}; diff --git a/src/datefield/index.ts b/src/datefield/index.ts new file mode 100644 index 000000000..868020dbd --- /dev/null +++ b/src/datefield/index.ts @@ -0,0 +1,4 @@ +export * from "./date-segment"; +export * from "./datefield-base"; +export * from "./datefield-base-state"; +export * from "./datefield-state"; diff --git a/src/datefield/stories/DateFieldBasic.component.tsx b/src/datefield/stories/DateFieldBasic.component.tsx new file mode 100644 index 000000000..328bf7d73 --- /dev/null +++ b/src/datefield/stories/DateFieldBasic.component.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { + DateField, + DateFieldBaseStateProps, + DateSegment, + useDateFieldBaseState, + useDateFieldState, +} from "../../index"; + +export type DateFieldBasicProps = DateFieldBaseStateProps & {}; + +export const DateFieldBasic: React.FC = props => { + const state = useDateFieldBaseState({ ...props }); + const datefield = useDateFieldState({ ...props, state }); + + return ( + + {state.segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); +}; + +export default DateFieldBasic; diff --git a/src/datefield/stories/DateFieldBasic.css b/src/datefield/stories/DateFieldBasic.css new file mode 100644 index 000000000..65203bbe4 --- /dev/null +++ b/src/datefield/stories/DateFieldBasic.css @@ -0,0 +1,19 @@ +* { + box-sizing: border-box; +} + +.datepicker__field { + font-family: monospace; + display: flex; +} + +.datepicker__field--item { + padding: 2px; + border-radius: 4px; +} + +.datepicker__field--item:focus { + background-color: #1e65fd; + color: white; + outline: none; +} diff --git a/src/datefield/stories/DateFieldBasic.stories.tsx b/src/datefield/stories/DateFieldBasic.stories.tsx new file mode 100644 index 000000000..82cbd0bfa --- /dev/null +++ b/src/datefield/stories/DateFieldBasic.stories.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/DateFieldBasicCss"; +import js from "./templates/DateFieldBasicJsx"; +import ts from "./templates/DateFieldBasicTsx"; +import { DateFieldBasic } from "./DateFieldBasic.component"; + +import "./DateFieldBasic.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "DateField/Basic", + component: DateFieldBasic, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ( + + ); +}; diff --git a/src/datefield/stories/DateFieldStyled.component.tsx b/src/datefield/stories/DateFieldStyled.component.tsx new file mode 100644 index 000000000..131b2444d --- /dev/null +++ b/src/datefield/stories/DateFieldStyled.component.tsx @@ -0,0 +1,33 @@ +import React from "react"; + +import { + DateField, + DateFieldBaseStateProps, + DateSegment, + useDateFieldBaseState, + useDateFieldState, +} from "../../index"; + +export type DateFieldStyledProps = DateFieldBaseStateProps & {}; + +export const DateFieldStyled: React.FC = props => { + const state = useDateFieldBaseState({ ...props }); + const datefield = useDateFieldState({ ...props, state }); + + return ( + + {state.segments.map((segment, i) => ( + + {segment.text} + + ))} + + ); +}; + +export default DateFieldStyled; diff --git a/src/datefield/stories/DateFieldStyled.stories.tsx b/src/datefield/stories/DateFieldStyled.stories.tsx new file mode 100644 index 000000000..e292924bd --- /dev/null +++ b/src/datefield/stories/DateFieldStyled.stories.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import { ComponentMeta } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/DateFieldStyledJsx"; +import ts from "./templates/DateFieldStyledTsx"; +import { DateFieldStyled } from "./DateFieldStyled.component"; + +import "./tailwind.css"; + +type Meta = ComponentMeta; +// type Story = ComponentStoryObj; + +export default { + title: "DateField/Styled", + component: DateFieldStyled, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts }), + }, + decorators: [ + Story => { + document.body.id = "tailwind"; + return ; + }, + ], +} as Meta; + +export const Default = () => { + let { locale } = useLocale(); + + return ( + + ); +}; diff --git a/src/datefield/stories/tailwind.css b/src/datefield/stories/tailwind.css new file mode 100644 index 000000000..b5c61c956 --- /dev/null +++ b/src/datefield/stories/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/src/datepicker/DatePicker.ts b/src/datepicker/DatePicker.ts deleted file mode 100644 index bb2737f58..000000000 --- a/src/datepicker/DatePicker.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - PickerBaseHTMLProps, - PickerBaseOptions, - usePickerBase, -} from "../picker-base"; -import { createComponent, createHook } from "../system"; -import { ariaAttr } from "../utils"; - -import { DATE_PICKER_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "./DatePickerState"; - -export type DatePickerOptions = PickerBaseOptions & - Pick; - -export type DatePickerHTMLProps = PickerBaseHTMLProps; - -export type DatePickerProps = DatePickerOptions & DatePickerHTMLProps; - -export const useDatePicker = createHook( - { - name: "DatePicker", - compose: usePickerBase, - keys: DATE_PICKER_KEYS, - - useProps(options, htmlProps) { - const { validationState, isRequired } = options; - - return { - "aria-invalid": ariaAttr(validationState === "invalid"), - "aria-required": ariaAttr(isRequired), - ...htmlProps, - }; - }, - }, -); - -export const DatePicker = createComponent({ - as: "div", - memo: true, - useHook: useDatePicker, -}); diff --git a/src/datepicker/DatePickerContent.ts b/src/datepicker/DatePickerContent.ts deleted file mode 100644 index 7e5a54693..000000000 --- a/src/datepicker/DatePickerContent.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - PickerBaseHTMLProps, - PickerBaseOptions, - usePickerBaseContent, -} from "../picker-base"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_CONTENT_KEYS } from "./__keys"; - -export type DatePickerContentOptions = PickerBaseOptions; - -export type DatePickerContentHTMLProps = PickerBaseHTMLProps; - -export type DatePickerContentProps = DatePickerContentOptions & - DatePickerContentHTMLProps; - -export const useDatePickerContent = createHook< - DatePickerContentOptions, - DatePickerContentHTMLProps ->({ - name: "DatePickerContent", - compose: usePickerBaseContent, - keys: DATE_PICKER_CONTENT_KEYS, -}); - -export const DatePickerContent = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerContent, -}); diff --git a/src/datepicker/DatePickerSegment.ts b/src/datepicker/DatePickerSegment.ts deleted file mode 100644 index 0dcfcc5ca..000000000 --- a/src/datepicker/DatePickerSegment.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { unstable_useId as useId } from "reakit"; - -import { SegmentHTMLProps, SegmentOptions, useSegment } from "../segment"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_SEGMENT_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "."; - -export type DatePickerSegmentOptions = SegmentOptions & - Partial>; - -export type DatePickerSegmentHTMLProps = SegmentHTMLProps; - -export type DatePickerSegmentProps = DatePickerSegmentOptions & - DatePickerSegmentHTMLProps; - -export const useDatePickerSegment = createHook< - DatePickerSegmentOptions, - DatePickerSegmentHTMLProps ->({ - name: "DatePickerSegment", - compose: useSegment, - keys: DATE_PICKER_SEGMENT_KEYS, - - useProps(options, htmlProps) { - const { id } = useId({ baseId: "datepicker-segment" }); - return { - id, - ...(options.isDateRangePicker - ? { "aria-labelledby": `${options.pickerId} ${options.baseId} ${id}` } - : { "aria-labelledby": id }), - ...htmlProps, - }; - }, -}); - -export const DatePickerSegment = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerSegment, -}); diff --git a/src/datepicker/DatePickerSegmentField.ts b/src/datepicker/DatePickerSegmentField.ts deleted file mode 100644 index f3df3338f..000000000 --- a/src/datepicker/DatePickerSegmentField.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - SegmentFieldHTMLProps, - SegmentFieldOptions, - useSegmentField, -} from "../segment"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_SEGMENT_FIELD_KEYS } from "./__keys"; -import { DatePickerStateReturn } from "./DatePickerState"; -import { DateRangePickerStateReturn } from "./DateRangePickerState"; - -export type DatePickerSegmentFieldOptions = - | SegmentFieldOptions - | Partial - | Partial; - -export type DatePickerSegmentFieldHTMLProps = SegmentFieldHTMLProps; - -export type DatePickerSegmentFieldProps = DatePickerSegmentFieldOptions & - DatePickerSegmentFieldHTMLProps; - -export const useDatePickerSegmentField = createHook< - DatePickerSegmentFieldOptions, - DatePickerSegmentFieldHTMLProps ->({ - name: "DatePickerSegmentField", - compose: useSegmentField, - keys: DATE_PICKER_SEGMENT_FIELD_KEYS, -}); - -export const DatePickerSegmentField = createComponent({ - as: "div", - memo: true, - useHook: useDatePickerSegmentField, -}); diff --git a/src/datepicker/DatePickerState.ts b/src/datepicker/DatePickerState.ts deleted file mode 100644 index 977f061f8..000000000 --- a/src/datepicker/DatePickerState.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useDatePickerState](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/datepicker/src/useDatePickerState.ts) - * to work with Reakit System - */ - -import * as React from "react"; -import { Validation, ValidationState, ValueBase } from "@react-types/shared"; - -import { useCalendarState } from "../calendar"; -import { PickerBaseInitialState, usePickerBaseState } from "../picker-base"; -import { SegmentInitialState, useSegmentState } from "../segment"; -import { toUTCString, useControllableState } from "../utils"; -import { RangeValueBase } from "../utils/types"; - -export type DatePickerInitialState = ValueBase & - RangeValueBase & - Validation & - PickerBaseInitialState & - Pick, "formatOptions" | "placeholderDate"> & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - }; - -export const useDatePickerState = (props: DatePickerInitialState = {}) => { - const { - value: initialValue, - defaultValue = toUTCString(new Date()), - onChange, - minValue, - maxValue, - isRequired, - autoFocus, - formatOptions, - placeholderDate, - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const date = new Date(value); - const setDate = (date: Date) => setValue(toUTCString(date)); - const minDateValue = minValue ? new Date(minValue) : new Date(-864e13); - const maxDateValue = maxValue ? new Date(maxValue) : new Date(864e13); - - const segmentState = useSegmentState({ - value: date, - onChange: setDate, - formatOptions, - placeholderDate, - }); - - const popover = usePickerBaseState({ - segmentFocus: segmentState.first, - ...props, - }); - - const selectDate = (newValue: string) => { - setValue(newValue); - popover.hide(); - }; - - const calendar = useCalendarState({ - value, - onChange: selectDate, - minValue: minValue, - maxValue: maxValue, - }); - - const validationState: ValidationState = - props.validationState || (isInvalidDateRange(date) ? "invalid" : "valid"); - - React.useEffect(() => { - if (popover.visible) { - calendar.setFocused(true); - calendar.focusCell(date); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [popover.visible]); - - React.useEffect(() => { - if (autoFocus) { - segmentState.first(); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFocus, segmentState.first]); - - function isInvalidDateRange(value: Date) { - const min = new Date(minDateValue); - const max = new Date(maxDateValue); - - return value < min || value > max; - } - - return { - dateValue: value, - setDateValue: setValue, - selectDate, - validationState, - minValue, - maxValue, - isRequired, - ...popover, - ...segmentState, - calendar, - isDateRangePicker: false, - }; -}; - -export type DatePickerStateReturn = ReturnType; diff --git a/src/datepicker/DatePickerTrigger.ts b/src/datepicker/DatePickerTrigger.ts deleted file mode 100644 index 6e3c2c16c..000000000 --- a/src/datepicker/DatePickerTrigger.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - PickerBaseTriggerHTMLProps, - PickerBaseTriggerOptions, - usePickerBaseTrigger, -} from "../picker-base"; -import { createComponent, createHook } from "../system"; - -import { DATE_PICKER_TRIGGER_KEYS } from "./__keys"; - -export type DatePickerTriggerOptions = PickerBaseTriggerOptions; - -export type DatePickerTriggerHTMLProps = PickerBaseTriggerHTMLProps; - -export type DatePickerTriggerProps = DatePickerTriggerOptions & - DatePickerTriggerHTMLProps; - -export const useDatePickerTrigger = createHook< - DatePickerTriggerOptions, - DatePickerTriggerHTMLProps ->({ - name: "DatePickerTrigger", - compose: usePickerBaseTrigger, - keys: DATE_PICKER_TRIGGER_KEYS, -}); - -export const DatePickerTrigger = createComponent({ - as: "button", - memo: true, - useHook: useDatePickerTrigger, -}); diff --git a/src/datepicker/DateRangePickerState.ts b/src/datepicker/DateRangePickerState.ts deleted file mode 100644 index d6eae664a..000000000 --- a/src/datepicker/DateRangePickerState.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * All credit goes to [React Spectrum](https://github.com/adobe/react-spectrum) - * We improved the Calendar from Stately [useDatePickerState](https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/datepicker/src/useDatePickerState.ts) - * to work with Reakit System - */ - -import * as React from "react"; -import { unstable_useId as useId, useCompositeState } from "reakit"; -import { - RangeValue, - Validation, - ValidationState, - ValueBase, -} from "@react-types/shared"; - -import { useRangeCalendarState } from "../calendar"; -import { makeRange } from "../calendar/helpers"; -import { PickerBaseInitialState, usePickerBaseState } from "../picker-base"; -import { SegmentInitialState, useSegmentState } from "../segment"; -import { - addDays, - toUTCRangeString, - toUTCString, - useControllableState, -} from "../utils"; -import { RangeValueBase } from "../utils/types"; - -export type DateRangePickerInitialState = ValueBase> & - RangeValueBase & - Validation & - PickerBaseInitialState & - Pick, "formatOptions" | "placeholderDate"> & { - /** - * Whether the element should receive focus on render. - */ - autoFocus?: boolean; - }; - -export const useDateRangePickerState = ( - props: DateRangePickerInitialState = {}, -) => { - const { - value: initialValue, - defaultValue = { - start: toUTCString(new Date()), - end: toUTCString(addDays(new Date(), 1)), - }, - onChange, - minValue, - maxValue, - isRequired, - autoFocus, - formatOptions, - placeholderDate, - } = props; - - const [value, setValue] = useControllableState({ - value: initialValue, - defaultValue, - onChange, - }); - - const dateRange: RangeValue = React.useMemo( - () => ({ - start: new Date(value.start), - end: new Date(value.end), - }), - [value.end, value.start], - ); - const minDateValue = minValue ? new Date(minValue) : new Date(-864e13); - const maxDateValue = maxValue ? new Date(maxValue) : new Date(864e13); - - const selectDate = (date: RangeValue) => { - if (props.isReadOnly || props.isDisabled) { - return; - } - - setValue( - toUTCRangeString(makeRange(new Date(date.start), new Date(date.end))), - ); - - popover.hide(); - }; - - const segmentComposite = useCompositeState({ orientation: "horizontal" }); - - const startSegmentState = useSegmentState({ - value: dateRange.start, - defaultValue: new Date(defaultValue.start), - onChange: date => - setValue(toUTCRangeString({ start: date, end: dateRange.end })), - formatOptions, - placeholderDate, - }); - - const endSegmentState = useSegmentState({ - value: dateRange.end, - defaultValue: new Date(defaultValue.end), - onChange: date => - setValue(toUTCRangeString({ start: dateRange.start, end: date })), - formatOptions, - placeholderDate, - }); - - const popover = usePickerBaseState({ - segmentFocus: segmentComposite.first, - ...props, - }); - - const calendar = useRangeCalendarState({ - value: { start: value.start, end: value.end }, - onChange: selectDate, - minValue: minValue, - maxValue: maxValue, - }); - - function isInvalidDateRange(value: Date) { - const min = new Date(minDateValue); - const max = new Date(maxDateValue); - - return value < min || value > max; - } - - const isStartInRange = isInvalidDateRange(dateRange.start); - const isEndInRange = isInvalidDateRange(dateRange.end); - - const validationState: ValidationState = - props.validationState || - (value != null && - (isStartInRange || - isEndInRange || - (value.end != null && value.start != null && value.end < value.start)) - ? "invalid" - : "valid"); - - React.useEffect(() => { - if (popover.visible) { - calendar.setFocused(true); - value.start && calendar.focusCell(new Date(value.start)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [popover.visible]); - - React.useEffect(() => { - if (autoFocus) { - segmentComposite.first(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [autoFocus, segmentComposite.first]); - - const { id: startId } = useId({ baseId: "startsegment" }); - const { id: endId } = useId({ baseId: "endsegment" }); - - return { - dateValue: value, - setDateValue: setValue, - selectDate, - validationState, - minValue, - maxValue, - isRequired, - ...popover, - startSegmentState: { - ...startSegmentState, - ...segmentComposite, - baseId: startId, - }, - endSegmentState: { - ...endSegmentState, - ...segmentComposite, - baseId: endId, - }, - calendar, - isDateRangePicker: true, - }; -}; - -export type DateRangePickerStateReturn = ReturnType< - typeof useDateRangePickerState ->; diff --git a/src/datepicker/__keys.ts b/src/datepicker/__keys.ts deleted file mode 100644 index 0cd428b9f..000000000 --- a/src/datepicker/__keys.ts +++ /dev/null @@ -1,163 +0,0 @@ -// Automatically generated -export const USE_DATE_PICKER_STATE_KEYS = [ - "value", - "defaultValue", - "onChange", - "minValue", - "maxValue", - "validationState", - "isRequired", - "baseId", - "visible", - "animated", - "modal", - "placement", - "unstable_fixed", - "unstable_flip", - "unstable_offset", - "gutter", - "unstable_preventOverflow", - "isDisabled", - "isReadOnly", - "pickerId", - "dialogId", - "segmentFocus", - "formatOptions", - "placeholderDate", - "autoFocus", -] as const; -export const DATE_PICKER_STATE_KEYS = [ - "calendar", - "isDateRangePicker", - "fieldValue", - "setFieldValue", - "segments", - "dateFormatter", - "increment", - "decrement", - "incrementPage", - "decrementPage", - "setSegment", - "confirmPlaceholder", - "baseId", - "unstable_idCountRef", - "setBaseId", - "unstable_virtual", - "rtl", - "orientation", - "items", - "groups", - "currentId", - "loop", - "wrap", - "shift", - "unstable_moves", - "unstable_hasActiveWidget", - "unstable_includesBaseElement", - "registerItem", - "unregisterItem", - "registerGroup", - "unregisterGroup", - "move", - "next", - "previous", - "up", - "down", - "first", - "last", - "sort", - "unstable_setVirtual", - "setRTL", - "setOrientation", - "setCurrentId", - "setLoop", - "setWrap", - "setShift", - "reset", - "unstable_setIncludesBaseElement", - "unstable_setHasActiveWidget", - "visible", - "animated", - "animating", - "show", - "hide", - "toggle", - "setVisible", - "setAnimated", - "stopAnimation", - "modal", - "unstable_disclosureRef", - "setModal", - "unstable_referenceRef", - "unstable_popoverRef", - "unstable_arrowRef", - "unstable_popoverStyles", - "unstable_arrowStyles", - "unstable_originalPlacement", - "unstable_update", - "placement", - "place", - "pickerId", - "dialogId", - "isDisabled", - "isReadOnly", - "segmentFocus", - "dateValue", - "setDateValue", - "selectDate", - "validationState", - "minValue", - "maxValue", - "isRequired", -] as const; -export const USE_DATE_RANGE_PICKER_STATE_KEYS = USE_DATE_PICKER_STATE_KEYS; -export const DATE_RANGE_PICKER_STATE_KEYS = [ - "startSegmentState", - "endSegmentState", - "calendar", - "isDateRangePicker", - "baseId", - "unstable_idCountRef", - "visible", - "animated", - "animating", - "setBaseId", - "show", - "hide", - "toggle", - "setVisible", - "setAnimated", - "stopAnimation", - "modal", - "unstable_disclosureRef", - "setModal", - "unstable_referenceRef", - "unstable_popoverRef", - "unstable_arrowRef", - "unstable_popoverStyles", - "unstable_arrowStyles", - "unstable_originalPlacement", - "unstable_update", - "placement", - "place", - "pickerId", - "dialogId", - "isDisabled", - "isReadOnly", - "segmentFocus", - "dateValue", - "setDateValue", - "selectDate", - "validationState", - "minValue", - "maxValue", - "isRequired", -] as const; -export const DATE_PICKER_KEYS = [ - ...DATE_PICKER_STATE_KEYS, - ...DATE_RANGE_PICKER_STATE_KEYS, -] as const; -export const DATE_PICKER_CONTENT_KEYS = DATE_PICKER_KEYS; -export const DATE_PICKER_SEGMENT_KEYS = DATE_PICKER_CONTENT_KEYS; -export const DATE_PICKER_SEGMENT_FIELD_KEYS = DATE_PICKER_SEGMENT_KEYS; -export const DATE_PICKER_TRIGGER_KEYS = DATE_PICKER_SEGMENT_FIELD_KEYS; diff --git a/src/datepicker/__tests__/DatePicker.test.tsx b/src/datepicker/__tests__/DatePicker.test.tsx deleted file mode 100644 index f1d3638d0..000000000 --- a/src/datepicker/__tests__/DatePicker.test.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import * as React from "react"; -import { axe, press, render, screen } from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; - -import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarStateReturn, - CalendarWeekTitle, -} from "../../calendar"; -import { addWeeks, subWeeks, toUTCString } from "../../utils"; -import { repeat } from "../../utils/test-utils"; -import { - DatePicker, - DatePickerContent, - DatePickerInitialState, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, - useDatePickerState, -} from ".."; - -/* -// Mocking useId otherwise snapshots will change each time -// since useCalendarState uses useId. -jest.spyOn(reakit, "unstable_useId").mockImplementation(options => ({ - id: options.baseId + "myid" -})); -*/ -afterEach(cleanup); - -export const CalendarComp: React.FC = state => { - return ( - - - - {"<"} - - - {"<<"} - - - - {">>"} - - - {">"} - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - - ); -}; - -const DatePickerComp: React.FC = props => { - const state = useDatePickerState({ - baseId: "calendar", - dialogId: "dialog", - pickerId: "picker", - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); - - return ( - <> - - - - {state.segments.map((segment, i) => ( - - ))} - - - open - - - - - - > - ); -}; - -const openDatePicker = () => { - press.Tab(); - expect(screen.getByLabelText("month", { selector: "div" })).toHaveFocus(); - - press.ArrowDown(null, { altKey: true }); - expect(screen.getByTestId("testid-datepicker-content")).toBeVisible(); -}; - -describe("DatePicker", () => { - it("should open/close the datepicker", () => { - render(); - - const datepickerContent = screen.getByTestId("testid-datepicker-content"); - const segment = screen.getByTestId("testid-segment"); - const month = screen.getByRole("spinbutton", { - name: /month/i, - }); - - expect(segment).toHaveTextContent("11/01/2020"); - expect(datepickerContent).not.toBeVisible(); - - // open - openDatePicker(); - - // close - press.Escape(); - expect(datepickerContent).not.toBeVisible(); - expect(month).toHaveFocus(); - }); - - it("should be able to open and select date", () => { - render(); - - const segment = screen.getByTestId("testid-segment"); - const datepickerContent = screen.getByTestId("testid-datepicker-content"); - const month = screen.getByRole("spinbutton", { - name: /month/i, - }); - - // open - openDatePicker(); - - // assert focused date on calendar - expect( - screen.getByLabelText(/Sunday, November 1, 2020 selected/i), - ).toHaveFocus(); - - // go to 24 - repeat(press.ArrowDown, 3); - repeat(press.ArrowRight, 2); - - expect(screen.getByLabelText(/Tuesday, November 24, 2020/i)).toHaveFocus(); - - press.Enter(); - expect(datepickerContent).not.toBeVisible(); - expect(month).toHaveFocus(); - expect(segment).toHaveTextContent("11/24/2020"); - }); - - it("should be able to open and select date and jump to different dates", () => { - render(); - const segment = screen.getByTestId("testid-segment"); - const calendarHeader = screen.getByTestId("testid-calendar-header"); - const datepickerContent = screen.getByTestId("testid-datepicker-content"); - const month = screen.getByRole("spinbutton", { - name: /month/i, - }); - - // open - openDatePicker(); - - // assert focused date on calendar - expect( - screen.getByLabelText(/^Sunday, November 1, 2020 selected$/i), - ).toHaveFocus(); - - // jump month - expect(calendarHeader).toHaveTextContent(/November 2020/i); - repeat(press.PageDown, 2); - - expect(calendarHeader).toHaveTextContent(/January 2021/i); - - // jump year - expect(calendarHeader).toHaveTextContent(/January 2021/i); - repeat(() => { - press.PageDown(null, { shiftKey: true }); - }, 2); - expect(calendarHeader).toHaveTextContent(/January 2023/i); - - press.Enter(); - expect(datepickerContent).not.toBeVisible(); - expect(month).toHaveFocus(); - expect(segment).toHaveTextContent("01/01/2023"); - }); - - it("should work with AutoFocus", () => { - render( - // eslint-disable-next-line jsx-a11y/no-autofocus - , - ); - - expect( - screen.getByRole("spinbutton", { - name: /month/i, - }), - ).toHaveFocus(); - }); - - it("should be invalid on out of range value", () => { - render( - , - ); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-invalid", - "true", - ); - }); - - it("should be disabled", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-disabled", - "true", - ); - }); - - it("should be readonly", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-readonly", - "true", - ); - }); - - test("DatePicker renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container, { - rules: { "nested-interactive": { enabled: false } }, - }); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/datepicker/__tests__/DateRangePicker.test.tsx b/src/datepicker/__tests__/DateRangePicker.test.tsx deleted file mode 100644 index af2dd297a..000000000 --- a/src/datepicker/__tests__/DateRangePicker.test.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import * as React from "react"; -import { axe, fireEvent, press, render, screen } from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; - -import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - RangeCalendarStateReturn, -} from "../../calendar"; -import { - isEndSelection, - isInSelectionRange, - isStartSelection, - repeat, -} from "../../utils/test-utils"; -import { - DateRangePickerInitialState, - useDateRangePickerState, -} from "../DateRangePickerState"; -import { - DatePicker, - DatePickerContent, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, -} from "../index"; - -afterEach(cleanup); - -const RangeCalendarComp: React.FC = state => { - return ( - - - - {"<<"} - - - {"<"} - - - - {">"} - - - {">>"} - - - - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - - - ); -}; - -const DateRangePickerComp: React.FC = props => { - const state = useDateRangePickerState({ - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); - - return ( - <> - - - - {state.startSegmentState.segments.map((segment, i) => ( - - ))} - - - - - {state.endSegmentState.segments.map((segment, i) => ( - - ))} - - open - - - - - - > - ); -}; - -const openDatePicker = () => { - fireEvent.click(screen.getByText(/open/i)); - - jest.advanceTimersByTime(1); - - expect(screen.getByTestId("testid-datepicker-content")).toBeVisible(); -}; - -describe("DateRangePicker", () => { - it("should select date ranges correctly", () => { - jest.useFakeTimers(); - - render( - , - ); - - openDatePicker(); - - expect( - screen.getByLabelText(/Sunday, November 15, 2020 selected/), - ).toHaveFocus(); - - isStartSelection( - screen.getByLabelText(/Sunday, November 15, 2020 selected/i), - ); - // check if current date is selected - isEndSelection( - screen.getByLabelText(/Sunday, November 15, 2020 selected/i), - ); - isInSelectionRange( - screen.getByLabelText(/Sunday, November 15, 2020 selected/i), - ); - - // change date selection - press.Enter(); - press.ArrowRight(); - press.ArrowRight(); - press.ArrowDown(); - - expect(screen.getByLabelText(/Tuesday, November 24, 2020/gi)).toHaveFocus(); - - isEndSelection(screen.getByLabelText(/Tuesday, November 24, 2020/gi)); - isStartSelection(screen.getByLabelText(/Sunday, November 15, 2020/gi)); - isInSelectionRange(screen.getByLabelText(/Wednesday, November 18, 2020/gi)); - - // Finish selection - press.Enter(); - expect(screen.getByTestId("testid-datepicker-content")).not.toBeVisible(); - expect(screen.getByTestId("testid-segment")).toHaveTextContent( - "11/15/2020 - 11/24/2020", - ); - - jest.useRealTimers(); - }); - - it("should be invalid on wrong date selection", () => { - render( - , - ); - - const datepicker = screen.getByTestId("testid-datepicker"); - - expect(datepicker).not.toHaveAttribute("aria-invalid"); - - // reverse dates are invalid - repeat(press.Tab, 4); - repeat(press.ArrowDown, 2); - - expect(document.activeElement).toHaveTextContent("09"); - expect(datepicker).toHaveAttribute("aria-invalid", "true"); - }); - - it("should be invalid if selection range is out of min max values", () => { - render( - , - ); - const datepicker = screen.getByTestId("testid-datepicker"); - - expect(datepicker).not.toHaveAttribute("aria-invalid"); - - repeat(press.Tab, 2); - press.ArrowUp(); - - expect(document.activeElement).toHaveTextContent("16"); - expect(datepicker).toHaveAttribute("aria-invalid", "true"); - }); - - it("should be disabled", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-disabled", - "true", - ); - }); - - it("should be readonly", () => { - render(); - - expect(screen.getByTestId("testid-datepicker")).toHaveAttribute( - "aria-readonly", - "true", - ); - }); - - it("should work with AutoFocus", () => { - render( - // eslint-disable-next-line jsx-a11y/no-autofocus - , - ); - - expect( - screen.getAllByLabelText("month", { selector: "div" })[0], - ).toHaveFocus(); - }); - - test("DateRangePicker renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container, { - rules: { "nested-interactive": { enabled: false } }, - }); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/datepicker/datepicker-base-state.ts b/src/datepicker/datepicker-base-state.ts new file mode 100644 index 000000000..316c15fd7 --- /dev/null +++ b/src/datepicker/datepicker-base-state.ts @@ -0,0 +1,32 @@ +import { DatePickerState, useDatePickerState } from "@react-stately/datepicker"; +import { DatePickerProps, DateValue } from "@react-types/datepicker"; +import { PopoverState, PopoverStateProps, usePopoverState } from "ariakit"; + +export function useDatePickerBaseState( + props: DatePickerBaseStateProps, +): DatePickerBaseState { + const datepicker = useDatePickerState(props); + const { isOpen, setOpen } = datepicker; + + const popover = usePopoverState({ + visible: isOpen, + setVisible: setOpen, + ...props, + }); + + return { datepicker, popover }; +} + +export type DatePickerBaseState = { + datepicker: DatePickerState; + popover: PopoverState; +}; + +export type DatePickerBaseStateProps = DatePickerProps & + PopoverStateProps & { + /** + * Determines whether the date picker popover should close automatically when a date is selected. + * @default true + */ + shouldCloseOnSelect?: boolean | (() => boolean); + }; diff --git a/src/datepicker/datepicker-disclosure.ts b/src/datepicker/datepicker-disclosure.ts new file mode 100644 index 000000000..6a8437f28 --- /dev/null +++ b/src/datepicker/datepicker-disclosure.ts @@ -0,0 +1,46 @@ +import { useRef } from "react"; +import { useButton } from "@react-aria/button"; +import { usePopoverDisclosure } from "ariakit"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DatePickerState } from "./datepicker-state"; +import { DateRangePickerState } from "./daterangepicker-state"; + +export const useDatePickerDisclosure = createHook( + ({ state, ...props }) => { + const ref = useRef(null); + props = { ...props, ref: useForkRef(ref, props.ref, state.ref) }; + + props = usePopoverDisclosure({ ...props, state: state.baseState.popover }); + + const { buttonProps } = useButton(state.buttonProps, ref); + props = { ...props, ...buttonProps }; + + return props; + }, +); + +export const DatePickerDisclosure = + createComponent(props => { + const htmlProps = useDatePickerDisclosure(props); + + return createElement("button", htmlProps); + }); + +export type DatePickerDisclosureOptions = + Options & { + /** + * Object returned by the `useDatePickerDisclosureState` hook. + */ + state: DatePickerState | DateRangePickerState; + }; + +export type DatePickerDisclosureProps = Props< + DatePickerDisclosureOptions +>; diff --git a/src/datepicker/datepicker-group.ts b/src/datepicker/datepicker-group.ts new file mode 100644 index 000000000..e89212d83 --- /dev/null +++ b/src/datepicker/datepicker-group.ts @@ -0,0 +1,39 @@ +import { mergeProps } from "@react-aria/utils"; +import { useForkRef } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DatePickerState } from "./datepicker-state"; +import { DateRangePickerState } from "./daterangepicker-state"; + +export const useDatePickerGroup = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.ref, props.ref) }; + props = mergeProps(state.groupProps, props); + + return props; + }, +); + +export const DatePickerGroup = createComponent( + props => { + const htmlProps = useDatePickerGroup(props); + + return createElement("div", htmlProps); + }, +); + +export type DatePickerGroupOptions = Options & { + /** + * Object returned by the `useDatePickerState` hook. + */ + state: DatePickerState | DateRangePickerState; +}; + +export type DatePickerGroupProps = Props< + DatePickerGroupOptions +>; diff --git a/src/datepicker/datepicker-label.ts b/src/datepicker/datepicker-label.ts new file mode 100644 index 000000000..30a18b3d7 --- /dev/null +++ b/src/datepicker/datepicker-label.ts @@ -0,0 +1,37 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { DatePickerState } from "./datepicker-state"; +import { DateRangePickerState } from "./daterangepicker-state"; + +export const useDatePickerLabel = createHook
@@ -142,7 +134,7 @@ export const Accordion: React.FC = props => {