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`] = ` - -
-

- -

- -

- -

- -

- -

- -

- -

- -
-
-`; 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 ( -
- - -

- 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 ( - -
    -
  1. - - WAI-ARIA Authoring Practices 1.1 - -
  2. -
  3. - - Design Patterns - -
  4. -
  5. - - Breadcrumb Pattern - -
  6. -
  7. - - Breadcrumb Example - -
  8. -
-
- ); -}; - -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`] = ` - - - -`; 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 ( - +
  1. @@ -21,7 +20,7 @@ export const Breadcrumbs = () => {
  2. 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} + + + ); + })} + + + + {[...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 ( + + + + ); +}; 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} + + + ); + })} + + + + {[...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 ( + + + + ); +}; 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} + + + ); + })} + + + + {[...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 ( + + + + ); +}; 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} + + + ); + })} + + + + {[...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 ( + + + + ); +}; 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( + ({ state, ...props }) => { + props = mergeProps(state.labelProps, props); + + return props; + }, +); + +export const DatePickerLabel = createComponent( + props => { + const htmlProps = useDatePickerLabel(props); + + return createElement("span", htmlProps); + }, +); + +export type DatePickerLabelOptions = Options & { + /** + * Object returned by the `useDatePickerState` hook. + */ + state: DatePickerState | DateRangePickerState; +}; + +export type DatePickerLabelProps = Props< + DatePickerLabelOptions +>; diff --git a/src/datepicker/datepicker-popover.ts b/src/datepicker/datepicker-popover.ts new file mode 100644 index 000000000..5d0542b07 --- /dev/null +++ b/src/datepicker/datepicker-popover.ts @@ -0,0 +1,57 @@ +import { mergeProps } from "@react-aria/utils"; +import { usePopover } from "ariakit"; +import { useEvent } 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 useDatePickerPopover = createHook( + ({ state, ...props }) => { + const onKeyDownProp = props.onKeyDown; + + const onKeyDown = useEvent((event: React.KeyboardEvent) => { + onKeyDownProp?.(event); + + if (event.key !== "Escape") return; + state.baseState.popover.hide(); + + if (event.defaultPrevented) return; + }); + + props = usePopover({ + ...props, + state: state.baseState.popover, + modal: true, + autoFocusOnShow: false, + backdropProps: { onKeyDown }, + }); + props = mergeProps(state.dialogProps, props); + + return props; + }, +); + +export const DatePickerPopover = createComponent( + props => { + const htmlProps = useDatePickerPopover(props); + + return createElement("span", htmlProps); + }, +); + +export type DatePickerPopoverOptions = Options & { + /** + * Object returned by the `useDatePickerState` hook. + */ + state: DatePickerState | DateRangePickerState; +}; + +export type DatePickerPopoverProps = Props< + DatePickerPopoverOptions +>; diff --git a/src/datepicker/datepicker-state.ts b/src/datepicker/datepicker-state.ts new file mode 100644 index 000000000..e6a5d138d --- /dev/null +++ b/src/datepicker/datepicker-state.ts @@ -0,0 +1,34 @@ +import { RefObject, useRef } from "react"; +import { DateValue } from "@internationalized/date"; +import { DatePickerAria, useDatePicker } from "@react-aria/datepicker"; +import { AriaDatePickerProps } from "@react-types/datepicker"; + +import { DatePickerBaseState } from "./datepicker-base-state"; + +export function useDatePickerState({ + state, + ...props +}: DatePickerStateProps): DatePickerState { + const ref = useRef(null); + const datepicker = useDatePicker(props, state.datepicker, ref); + + return { ...datepicker, ref, baseState: state }; +} + +export type DatePickerState = DatePickerAria & { + /** + * Reference for the date picker's visible label element, if any. + */ + ref: RefObject; + /** + * Object returned by the `useDatePickerBaseState` hook. + */ + baseState: DatePickerBaseState; +}; + +export type DatePickerStateProps = AriaDatePickerProps & { + /** + * Object returned by the `useDatePickerBaseState` hook. + */ + state: DatePickerBaseState; +}; diff --git a/src/datepicker/daterangepicker-base-state.ts b/src/datepicker/daterangepicker-base-state.ts new file mode 100644 index 000000000..7e4c8b901 --- /dev/null +++ b/src/datepicker/daterangepicker-base-state.ts @@ -0,0 +1,35 @@ +import { + DateRangePickerState, + useDateRangePickerState, +} from "@react-stately/datepicker"; +import { DateRangePickerProps, DateValue } from "@react-types/datepicker"; +import { PopoverState, PopoverStateProps, usePopoverState } from "ariakit"; + +export function useDateRangePickerBaseState( + props: DateRangePickerBaseStateProps, +): DateRangePickerBaseState { + const datepicker = useDateRangePickerState(props); + const { isOpen, setOpen } = datepicker; + + const popover = usePopoverState({ + visible: isOpen, + setVisible: setOpen, + ...props, + }); + + return { datepicker, popover }; +} + +export type DateRangePickerBaseState = { + datepicker: DateRangePickerState; + popover: PopoverState; +}; + +export type DateRangePickerBaseStateProps = DateRangePickerProps & + 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/daterangepicker-state.ts b/src/datepicker/daterangepicker-state.ts new file mode 100644 index 000000000..25a6fdc32 --- /dev/null +++ b/src/datepicker/daterangepicker-state.ts @@ -0,0 +1,34 @@ +import { RefObject, useRef } from "react"; +import { DateValue } from "@internationalized/date"; +import { + DateRangePickerAria, + useDateRangePicker, +} from "@react-aria/datepicker"; +import { AriaDateRangePickerProps } from "@react-types/datepicker"; + +import { DateRangePickerBaseState } from "./daterangepicker-base-state"; + +export function useDateRangePickerState({ + state, + ...props +}: DateRangePickerStateProps): DateRangePickerState { + const ref = useRef(null); + const datepicker = useDateRangePicker(props, state.datepicker, ref); + + return { ...datepicker, ref, baseState: state }; +} + +export type DateRangePickerState = DateRangePickerAria & { + /** + * Reference for the date picker's visible label element, if any. + */ + ref: RefObject; + baseState: DateRangePickerBaseState; +}; + +export type DateRangePickerStateProps = AriaDateRangePickerProps & { + /** + * Object returned by the `useDatePickerBaseState` hook. + */ + state: DateRangePickerBaseState; +}; diff --git a/src/datepicker/helpers.ts b/src/datepicker/helpers.ts deleted file mode 100644 index 279957790..000000000 --- a/src/datepicker/helpers.ts +++ /dev/null @@ -1,10 +0,0 @@ -export function setTime(date: Date, time: Date) { - if (!date || !time) { - return; - } - - date.setHours(time.getHours()); - date.setMinutes(time.getMinutes()); - date.setSeconds(time.getSeconds()); - date.setMilliseconds(time.getMilliseconds()); -} diff --git a/src/datepicker/index.ts b/src/datepicker/index.ts index a34fca007..bd3b1f553 100644 --- a/src/datepicker/index.ts +++ b/src/datepicker/index.ts @@ -1,8 +1,8 @@ -export * from "./__keys"; -export * from "./DatePicker"; -export * from "./DatePickerContent"; -export * from "./DatePickerSegment"; -export * from "./DatePickerSegmentField"; -export * from "./DatePickerState"; -export * from "./DatePickerTrigger"; -export * from "./DateRangePickerState"; +export * from "./datepicker-base-state"; +export * from "./datepicker-disclosure"; +export * from "./datepicker-group"; +export * from "./datepicker-label"; +export * from "./datepicker-popover"; +export * from "./datepicker-state"; +export * from "./daterangepicker-base-state"; +export * from "./daterangepicker-state"; diff --git a/src/datepicker/stories/DatePickerBasic.component.tsx b/src/datepicker/stories/DatePickerBasic.component.tsx index 77754d877..42ea00d4d 100644 --- a/src/datepicker/stories/DatePickerBasic.component.tsx +++ b/src/datepicker/stories/DatePickerBasic.component.tsx @@ -1,115 +1,57 @@ -import * as React from "react"; +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import CalendarBasic from "../../calendar/stories/CalendarBasic.component"; +import DateFieldBasic from "../../datefield/stories/DateFieldBasic.component"; import { - Calendar as CalendarWrapper, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarStateReturn, - CalendarWeekTitle, - DatePicker, - DatePickerContent, - DatePickerInitialState, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, + DatePickerBaseStateProps, + DatePickerDisclosure, + DatePickerGroup, + DatePickerPopover, + useDatePickerBaseState, useDatePickerState, } from "../../index"; -import { - CalendarIcon, - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; - -export const Datepicker: React.FC = props => { - const state = useDatePickerState({ - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); - - return ( - <> - -
- - {state.segments.map((segment, i) => ( - - ))} - +import { CalendarIcon } from "./Utils.component"; - - - -
-
- - - - - ); -}; +export type DatePickerBasicProps = DatePickerBaseStateProps & {}; -export default Datepicker; +export const DatePickerBasic: React.FC = props => { + const { locale } = useLocale(); + const state = useDatePickerBaseState({ ...props, gutter: 10 }); + const datepicker = useDatePickerState({ ...props, state }); -const Calendar: React.FC = state => { return ( - -
- - - - - - - - - - - - - -
- - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - -
+
+ + + + + + {state.popover.visible && ( + + + + )} + +
); }; + +export default DatePickerBasic; diff --git a/src/datepicker/stories/DatePickerBasic.css b/src/datepicker/stories/DatePickerBasic.css index 4e2db08f1..894a991d0 100644 --- a/src/datepicker/stories/DatePickerBasic.css +++ b/src/datepicker/stories/DatePickerBasic.css @@ -10,6 +10,10 @@ border: none; } +.datepicker__trigger:focus-visible { + outline: none; +} + .datepicker__trigger svg { fill: #43424d; width: 20px; @@ -90,30 +94,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; @@ -141,7 +139,7 @@ text-align: center; } -.calendar .dates th abbr { +.calendar .dates th span { text-decoration: none; } @@ -165,7 +163,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; @@ -195,6 +193,7 @@ .calendar [aria-disabled="true"] { color: #959595; } + .calendar [data-weekend] { color: #fa3131; } diff --git a/src/datepicker/stories/DatePickerBasic.stories.tsx b/src/datepicker/stories/DatePickerBasic.stories.tsx index 9061ccfca..81592c411 100644 --- a/src/datepicker/stories/DatePickerBasic.stories.tsx +++ b/src/datepicker/stories/DatePickerBasic.stories.tsx @@ -1,84 +1,26 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { addDays, addWeeks, subWeeks, toUTCString } from "../../utils/index"; import css from "./templates/DatePickerBasicCss"; import js from "./templates/DatePickerBasicJsx"; import ts from "./templates/DatePickerBasicTsx"; -import jsUtils from "./templates/UtilsJsx"; -import tsUtils from "./templates/UtilsTsx"; -import DatePicker from "./DatePickerBasic.component"; +import { DatePickerBasic } from "./DatePickerBasic.component"; import "./DatePickerBasic.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: DatePicker, title: "DatePicker/Basic", - argTypes: { - defaultValue: { control: "date" }, - value: { control: "date" }, - minValue: { control: "date" }, - maxValue: { control: "date" }, - }, + component: DatePickerBasic, parameters: { layout: "centered", - preview: createPreviewTabs({ js, ts, css, jsUtils, tsUtils }), + preview: createPreviewTabs({ js, ts, css }), }, } as Meta; -export const Default: Story = args => { - return ; -}; - -export const DefaultDate = Default.bind({}); -DefaultDate.args = { - defaultValue: toUTCString(addDays(new Date(), 2)), -}; - -export const MinMaxDate = Default.bind({}); -MinMaxDate.args = { - defaultValue: toUTCString(addDays(new Date(), 2)), - minValue: toUTCString(new Date()), - maxValue: toUTCString(addWeeks(new Date(), 2)), -}; - -export const InValidDate = Default.bind({}); -InValidDate.args = { - defaultValue: toUTCString(addWeeks(new Date(), 2)), - minValue: toUTCString(subWeeks(new Date(), 1)), - maxValue: toUTCString(addWeeks(new Date(), 1)), -}; - -export const Disabled = Default.bind({}); -Disabled.args = { defaultValue: toUTCString(new Date()), isDisabled: true }; - -export const Readonly = Default.bind({}); -Readonly.args = { defaultValue: toUTCString(new Date()), isReadOnly: true }; - -export const AutoFocus = Default.bind({}); -AutoFocus.args = { defaultValue: toUTCString(new Date()), autoFocus: true }; - -export const FormatOptions = Default.bind({}); -FormatOptions.args = { - defaultValue: toUTCString(new Date()), - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, -}; - -export const ControllableInput = () => { - const [value, setValue] = React.useState("2020-10-13"); - - return ( -
- setValue(e.target.value)} - value={value} - /> -
-
- -
- ); +export const Default: Story = { + args: { label: "DatePicker" }, }; diff --git a/src/datepicker/stories/DatePickerStyled.component.tsx b/src/datepicker/stories/DatePickerStyled.component.tsx index d369607e3..e5501c84e 100644 --- a/src/datepicker/stories/DatePickerStyled.component.tsx +++ b/src/datepicker/stories/DatePickerStyled.component.tsx @@ -1,155 +1,57 @@ -import * as React from "react"; +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import CalendarStyled from "../../calendar/stories/CalendarStyled.component"; +import DateFieldStyled from "../../datefield/stories/DateFieldStyled.component"; import { - Calendar as CalendarWrapper, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarStateReturn, - CalendarWeekTitle, - DatePicker as DatePickerWrapper, - DatePickerContent, - DatePickerInitialState, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, + DatePickerBaseStateProps, + DatePickerDisclosure, + DatePickerGroup, + DatePickerPopover, + useDatePickerBaseState, useDatePickerState, } from "../../index"; -import { - CalendarStyledIcon, - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; - -export const DatePicker: React.FC = props => { - const state = useDatePickerState({ - gutter: 0, - unstable_offset: [-19, 10], - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); +import { CalendarStyledIcon } from "./Utils.component"; - return ( - <> - -
- - {state.segments.map((segment, i) => ( - - ))} - - - - - -
-
- - - - - ); -}; +export type DatePickerStyledProps = DatePickerBaseStateProps & {}; -export default DatePicker; +export const DatePickerStyled: React.FC = props => { + const { locale } = useLocale(); + const state = useDatePickerBaseState({ ...props, gutter: 10 }); + const datepicker = useDatePickerState({ ...props, state }); -export const Calendar: React.FC = state => { return ( - -
- - - - - - - + + - - - - - - -
- - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr.slice(0, 2)} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - -
+ + + {state.popover.visible && ( + + + + )} + +
); }; + +export default DatePickerStyled; diff --git a/src/datepicker/stories/DatePickerStyled.stories.tsx b/src/datepicker/stories/DatePickerStyled.stories.tsx index d8c98107a..bb9ae71d1 100644 --- a/src/datepicker/stories/DatePickerStyled.stories.tsx +++ b/src/datepicker/stories/DatePickerStyled.stories.tsx @@ -1,22 +1,23 @@ import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; -import { DatePicker } from "./DatePickerStyled.component"; +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/DatePickerStyledJsx"; +import ts from "./templates/DatePickerStyledTsx"; +import { DatePickerStyled } from "./DatePickerStyled.component"; import "./tailwind.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: DatePicker, title: "DatePicker/Styled", + component: DatePickerStyled, parameters: { layout: "centered", - options: { showPanel: false }, - }, - argTypes: { - defaultValue: { control: "date", defaultValue: new Date() }, - value: { control: "date" }, - minValue: { control: "date" }, - maxValue: { control: "date" }, + preview: createPreviewTabs({ js, ts }), }, decorators: [ Story => { @@ -26,4 +27,6 @@ export default { ], } as Meta; -export const Default: Story = args => ; +export const Default: Story = { + args: { label: "DatePicker" }, +}; diff --git a/src/datepicker/stories/DateRangePickerBasic.component.tsx b/src/datepicker/stories/DateRangePickerBasic.component.tsx index ee6fb6643..1bd85e3cc 100644 --- a/src/datepicker/stories/DateRangePickerBasic.component.tsx +++ b/src/datepicker/stories/DateRangePickerBasic.component.tsx @@ -1,135 +1,66 @@ import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import CalendarRange from "../../calendar/stories/CalendarRange.component"; +import DateFieldBasic from "../../datefield/stories/DateFieldBasic.component"; import { - Calendar, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarWeekTitle, - DatePicker, - DatePickerContent, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, - DateRangePickerInitialState, - RangeCalendarStateReturn, + DatePickerDisclosure, + DatePickerGroup, + DatePickerPopover, + DateRangePickerBaseStateProps, + useDateRangePickerBaseState, useDateRangePickerState, } from "../../index"; -import { - CalendarIcon, - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; +import DatePickerBasic from "./DatePickerBasic.component"; +import { CalendarIcon } from "./Utils.component"; -export const DateRangePicker: React.FC = props => { - const state = useDateRangePickerState({ - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); +export type DateRangePickerBasicProps = DateRangePickerBaseStateProps & {}; - return ( - <> - -
- - {state.startSegmentState.segments.map((segment, i) => ( - - ))} - -  -  - - {state.endSegmentState.segments.map((segment, i) => ( - - ))} - - - - -
-
- - - - - ); -}; +export const DateRangePickerBasic: React.FC< + DateRangePickerBasicProps +> = props => { + const { locale } = useLocale(); + const state = useDateRangePickerBaseState({ ...props, gutter: 10 }); + const daterangepicker = useDateRangePickerState({ ...props, state }); -export default DatePicker; - -const RangeCalendar: React.FC = state => { return ( - -
- - - - - - - - - - - - - -
- - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - -
+
+ + + ); }; + +export default DatePickerBasic; diff --git a/src/datepicker/stories/DateRangePickerBasic.css b/src/datepicker/stories/DateRangePickerBasic.css index db5e10c39..cf19cd6a1 100644 --- a/src/datepicker/stories/DateRangePickerBasic.css +++ b/src/datepicker/stories/DateRangePickerBasic.css @@ -15,6 +15,14 @@ width: 20px; } +.datepicker__trigger:focus-visible { + outline: none; +} + +.datepicker__dash:before { + content: "-"; +} + .datepicker__header { padding: 0; border-radius: 4px; @@ -37,6 +45,8 @@ .datepicker__field { font-family: monospace; display: flex; + padding-left: 4px; + padding-right: 4px; } .datepicker__field--item { @@ -143,7 +153,7 @@ text-align: center; } -.calendar-range .dates th abbr { +.calendar-range .dates th span { text-decoration: none; } diff --git a/src/datepicker/stories/DateRangePickerBasic.stories.tsx b/src/datepicker/stories/DateRangePickerBasic.stories.tsx index f88937b64..111cc636a 100644 --- a/src/datepicker/stories/DateRangePickerBasic.stories.tsx +++ b/src/datepicker/stories/DateRangePickerBasic.stories.tsx @@ -1,99 +1,33 @@ import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { addDays, addWeeks, subDays, subWeeks, toUTCString } from "../../utils"; -import css from "./templates/DatePickerBasicCss"; -import js from "./templates/DatePickerBasicJsx"; -import ts from "./templates/DatePickerBasicTsx"; -import jsUtils from "./templates/UtilsJsx"; -import tsUtils from "./templates/UtilsTsx"; -import { DateRangePicker } from "./DateRangePickerBasic.component"; +import css from "./templates/DateRangePickerBasicCss"; +import js from "./templates/DateRangePickerBasicJsx"; +import ts from "./templates/DateRangePickerBasicTsx"; +import { DateRangePickerBasic } from "./DateRangePickerBasic.component"; import "./DateRangePickerBasic.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - title: "DateRangePicker/Basic", - component: DateRangePicker, - argTypes: { - minValue: { control: "date" }, - maxValue: { control: "date" }, - }, + title: "DatePicker/Range", + component: DateRangePickerBasic, parameters: { layout: "centered", - preview: createPreviewTabs({ js, ts, css, jsUtils, tsUtils }), + preview: createPreviewTabs({ js, ts, css }), }, + decorators: [ + Story => { + document.body.id = "date-range-picker-basic"; + return ; + }, + ], } 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 MinMaxValue = Default.bind({}); -MinMaxValue.args = { - defaultValue: { - start: toUTCString(new Date()), - end: toUTCString(addWeeks(new Date(), 1)), - }, - minValue: toUTCString(subWeeks(new Date(), 1)), - maxValue: toUTCString(addWeeks(new Date(), 2)), -}; - -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))); - - return ( -
- setStart(e.target.value)} - value={start} - /> - setEnd(e.target.value)} value={end} /> - { - setStart(start); - setEnd(end); - }} - /> -
- ); +export const Default: Story = { + args: { label: "DateRangePicker" }, }; diff --git a/src/datepicker/stories/DateRangePickerStyled.component.tsx b/src/datepicker/stories/DateRangePickerStyled.component.tsx index db01bbdca..b796ddc86 100644 --- a/src/datepicker/stories/DateRangePickerStyled.component.tsx +++ b/src/datepicker/stories/DateRangePickerStyled.component.tsx @@ -1,166 +1,66 @@ -import * as React from "react"; +import React from "react"; +import { createCalendar } from "@internationalized/date"; +import { useLocale } from "@react-aria/i18n"; +import CalendarRangeStyled from "../../calendar/stories/CalendarRangeStyled.component"; +import DateFieldStyled from "../../datefield/stories/DateFieldStyled.component"; import { - Calendar as CalendarWrapper, - CalendarButton, - CalendarCell, - CalendarCellButton, - CalendarGrid, - CalendarHeader, - CalendarStateReturn, - CalendarWeekTitle, - DatePicker as DatePickerWrapper, - DatePickerContent, - DatePickerSegment, - DatePickerSegmentField, - DatePickerTrigger, - DateRangePickerInitialState, + DatePickerDisclosure, + DatePickerGroup, + DatePickerPopover, + DateRangePickerBaseStateProps, + useDateRangePickerBaseState, useDateRangePickerState, } from "../../index"; -import { - CalendarStyledIcon, - ChevronLeft, - ChevronRight, - DoubleChevronLeft, - DoubleChevronRight, -} from "./Utils.component"; +import DatePickerStyled from "./DatePickerStyled.component"; +import { CalendarStyledIcon } from "./Utils.component"; -export const DateRangePicker: React.FC = props => { - const state = useDateRangePickerState({ - formatOptions: { month: "2-digit", day: "2-digit", year: "numeric" }, - ...props, - }); +export type DateRangePickerStyledProps = DateRangePickerBaseStateProps & {}; - return ( - <> - -
- - {state.startSegmentState.segments.map((segment, i) => ( - - ))} - -  -  - - {state.endSegmentState.segments.map((segment, i) => ( - - ))} - - - - -
-
- - - - - ); -}; +export const DateRangePickerStyled: React.FC< + DateRangePickerStyledProps +> = props => { + const { locale } = useLocale(); + const state = useDateRangePickerBaseState({ ...props, gutter: 10 }); + const daterangepicker = useDateRangePickerState({ ...props, state }); -export const Calendar: React.FC = state => { return ( - -
- - - - - - - + + - - - - -
- - - - - {state.weekDays.map((day, dayIndex) => { - return ( - - {day.abbr.slice(0, 2)} - - ); - })} - - - - {state.daysInMonth.map((week, weekIndex) => ( - - {week.map((day, dayIndex) => ( - - - - ))} - - ))} - - -
+ + + {state.popover.visible && ( + + + + )} +
+
); }; + +export default DatePickerStyled; diff --git a/src/datepicker/stories/DateRangePickerStyled.stories.tsx b/src/datepicker/stories/DateRangePickerStyled.stories.tsx index 409772702..9303fd29a 100644 --- a/src/datepicker/stories/DateRangePickerStyled.stories.tsx +++ b/src/datepicker/stories/DateRangePickerStyled.stories.tsx @@ -1,20 +1,23 @@ import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; -import { DateRangePicker } from "./DateRangePickerStyled.component"; +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/DateRangePickerStyledJsx"; +import ts from "./templates/DateRangePickerStyledTsx"; +import { DateRangePickerStyled } from "./DateRangePickerStyled.component"; import "./tailwind.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: DateRangePicker, - title: "DateRangePicker/Styled", + title: "DatePicker/RangeStyled", + component: DateRangePickerStyled, parameters: { layout: "centered", - options: { showPanel: false }, - }, - argTypes: { - minValue: { control: "date" }, - maxValue: { control: "date" }, + preview: createPreviewTabs({ js, ts }), }, decorators: [ Story => { @@ -24,4 +27,6 @@ export default { ], } as Meta; -export const Default: Story = args => ; +export const Default: Story = { + args: { label: "DateRangePicker" }, +}; diff --git a/src/datepicker/stories/tailwind.css b/src/datepicker/stories/tailwind.css index 28d75cc29..2f5267723 100644 --- a/src/datepicker/stories/tailwind.css +++ b/src/datepicker/stories/tailwind.css @@ -32,6 +32,14 @@ } .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; } @@ -42,4 +50,8 @@ .styled-datepicker.calendar span { outline: none; } + + .datepicker__dash:before { + content: "-"; + } } diff --git a/src/dialog/Dialog.tsx b/src/dialog/Dialog.tsx deleted file mode 100644 index 7d42c0215..000000000 --- a/src/dialog/Dialog.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import * as React from "react"; -import { RemoveScroll } from "react-remove-scroll"; -import { createComponent, createHook, useCreateElement } from "reakit-system"; -import { Portal } from "reakit"; -import { useForkRef, useLiveRef } from "reakit-utils"; -import { useWarning, warning } from "reakit-warning"; - -import { - DisclosureContentHTMLProps, - DisclosureContentOptions, - useDisclosureContent, -} from "../disclosure"; - -import { DIALOG_KEYS } from "./__keys"; -import { DialogStateReturn } from "./DialogState"; -import { - DialogBackdropContext, - useDisableHoverOutside, - useDisclosureRef, - useFocusOnBlur, - useFocusOnChildUnmount, - useFocusOnHide, - useFocusOnShow, - useFocusTrap, - useHideOnClickOutside, - useNestedDialogs, -} from "./helpers"; - -export type DialogOptions = DisclosureContentOptions & - Pick, "modal" | "hide" | "disclosureRef"> & - Pick & { - /** - * When enabled, user can hide the dialog by pressing `Escape`. - */ - hideOnEsc?: boolean; - - /** - * When enabled, user can hide the dialog by clicking outside it. - */ - hideOnClickOutside?: boolean; - - /** - * When enabled, user can't scroll on body when the dialog is visible. - * This option doesn't work if the dialog isn't modal. - */ - preventBodyScroll?: boolean; - - /** - * The element that will be focused when the dialog shows. - * When not set, the first tabbable element within the dialog will be used. - */ - unstable_initialFocusRef?: React.RefObject; - - /** - * The element that will be focused when the dialog hides. - * When not set, the disclosure component will be used. - */ - unstable_finalFocusRef?: React.RefObject; - - /** - * Whether or not the dialog should be a child of its parent. - * Opening a nested orphan dialog will close its parent dialog if - * `hideOnClickOutside` is set to `true` on the parent. - * It will be set to `false` if `modal` is `false`. - */ - unstable_orphan?: boolean; - - /** - * Whether or not to move focus when the dialog shows. - * @private - */ - unstable_autoFocusOnShow?: boolean; - - /** - * Whether or not to move focus when the dialog hides. - * @private - */ - unstable_autoFocusOnHide?: boolean; - }; - -export type DialogHTMLProps = DisclosureContentHTMLProps; - -export type DialogProps = DialogOptions & DialogHTMLProps; - -export const useDialog = createHook({ - name: "Dialog", - compose: useDisclosureContent, - keys: DIALOG_KEYS, - - useOptions(options) { - const { - modal = true, - hideOnEsc = true, - hideOnClickOutside = true, - preventBodyScroll = modal, - unstable_autoFocusOnShow = true, - unstable_autoFocusOnHide = true, - unstable_orphan, - ...restOptions - } = options; - - return { - modal, - hideOnEsc, - hideOnClickOutside, - preventBodyScroll: modal && preventBodyScroll, - unstable_autoFocusOnShow, - unstable_autoFocusOnHide, - unstable_orphan: modal && unstable_orphan, - ...restOptions, - }; - }, - - useProps(options, htmlProps) { - const { - preventBodyScroll, - baseId, - hideOnEsc, - hide, - modal: optionsModal, - } = options; - const { - ref: htmlRef, - onKeyDown: htmlOnKeyDown, - onBlur: htmlOnBlur, - wrapElement: htmlWrapElement, - tabIndex, - ...restHtmlProps - } = htmlProps; - const dialog = React.useRef(null); - const backdrop = React.useContext(DialogBackdropContext); - const hasBackdrop = backdrop && backdrop === baseId; - const disclosure = useDisclosureRef(dialog, options); - const onKeyDownRef = useLiveRef(htmlOnKeyDown); - const onBlurRef = useLiveRef(htmlOnBlur); - const focusOnBlur = useFocusOnBlur(dialog, options); - const { dialogs, visibleModals, wrap } = useNestedDialogs(dialog, options); - // VoiceOver/Safari accepts only one `aria-modal` container, so if there - // are visible child modals, then we don't want to set aria-modal on the - // parent modal (this component). - const modal = optionsModal && !visibleModals.length ? true : undefined; - - useFocusTrap(dialog, visibleModals, options); - useFocusOnChildUnmount(dialog, options); - useFocusOnShow(dialog, dialogs, options); - useFocusOnHide(dialog, disclosure, options); - useHideOnClickOutside(dialog, disclosure, dialogs, options); - useDisableHoverOutside(dialog, dialogs, options); - - const onKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - onKeyDownRef.current?.(event); - - if (event.defaultPrevented) return; - if (event.key !== "Escape") return; - if (!hideOnEsc) return; - if (!hide) { - warning( - true, - "`hideOnEsc` prop is truthy, but `hide` prop wasn't provided.", - dialog.current, - ); - return; - } - event.stopPropagation(); - - hide(); - }, - [onKeyDownRef, hideOnEsc, hide], - ); - - const onBlur = React.useCallback( - (event: React.FocusEvent) => { - onBlurRef.current?.(event); - - focusOnBlur(event); - }, - [focusOnBlur, onBlurRef], - ); - - const wrapElement = React.useCallback( - (element: React.ReactNode) => { - element = wrap(element); - - if (optionsModal && !hasBackdrop) { - if (preventBodyScroll) { - element = ( - - {element} - - ); - } else { - element = {element}; - } - } - - if (htmlWrapElement) { - element = htmlWrapElement(element); - } - - // return ( - // // Prevents Menu > Dialog > Menu to behave as a sub menu - // {element} - // ); - return element; - }, - [wrap, optionsModal, hasBackdrop, htmlWrapElement, preventBodyScroll], - ); - - return { - ref: useForkRef(dialog, htmlRef), - role: "dialog", - tabIndex: tabIndex ?? -1, - "aria-modal": modal, - "data-dialog": true, - onKeyDown, - onBlur, - wrapElement, - ...restHtmlProps, - }; - }, -}); - -export const Dialog = createComponent({ - as: "div", - useHook: useDialog, - useCreateElement: (type, props, children) => { - useWarning( - !props["aria-label"] && !props["aria-labelledby"], - "You should provide either `aria-label` or `aria-labelledby` props.", - ); - return useCreateElement(type, props, children); - }, -}); diff --git a/src/dialog/DialogBackdrop.tsx b/src/dialog/DialogBackdrop.tsx deleted file mode 100644 index 10f9540f7..000000000 --- a/src/dialog/DialogBackdrop.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import * as React from "react"; -import { RemoveScroll } from "react-remove-scroll"; -import { Portal } from "reakit"; - -import { - DisclosureContentHTMLProps, - DisclosureContentOptions, - useDisclosureContent, -} from "../disclosure/DisclosureContent"; -import { createComponent, createHook } from "../system"; - -import { DIALOG_BACKDROP_KEYS } from "./__keys"; -import { DialogStateReturn } from "./DialogState"; -import { DialogBackdropContext } from "./helpers"; - -export type DialogBackdropOptions = DisclosureContentOptions & - Pick, "modal"> & { - /** - * When enabled, user can't scroll on body when the dialog is visible. - * This option doesn't work if the dialog isn't modal. - */ - preventBodyScroll?: boolean; - }; - -export type DialogBackdropHTMLProps = DisclosureContentHTMLProps; - -export type DialogBackdropProps = DialogBackdropOptions & - DialogBackdropHTMLProps; - -export const useDialogBackdrop = createHook< - DialogBackdropOptions, - DialogBackdropHTMLProps ->({ - name: "DialogBackdrop", - compose: useDisclosureContent, - keys: DIALOG_BACKDROP_KEYS, - - useOptions({ modal = true, preventBodyScroll = modal, ...options }) { - return { modal, preventBodyScroll: modal && preventBodyScroll, ...options }; - }, - - useProps(options, htmlProps) { - const { modal, baseId, preventBodyScroll } = options; - const { wrapElement: htmlWrapElement, ...restHtmlProps } = htmlProps; - const wrapElement = React.useCallback( - (element: React.ReactNode) => { - if (modal) { - if (preventBodyScroll) { - element = ( - - - {element} - - - ); - } else { - element = ( - - - {element} - - - ); - } - } - - if (htmlWrapElement) { - return htmlWrapElement(element); - } - - return element; - }, - [modal, htmlWrapElement, preventBodyScroll, baseId], - ); - - return { - id: undefined, - "data-dialog-ref": baseId, - wrapElement, - ...restHtmlProps, - }; - }, -}); - -export const DialogBackdrop = createComponent({ - as: "div", - memo: true, - useHook: useDialogBackdrop, -}); diff --git a/src/dialog/DialogDisclosure.ts b/src/dialog/DialogDisclosure.ts deleted file mode 100644 index daa53497f..000000000 --- a/src/dialog/DialogDisclosure.ts +++ /dev/null @@ -1,85 +0,0 @@ -import * as React from "react"; -import { useForkRef, useLiveRef } from "reakit-utils"; -import { warning } from "reakit-warning"; -import { useSafeLayoutEffect } from "@chakra-ui/hooks"; - -import { - DisclosureHTMLProps, - DisclosureOptions, - useDisclosure, -} from "../disclosure"; -import { createComponent, createHook } from "../system"; - -import { DIALOG_DISCLOSURE_KEYS } from "./__keys"; -import { DialogStateReturn } from "./DialogState"; - -export type DialogDisclosureOptions = DisclosureOptions & - Pick, "disclosureRef">; - -export type DialogDisclosureHTMLProps = DisclosureHTMLProps; - -export type DialogDisclosureProps = DialogDisclosureOptions & - DialogDisclosureHTMLProps; - -export const useDialogDisclosure = createHook< - DialogDisclosureOptions, - DialogDisclosureHTMLProps ->({ - name: "DialogDisclosure", - compose: useDisclosure, - keys: DIALOG_DISCLOSURE_KEYS, - - useProps(options, htmlProps) { - const { disclosureRef, visible } = options; - const { ref: htmlRef, onClick: htmlOnClick, ...restHtmlProps } = htmlProps; - const ref = React.useRef(null); - const onClickRef = useLiveRef(htmlOnClick); - const [expanded, setExpanded] = React.useState(false); - - // aria-expanded may be used for styling purposes, so we useLayoutEffect - useSafeLayoutEffect(() => { - const element = ref.current; - - warning( - !element, - "Can't determine whether the element is the current disclosure because `ref` wasn't passed to the component", - ); - - if (disclosureRef && !disclosureRef.current) { - disclosureRef.current = element; - } - - const isCurrentDisclosure = - !disclosureRef?.current || disclosureRef.current === element; - - setExpanded(!!visible && isCurrentDisclosure); - }, [visible, disclosureRef]); - - const onClick = React.useCallback( - (event: React.MouseEvent) => { - onClickRef.current?.(event); - - if (event.defaultPrevented) return; - - if (disclosureRef) { - disclosureRef.current = event.currentTarget; - } - }, - [disclosureRef, onClickRef], - ); - - return { - ref: useForkRef(ref, htmlRef), - "aria-haspopup": "dialog", - "aria-expanded": expanded, - onClick, - ...restHtmlProps, - }; - }, -}); - -export const DialogDisclosure = createComponent({ - as: "button", - memo: true, - useHook: useDialogDisclosure, -}); diff --git a/src/dialog/DialogState.ts b/src/dialog/DialogState.ts deleted file mode 100644 index df13fbf98..000000000 --- a/src/dialog/DialogState.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as React from "react"; - -import { - DisclosureActions, - DisclosureInitialState, - DisclosureState, - DisclosureStateReturn, - useDisclosureState, -} from "../disclosure"; - -export type DialogState = DisclosureState & { - /** - * Toggles Dialog's `modal` state. - * - Non-modal: `preventBodyScroll` doesn't work and focus is free. - * - Modal: `preventBodyScroll` is automatically enabled, focus is - * trapped within the dialog and the dialog is rendered within a `Portal` - * by default. - */ - modal: boolean; - - /** - * @private - */ - disclosureRef: React.MutableRefObject; -}; - -export type DialogActions = DisclosureActions & { - /** - * Sets `modal`. - */ - setModal: React.Dispatch>; -}; - -export type DialogInitialState = DisclosureInitialState & - Partial>; - -export type DialogStateReturn = DisclosureStateReturn & - DialogState & - DialogActions; - -export function useDialogState( - props: DialogInitialState = {}, -): DialogStateReturn { - const { modal: initialModal = true, ...restProps } = props; - const disclosure = useDisclosureState(restProps); - const [modal, setModal] = React.useState(initialModal); - const disclosureRef = React.useRef(null); - - return { - ...disclosure, - modal, - setModal, - disclosureRef: disclosureRef, - }; -} diff --git a/src/dialog/__keys.ts b/src/dialog/__keys.ts deleted file mode 100644 index ecac0c78d..000000000 --- a/src/dialog/__keys.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Automatically generated -export const USE_DIALOG_STATE_KEYS = [ - "baseId", - "visible", - "defaultVisible", - "onVisibleChange", - "modal", -] as const; -export const DIALOG_STATE_KEYS = [ - "baseId", - "unstable_idCountRef", - "visible", - "setBaseId", - "show", - "hide", - "toggle", - "setVisible", - "modal", - "disclosureRef", - "setModal", -] as const; -export const DIALOG_KEYS = [ - ...DIALOG_STATE_KEYS, - "hideOnEsc", - "hideOnClickOutside", - "preventBodyScroll", - "unstable_initialFocusRef", - "unstable_finalFocusRef", - "unstable_orphan", - "unstable_autoFocusOnShow", - "unstable_autoFocusOnHide", -] as const; -export const DIALOG_BACKDROP_KEYS = [ - ...DIALOG_STATE_KEYS, - "preventBodyScroll", -] as const; -export const DIALOG_DISCLOSURE_KEYS = DIALOG_STATE_KEYS; diff --git a/src/dialog/drawer.ts b/src/dialog/drawer.ts new file mode 100644 index 000000000..46dff682e --- /dev/null +++ b/src/dialog/drawer.ts @@ -0,0 +1,69 @@ +import { CSSProperties } from "react"; +import { DialogOptions, useDialog } from "ariakit/dialog"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Props } from "ariakit-utils/types"; + +export const useDrawer = createHook( + ({ placement = "left", state, ...props }) => { + const style: CSSProperties = { + ...PLACEMENTS[placement], + position: "fixed", + ...props.style, + }; + + props = { ...props, style }; + props = useDialog({ state, ...props }); + + return props; + }, +); + +export const Drawer = createComponent(props => { + const htmlProps = useDrawer(props); + + return createElement("div", htmlProps); +}); + +export type DrawerOptions = DialogOptions & { + /** + * Direction to place the drawer. + * + * @default left + */ + placement?: Placement; +}; + +export type DrawerProps = Props>; + +export type Placement = keyof typeof PLACEMENTS; + +const PLACEMENTS = { + left: { + left: 0, + top: 0, + bottom: 0, + height: "100vh", + }, + right: { + right: 0, + top: 0, + bottom: 0, + height: "100vh", + }, + top: { + right: 0, + left: 0, + top: 0, + width: "100vw", + }, + bottom: { + right: 0, + left: 0, + bottom: 0, + width: "100vw", + }, +}; diff --git a/src/dialog/helpers/DialogBackdropContext.ts b/src/dialog/helpers/DialogBackdropContext.ts deleted file mode 100644 index 6c090a44a..000000000 --- a/src/dialog/helpers/DialogBackdropContext.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from "react"; - -export const DialogBackdropContext = React.createContext( - undefined, -); diff --git a/src/dialog/helpers/index.ts b/src/dialog/helpers/index.ts deleted file mode 100644 index 0538f4645..000000000 --- a/src/dialog/helpers/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from "./DialogBackdropContext"; -export * from "./useDisableHoverOutside"; -export * from "./useDisclosureRef"; -export * from "./useEventListenerOutside"; -export * from "./useFocusOnBlur"; -export * from "./useFocusOnChildUnmount"; -export * from "./useFocusOnHide"; -export * from "./useFocusOnShow"; -export * from "./useFocusTrap"; -export * from "./useHideOnClickOutside"; -export * from "./useNestedDialogs"; -export * from "./usePortalRef"; diff --git a/src/dialog/helpers/useDisableHoverOutside.ts b/src/dialog/helpers/useDisableHoverOutside.ts deleted file mode 100644 index bdacdb351..000000000 --- a/src/dialog/helpers/useDisableHoverOutside.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as React from "react"; - -import { DialogOptions } from "../Dialog"; - -import { useEventListenerOutside } from "./useEventListenerOutside"; - -export function useDisableHoverOutside( - portalRef: React.RefObject, - nestedDialogs: Array>, - options: DialogOptions, -) { - const useEvent = (eventType: string) => - useEventListenerOutside( - portalRef, - { current: null }, - nestedDialogs, - eventType, - event => { - event.stopPropagation(); - event.preventDefault(); - }, - options.visible && options.modal, - true, - ); - useEvent("mouseover"); - useEvent("mousemove"); - useEvent("mouseout"); -} diff --git a/src/dialog/helpers/useDisclosureRef.ts b/src/dialog/helpers/useDisclosureRef.ts deleted file mode 100644 index 468c41a4a..000000000 --- a/src/dialog/helpers/useDisclosureRef.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as React from "react"; -import { getDocument, isButton } from "reakit-utils"; - -import { DialogOptions } from "../Dialog"; - -export function useDisclosureRef( - dialogRef: React.RefObject, - options: DialogOptions, -) { - const ref = React.useRef(null); - - React.useEffect(() => { - if (options.visible || options.animating) return undefined; - - // We get the last focused element before the dialog opens, so we can move - // focus back to it when the dialog closes. - const onFocus = (event: FocusEvent) => { - const target = event.target as HTMLElement; - - if ("focus" in target) { - ref.current = target; - - if (options.disclosureRef) { - options.disclosureRef.current = target; - } - } - }; - - const document = getDocument(dialogRef.current); - document.addEventListener("focusin", onFocus); - - return () => document.removeEventListener("focusin", onFocus); - }, [options.visible, options.animating, options.disclosureRef, dialogRef]); - - React.useEffect(() => { - if (!options.visible || options.animating) return undefined; - - // Safari and Firefox on MacOS don't focus on buttons on mouse down. - // Instead, they focus on the closest focusable parent (ultimately, the - // body element). This works around that by preventing that behavior and - // forcing focus on the disclosure button. Otherwise, we wouldn't be able - // to close the dialog by clicking again on the disclosure. - const onMouseDown = (event: MouseEvent) => { - const element = event.currentTarget as HTMLElement; - - if (!isButton(element)) return; - - event.preventDefault(); - element.focus(); - }; - - const disclosure = options.disclosureRef?.current || ref.current; - disclosure?.addEventListener("mousedown", onMouseDown); - - return () => disclosure?.removeEventListener("mousedown", onMouseDown); - }, [options.visible, options.animating, options.disclosureRef]); - - return options.disclosureRef || ref; -} diff --git a/src/dialog/helpers/useEventListenerOutside.ts b/src/dialog/helpers/useEventListenerOutside.ts deleted file mode 100644 index 9033145cf..000000000 --- a/src/dialog/helpers/useEventListenerOutside.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as React from "react"; -import { contains, getDocument, useLiveRef } from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { isFocusTrap } from "./useFocusTrap"; - -function dialogContains(target: Element) { - return (dialogRef: React.RefObject) => { - const dialog = dialogRef.current; - if (!dialog) return false; - if (contains(dialog, target)) return true; - const document = getDocument(dialog); - const backdrop = document.querySelector(`[data-dialog-ref="${dialog.id}"]`); - if (backdrop) { - return contains(backdrop, target); - } - return false; - }; -} - -function isDisclosure(target: Element, disclosure: HTMLElement) { - return contains(disclosure, target); -} - -function isInDocument(target: Element) { - const document = getDocument(target); - if (target.tagName === "HTML") { - return true; - } - return contains(document.body, target); -} - -export function useEventListenerOutside( - containerRef: React.RefObject, - disclosureRef: React.RefObject, - nestedDialogs: Array>, - eventType: string, - listener?: (e: Event) => void, - shouldListen?: boolean, - capture?: boolean, -) { - const listenerRef = useLiveRef(listener); - - React.useEffect(() => { - if (!shouldListen) return undefined; - - const onEvent = (event: Event) => { - if (!listenerRef.current) return; - const container = containerRef.current; - const disclosure = disclosureRef.current; - const target = event.target as Element; - if (!container) { - warning( - true, - "Can't detect events outside dialog because `ref` wasn't passed to component.", - ); - return; - } - // When an element is unmounted right after it receives focus, the focus - // event is triggered after that, when the element isn't part of the - // current document anymore. So we ignore it. - if (!isInDocument(target)) return; - // Event inside dialog - if (contains(container, target)) return; - // Event on disclosure - if (disclosure && isDisclosure(target, disclosure)) return; - // Event inside a nested dialog or focus trap - if (isFocusTrap(target) || nestedDialogs.some(dialogContains(target))) { - return; - } - listenerRef.current(event); - }; - - const document = getDocument(containerRef.current); - document.addEventListener(eventType, onEvent, capture); - return () => document.removeEventListener(eventType, onEvent, capture); - }, [ - containerRef, - disclosureRef, - nestedDialogs, - eventType, - shouldListen, - listenerRef, - capture, - ]); -} diff --git a/src/dialog/helpers/useFocusOnBlur.ts b/src/dialog/helpers/useFocusOnBlur.ts deleted file mode 100644 index 8dda64c89..000000000 --- a/src/dialog/helpers/useFocusOnBlur.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from "react"; -import { - getActiveElement, - getDocument, - getNextActiveElementOnBlur, -} from "reakit-utils"; -import { warning } from "reakit-warning"; -import { useSafeLayoutEffect } from "@chakra-ui/hooks"; - -import { DialogOptions } from "../Dialog"; - -function isActualElement(element?: Element | null) { - return ( - element && - element.tagName && - element.tagName !== "HTML" && - element !== getDocument(element).body - ); -} - -export function useFocusOnBlur( - dialogRef: React.RefObject, - options: DialogOptions, -) { - const [blurred, scheduleFocus] = React.useReducer((n: number) => n + 1, 0); - - useSafeLayoutEffect(() => { - const dialog = dialogRef.current; - if (!options.visible) return; - if (!blurred) return; - // After blur, if the active element isn't an actual element, this probably - // means that element.blur() was called on an element inside the dialog. - // In this case, the browser will automatically focus the body element. - // So we move focus back to the dialog. - if (!isActualElement(getActiveElement(dialog))) { - warning( - !dialog, - "Can't focus dialog after a nested element got blurred because `ref` wasn't passed to the component", - ); - dialog?.focus(); - } - }, [blurred, dialogRef]); - - const onBlur = React.useCallback( - (event: React.FocusEvent) => { - if (!options.visible) return; - const nextActiveElement = getNextActiveElementOnBlur(event); - if (!isActualElement(nextActiveElement)) { - scheduleFocus(); - } - }, - [options.visible], - ); - - return onBlur; -} diff --git a/src/dialog/helpers/useFocusOnChildUnmount.ts b/src/dialog/helpers/useFocusOnChildUnmount.ts deleted file mode 100644 index d9c93d339..000000000 --- a/src/dialog/helpers/useFocusOnChildUnmount.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as React from "react"; -import { getActiveElement, getDocument, isEmpty } from "reakit-utils"; - -import { DialogOptions } from "../Dialog"; - -/** - * When the focused child gets removed from the DOM, we make sure to move focus - * to the dialog. - */ -export function useFocusOnChildUnmount( - dialogRef: React.RefObject, - options: DialogOptions, -) { - React.useEffect(() => { - const dialog = dialogRef.current; - if (!options.visible || !dialog) return undefined; - - const observer = new MutationObserver(mutations => { - const [{ target }] = mutations; - // If target is not this dialog, then this observer was triggered by a - // nested dialog, so we just ignore it here and let the nested dialog - // handle it there. - if (target !== dialog) return; - const document = getDocument(dialog); - const activeElement = getActiveElement(dialog); - // We can check if the current focused element is the document body. On - // IE 11, it's an empty object when the current document is in a frame or - // iframe. - if (activeElement === document.body || isEmpty(activeElement)) { - dialog.focus(); - } - }); - - observer.observe(dialog, { childList: true, subtree: true }); - return () => { - observer.disconnect(); - }; - }, [options.visible, dialogRef]); -} diff --git a/src/dialog/helpers/useFocusOnHide.ts b/src/dialog/helpers/useFocusOnHide.ts deleted file mode 100644 index 744649715..000000000 --- a/src/dialog/helpers/useFocusOnHide.ts +++ /dev/null @@ -1,74 +0,0 @@ -import * as React from "react"; -import { - contains, - ensureFocus, - getActiveElement, - getDocument, - isTabbable, - useUpdateEffect, -} from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { DialogOptions } from "../Dialog"; - -function hidByFocusingAnotherElement(dialogRef: React.RefObject) { - const dialog = dialogRef.current; - if (!dialog) return false; - - const activeElement = getActiveElement(dialog); - - if (!activeElement) return false; - if (contains(dialog, activeElement)) return false; - if (isTabbable(activeElement)) return true; - if (activeElement.getAttribute("data-dialog") === "true") return true; - - return false; -} - -export function useFocusOnHide( - dialogRef: React.RefObject, - disclosureRef: React.RefObject, - options: DialogOptions, -) { - const shouldFocus = options.unstable_autoFocusOnHide && !options.visible; - - useUpdateEffect(() => { - if (!shouldFocus) return; - if (options.animating) return; - console.log("%canimating", "color: #ffa280", options.animating); - - // Hide was triggered by a click/focus on a tabbable element outside - // the dialog or on another dialog. We won't change focus then. - if (hidByFocusingAnotherElement(dialogRef)) { - return; - } - - const finalFocusEl = - options.unstable_finalFocusRef?.current || disclosureRef.current; - - if (finalFocusEl) { - if (finalFocusEl.id) { - const document = getDocument(finalFocusEl); - const compositeElement = document.querySelector( - `[aria-activedescendant='${finalFocusEl.id}']`, - ); - - if (compositeElement) { - ensureFocus(compositeElement); - - return; - } - } - - ensureFocus(finalFocusEl); - - return; - } - - warning( - true, - "Can't return focus after closing dialog. Either render a disclosure component or provide a `unstable_finalFocusRef` prop.", - dialogRef.current, - ); - }, [shouldFocus, options.animating, dialogRef, disclosureRef]); -} diff --git a/src/dialog/helpers/useFocusOnShow.ts b/src/dialog/helpers/useFocusOnShow.ts deleted file mode 100644 index 7a226b4cd..000000000 --- a/src/dialog/helpers/useFocusOnShow.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as React from "react"; -import { - ensureFocus, - getFirstTabbableIn, - hasFocusWithin, - useUpdateEffect, -} from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { DialogOptions } from "../Dialog"; - -export function useFocusOnShow( - dialogRef: React.RefObject, - nestedDialogs: Array>, - options: DialogOptions, -) { - const initialFocusRef = options.unstable_initialFocusRef; - const shouldFocus = options.visible && options.unstable_autoFocusOnShow; - - useUpdateEffect(() => { - const dialog = dialogRef.current; - - warning( - !!shouldFocus && !dialog, - "[reakit/Dialog]", - "Can't set initial focus on dialog because `ref` wasn't passed to the dialog element.", - ); - - if (!shouldFocus) return; - if (!dialog) return; - if (options.animating) return; - - // If there're nested open dialogs, let them handle focus - if (nestedDialogs.some(child => child.current && !child.current.hidden)) { - return; - } - - if (initialFocusRef?.current) { - initialFocusRef.current.focus({ preventScroll: true }); - } else { - const tabbable = getFirstTabbableIn(dialog, true); - const isActive = () => hasFocusWithin(dialog); - if (tabbable) { - ensureFocus(tabbable, { preventScroll: true, isActive }); - } else { - ensureFocus(dialog, { preventScroll: true, isActive }); - warning( - dialog.tabIndex === undefined || dialog.tabIndex < 0, - "It's recommended to have at least one tabbable element inside dialog. The dialog element has been automatically focused.", - "If this is the intended behavior, pass `tabIndex={0}` to the dialog element to disable this warning.", - dialog, - ); - } - } - }, [ - dialogRef, - shouldFocus, - options.animating, - nestedDialogs, - initialFocusRef, - ]); -} diff --git a/src/dialog/helpers/useFocusTrap.ts b/src/dialog/helpers/useFocusTrap.ts deleted file mode 100644 index 2afd528f4..000000000 --- a/src/dialog/helpers/useFocusTrap.ts +++ /dev/null @@ -1,103 +0,0 @@ -import * as React from "react"; -import { - getDocument, - getFirstTabbableIn, - getLastTabbableIn, -} from "reakit-utils"; -import { warning } from "reakit-warning"; - -import { DialogOptions } from "../Dialog"; - -import { usePortalRef } from "./usePortalRef"; - -function removeFromDOM(element: Element) { - if (element.parentNode == null) return; - element.parentNode.removeChild(element); -} - -const focusTrapClassName = "__reakit-focus-trap"; - -export function isFocusTrap(element: Element) { - return element.classList?.contains(focusTrapClassName); -} - -export function useFocusTrap( - dialogRef: React.RefObject, - visibleModals: Array>, - options: DialogOptions, -) { - const portalRef = usePortalRef(dialogRef, options); - const shouldTrap = options.visible && options.modal; - const beforeElement = React.useRef(null); - const afterElement = React.useRef(null); - - // Create before and after elements - // https://github.com/w3c/aria-practices/issues/545 - React.useEffect(() => { - if (!shouldTrap) return undefined; - const portal = portalRef.current; - - if (!portal) { - warning( - true, - "Can't trap focus within modal dialog because either `ref` wasn't passed to component or the component wasn't rendered within a portal", - ); - return undefined; - } - - if (!beforeElement.current) { - const document = getDocument(portal); - beforeElement.current = document.createElement("div"); - beforeElement.current.className = focusTrapClassName; - beforeElement.current.tabIndex = 0; - beforeElement.current.style.position = "fixed"; - beforeElement.current.setAttribute("aria-hidden", "true"); - } - - if (!afterElement.current) { - afterElement.current = beforeElement.current.cloneNode() as HTMLElement; - } - - portal.insertAdjacentElement("beforebegin", beforeElement.current); - portal.insertAdjacentElement("afterend", afterElement.current); - - return () => { - if (beforeElement.current) removeFromDOM(beforeElement.current); - if (afterElement.current) removeFromDOM(afterElement.current); - }; - }, [portalRef, shouldTrap]); - - // Focus trap - React.useEffect(() => { - const before = beforeElement.current; - const after = afterElement.current; - if (!shouldTrap || !before || !after) return undefined; - - const handleFocus = (event: FocusEvent) => { - const dialog = dialogRef.current; - if (!dialog || visibleModals.length) return; - - event.preventDefault(); - - const isAfter = event.target === after; - - const tabbable = isAfter - ? getFirstTabbableIn(dialog) - : getLastTabbableIn(dialog); - - if (tabbable) { - tabbable.focus(); - } else { - // fallback to dialog - dialog.focus(); - } - }; - - before.addEventListener("focus", handleFocus); - after.addEventListener("focus", handleFocus); - return () => { - before.removeEventListener("focus", handleFocus); - after.removeEventListener("focus", handleFocus); - }; - }, [dialogRef, visibleModals, shouldTrap]); -} diff --git a/src/dialog/helpers/useHideOnClickOutside.ts b/src/dialog/helpers/useHideOnClickOutside.ts deleted file mode 100644 index 2e5b6eef9..000000000 --- a/src/dialog/helpers/useHideOnClickOutside.ts +++ /dev/null @@ -1,69 +0,0 @@ -import * as React from "react"; -import { getDocument } from "reakit-utils/getDocument"; - -import { DialogOptions } from "../Dialog"; - -import { useEventListenerOutside } from "./useEventListenerOutside"; - -function useMouseDownRef( - dialogRef: React.RefObject, - options: DialogOptions, -) { - const mouseDownRef = React.useRef(); - - React.useEffect(() => { - if (!options.visible) return undefined; - if (!options.hideOnClickOutside) return undefined; - const document = getDocument(dialogRef.current); - const onMouseDown = (event: MouseEvent) => { - mouseDownRef.current = event.target; - }; - document.addEventListener("mousedown", onMouseDown); - return () => document.removeEventListener("mousedown", onMouseDown); - }, [options.visible, options.hideOnClickOutside, dialogRef]); - - return mouseDownRef; -} - -export function useHideOnClickOutside( - dialogRef: React.RefObject, - disclosureRef: React.RefObject, - nestedDialogs: Array>, - options: DialogOptions, -) { - const mouseDownRef = useMouseDownRef(dialogRef, options); - - useEventListenerOutside( - dialogRef, - disclosureRef, - nestedDialogs, - "click", - event => { - // Make sure the element that has been clicked is the same that last - // triggered the mousedown event. This prevents the dialog from closing - // by dragging the cursor (for example, selecting some text inside the - // dialog and releasing the mouse outside of it). - if (mouseDownRef.current === event.target) { - options.hide?.(); - } - }, - options.visible && options.hideOnClickOutside, - ); - - useEventListenerOutside( - dialogRef, - disclosureRef, - nestedDialogs, - "focusin", - event => { - const document = getDocument(dialogRef.current); - // Fix for https://github.com/reakit/reakit/issues/619 - // On IE11, calling element.blur() triggers the focus event on - // document.body, so we make sure to ignore it as well. - if (event.target !== document && event.target !== document.body) { - options.hide?.(); - } - }, - options.visible && options.hideOnClickOutside, - ); -} diff --git a/src/dialog/helpers/useNestedDialogs.tsx b/src/dialog/helpers/useNestedDialogs.tsx deleted file mode 100644 index b9df3f28e..000000000 --- a/src/dialog/helpers/useNestedDialogs.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import * as React from "react"; -import { removeItemFromArray } from "reakit-utils/removeItemFromArray"; - -import { DialogOptions } from "../Dialog"; - -type DialogRef = React.RefObject; - -const DialogContext = React.createContext<{ - visible?: boolean; - addDialog?: (ref: DialogRef) => void; - removeDialog?: (ref: DialogRef) => void; - showDialog?: (ref: DialogRef) => void; - hideDialog?: (ref: DialogRef) => void; -}>({}); - -export function useNestedDialogs(dialogRef: DialogRef, options: DialogOptions) { - const context = React.useContext(DialogContext); - - const [dialogs, setDialogs] = React.useState>([]); - const [visibleModals, setVisibleModals] = React.useState(dialogs); - - const addDialog = React.useCallback( - (ref: DialogRef) => { - context.addDialog?.(ref); - setDialogs(prevDialogs => [...prevDialogs, ref]); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [context.addDialog], - ); - - const removeDialog = React.useCallback( - (ref: DialogRef) => { - context.removeDialog?.(ref); - setDialogs(prevDialogs => removeItemFromArray(prevDialogs, ref)); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [context.removeDialog], - ); - - const showDialog = React.useCallback( - (ref: DialogRef) => { - context.showDialog?.(ref); - setVisibleModals(prevDialogs => [...prevDialogs, ref]); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [context.showDialog], - ); - - const hideDialog = React.useCallback( - (ref: DialogRef) => { - context.hideDialog?.(ref); - setVisibleModals(prevDialogs => removeItemFromArray(prevDialogs, ref)); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [context.hideDialog], - ); - - // If it's a nested dialog, add it to context - React.useEffect(() => { - if (options.unstable_orphan) return undefined; - context.addDialog?.(dialogRef); - return () => { - context.removeDialog?.(dialogRef); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - options.unstable_orphan, - context.addDialog, - dialogRef, - context.removeDialog, - ]); - - React.useEffect(() => { - if (options.unstable_orphan) return undefined; - if (!options.modal) return undefined; - if (!options.visible) return undefined; - context.showDialog?.(dialogRef); - return () => { - context.hideDialog?.(dialogRef); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - options.unstable_orphan, - options.modal, - options.visible, - context.showDialog, - dialogRef, - context.hideDialog, - ]); - - // Close all nested dialogs when parent dialog closes - React.useEffect(() => { - if ( - context.visible === false && - options.visible && - !options.unstable_orphan - ) { - options.hide?.(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [context.visible, options.visible, options.hide, options.unstable_orphan]); - - // Provider - const providerValue = React.useMemo( - () => ({ - visible: options.visible, - addDialog, - removeDialog, - showDialog, - hideDialog, - }), - [options.visible, addDialog, removeDialog, showDialog, hideDialog], - ); - - const wrap = React.useCallback( - (element: React.ReactNode) => ( - - {element} - - ), - [providerValue], - ); - - return { dialogs, visibleModals, wrap }; -} diff --git a/src/dialog/helpers/usePortalRef.ts b/src/dialog/helpers/usePortalRef.ts deleted file mode 100644 index a6d048595..000000000 --- a/src/dialog/helpers/usePortalRef.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as React from "react"; -import { Portal } from "reakit"; -import { closest } from "reakit-utils/closest"; - -import { DialogOptions } from "../Dialog"; - -export function usePortalRef( - dialogRef: React.RefObject, - options: DialogOptions, -) { - const { visible } = options; - const portalRef = React.useRef(null); - - React.useEffect(() => { - const dialog = dialogRef.current; - - if (!dialog || !visible) return; - - portalRef.current = closest(dialog, Portal.__selector) as HTMLElement; - }, [dialogRef, visible]); - - return portalRef; -} diff --git a/src/dialog/index.ts b/src/dialog/index.ts index 5adb63727..29c090d8f 100644 --- a/src/dialog/index.ts +++ b/src/dialog/index.ts @@ -1,6 +1 @@ -export * from "./__keys"; -export * from "./Dialog"; -export * from "./DialogBackdrop"; -export * from "./DialogDisclosure"; -export * from "./DialogState"; -export * from "./helpers"; +export * from "./drawer"; diff --git a/src/dialog/stories/DialogBasic.component.tsx b/src/dialog/stories/DialogBasic.component.tsx deleted file mode 100644 index 330936b61..000000000 --- a/src/dialog/stories/DialogBasic.component.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from "react"; - -import { - Dialog, - DialogBackdrop, - DialogDisclosure, - DialogInitialState, - useDialogState, -} from "../../index"; - -export type DialogBasicProps = DialogInitialState & {}; - -export const DialogBasic: React.FC = props => { - const dialog = useDialogState(props); - const searchFieldRef = React.useRef(null); - const firstNameRef = React.useRef(null); - - return ( - <> - Open dialog - - - Welcome to Reakit! -
- - - - - -
-
-
-
-

The search input will receive the focus after closing the dialog.

- -
- - ); -}; - -export default DialogBasic; diff --git a/src/dialog/stories/DialogBasic.css b/src/dialog/stories/DialogBasic.css deleted file mode 100644 index 9f4d362f2..000000000 --- a/src/dialog/stories/DialogBasic.css +++ /dev/null @@ -1,68 +0,0 @@ -.backdrop { - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - background-color: rgba(0, 0, 0, 0.2); - overflow: auto; - display: flex; - align-items: flex-start; - justify-content: center; -} - -.backdrop[data-enter] { - animation: fadeIn 250ms ease-in-out; -} - -.backdrop[data-leave] { - animation: fadeOut 250ms ease-in-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes fadeOut { - from { - opacity: 1; - } - - to { - opacity: 0; - } -} - -.dialog { - min-width: 300px; - min-height: 150px; - padding: 50px; - border-radius: 10px; - background-color: white; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12); - margin-top: 50px; - margin-bottom: 50px; -} - -.dialog:focus { - box-shadow: rgb(0 109 255 / 50%) 0px 0px 0px 0.2em; -} - -.dialog { - transition: transform 250ms ease-in-out; - transform: translate(0, -10px); -} - -.dialog[data-enter] { - transform: translate(0, 0); -} - -.dialog[data-leave] { - transform: translate(0, -10px); -} diff --git a/src/dialog/stories/DialogBasic.stories.tsx b/src/dialog/stories/DialogBasic.stories.tsx deleted file mode 100644 index a05fff5b8..000000000 --- a/src/dialog/stories/DialogBasic.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createPreviewTabs } from "../../../.storybook/utils"; -import { DialogState } from "../DialogState"; - -import css from "./templates/DialogBasicCss"; -import js from "./templates/DialogBasicJsx"; -import ts from "./templates/DialogBasicTsx"; -import { DialogBasic, DialogBasicProps } from "./DialogBasic.component"; - -import "./DialogBasic.css"; - -export default { - component: DialogBasic, - title: "Dialog/Basic", - parameters: { - options: { showPanel: true }, - preview: createPreviewTabs({ js, ts, css }), - }, -} as Meta; - -export const Default: Story = args => ( - -); - -export const Controlled = () => { - const [value, setValue] = React.useState(false); - console.log("%cvalue", "color: #997326", value); - - return ; -}; diff --git a/src/dialog/stories/DrawerBasic.component.tsx b/src/dialog/stories/DrawerBasic.component.tsx new file mode 100644 index 000000000..2e446e04e --- /dev/null +++ b/src/dialog/stories/DrawerBasic.component.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { + Button, + DialogDismiss, + DialogHeading, + DisclosureStateProps, + useDialogState, +} from "ariakit"; + +import { Drawer } from "../../index"; + +export type DrawerBasicProps = DisclosureStateProps & {}; + +export const DrawerBasic: React.FC = props => { + const dialog = useDialogState({ animated: true, ...props }); + + return ( + <> + + +
+ Apples + +
+
    +
  • + Calories: 95 +
  • +
  • + Carbs: 25 grams +
  • +
  • + Fibers: 4 grams +
  • +
  • + Vitamin C: 14% of the Reference Daily Intake (RDI) +
  • +
  • + Potassium: 6% of the RDI +
  • +
  • + Vitamin K: 5% of the RDI +
  • +
+
+ + ); +}; + +export default DrawerBasic; diff --git a/src/dialog/stories/DrawerBasic.css b/src/dialog/stories/DrawerBasic.css new file mode 100644 index 000000000..75978cecc --- /dev/null +++ b/src/dialog/stories/DrawerBasic.css @@ -0,0 +1,41 @@ +.dialog { + opacity: 0; + padding: 10px; + background-color: white; + transition: 250ms ease-in-out; + transform: translate(-200px, 0); +} + +.dialog[data-enter] { + opacity: 1; + transform: translate(0, 0); +} + +.dialog[data-leave] { + opacity: 0; + transform: translate(-200px, 0); +} + +.backdrop { + opacity: 0; + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + transition: opacity 250ms ease-in-out; + background-color: rgba(0, 0, 0, 0.2); +} + +.backdrop[data-enter] { + opacity: 1; +} + +.header { + display: flex; + justify-content: space-between; +} + +.heading { + margin: 0px; +} diff --git a/src/drawer/stories/DrawerBasic.stories.tsx b/src/dialog/stories/DrawerBasic.stories.tsx similarity index 53% rename from src/drawer/stories/DrawerBasic.stories.tsx rename to src/dialog/stories/DrawerBasic.stories.tsx index 8d31126fe..f9e75fc05 100644 --- a/src/drawer/stories/DrawerBasic.stories.tsx +++ b/src/dialog/stories/DrawerBasic.stories.tsx @@ -1,5 +1,4 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; @@ -7,13 +6,20 @@ import js from "./templates/DrawerBasicJsx"; import ts from "./templates/DrawerBasicTsx"; import { DrawerBasic } from "./DrawerBasic.component"; +import "./DrawerBasic.css"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: DrawerBasic, title: "Drawer/Basic", + component: DrawerBasic, parameters: { layout: "centered", - preview: createPreviewTabs({ js, ts, deps: ["@emotion/css@latest"] }), + preview: createPreviewTabs({ js, ts }), }, } as Meta; -export const Default: Story = args => ; +export const Default: Story = { + args: {}, +}; diff --git a/src/disclosure/Disclosure.tsx b/src/disclosure/Disclosure.tsx deleted file mode 100644 index 11042cfb4..000000000 --- a/src/disclosure/Disclosure.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react"; -import { createComponent } from "reakit-system"; -import { - ButtonHTMLProps, - ButtonOptions, - useButton as useReakitButton, -} from "reakit"; -import { useLiveRef } from "reakit-utils"; - -import { createComposableHook } from "../system"; - -import { DISCLOSURE_KEYS } from "./__keys"; -import { DisclosureStateReturn } from "./DisclosureState"; - -export type DisclosureOptions = ButtonOptions & - Pick; - -export type DisclosureHTMLProps = ButtonHTMLProps; - -export type DisclosureProps = DisclosureOptions & DisclosureHTMLProps; - -export const disclosureComposableButton = createComposableHook< - DisclosureOptions, - DisclosureHTMLProps ->({ - name: "Disclosure", - compose: useReakitButton, - keys: DISCLOSURE_KEYS, - - useProps(options, htmlProps) { - const { toggle, visible, baseId } = options; - const { - onClick: htmlOnClick, - "aria-controls": ariaControls, - ...restHtmlProps - } = htmlProps; - const controls = ariaControls ? `${ariaControls} ${baseId}` : baseId; - - const onClickRef = useLiveRef(htmlOnClick); - - const onClick = React.useCallback( - (event: React.MouseEvent) => { - onClickRef.current?.(event); - if (event.defaultPrevented) return; - - toggle?.(); - }, - [onClickRef, toggle], - ); - - return { - "aria-controls": controls, - "aria-expanded": visible, - "data-enter": visible ? "" : undefined, - "data-leave": !visible ? "" : undefined, - onClick, - ...restHtmlProps, - }; - }, -}); - -export const useDisclosure = disclosureComposableButton(); - -export const Disclosure = createComponent({ - as: "button", - memo: true, - useHook: useDisclosure, -}); diff --git a/src/disclosure/DisclosureCollapseContent.tsx b/src/disclosure/DisclosureCollapseContent.tsx deleted file mode 100644 index be5da8a22..000000000 --- a/src/disclosure/DisclosureCollapseContent.tsx +++ /dev/null @@ -1,283 +0,0 @@ -// Core Logic for transition is based on https://github.com/roginfarrer/react-collapsed -import * as React from "react"; -import { flushSync } from "react-dom"; -import { createComponent } from "reakit-system"; -import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; -import { useForkRef, useLiveRef, useUpdateEffect } from "reakit-utils"; -import raf from "raf"; - -import { createComposableHook } from "../system"; - -import { DISCLOSURE_COLLAPSE_CONTENT_KEYS } from "./__keys"; -import { DisclosureStateReturn } from "./DisclosureState"; -import { - getAutoSizeDuration, - getElementHeight, - getElementWidth, -} from "./helpers"; - -export type DisclosureCollapseContentOptions = BoxOptions & - Pick & { - /** - * Direction of the transition. - * - * @default vertical - */ - direction: "vertical" | "horizontal"; - - /** - * Size of the content. - * - * @default 0 - */ - contentSize: number; - - /** - * Duration of the transition. - * By default the duration is calculated based on the size of change. - */ - duration?: number; - - /** - * Transition Easing. - * - * @default cubic-bezier(0.4, 0, 0.2, 1) - */ - easing: string; - - /** - * Callback called before the expand transition starts. - */ - onExpandStart?: () => void; - - /** - * Callback called after the expand transition ends. - */ - onExpandEnd?: () => void; - - /** - * Callback called before the collapse transition starts. - */ - onCollapseStart?: () => void; - - /** - * Callback called after the collapse transition ends.. - */ - onCollapseEnd?: () => void; - }; - -export type DisclosureCollapseContentHTMLProps = BoxHTMLProps; - -export type DisclosureCollapseContentProps = DisclosureCollapseContentOptions & - DisclosureCollapseContentHTMLProps; - -export const disclosureCollapseComposableContent = createComposableHook< - DisclosureCollapseContentOptions, - DisclosureCollapseContentHTMLProps ->({ - name: "DisclosureCollapseContent", - compose: useBox, - keys: DISCLOSURE_COLLAPSE_CONTENT_KEYS, - - useOptions(options, htmlProps) { - const { - direction = "vertical", - contentSize = 0, - easing = "cubic-bezier(0.4, 0, 0.2, 1)", - ...restOptions - } = options; - return { direction, contentSize, easing, ...restOptions }; - }, - - useProps(options, htmlProps) { - const { - contentSize, - visible, - direction, - duration, - easing, - onCollapseEnd, - onCollapseStart, - onExpandEnd, - onExpandStart, - } = options; - const { - ref: htmlRef, - style: htmlStyle, - onTransitionEnd: htmlOnTransitionEnd, - ...restHtmlProps - } = htmlProps; - const ref = React.useRef(null); - const onTransitionEndRef = useLiveRef(htmlOnTransitionEnd); - const isVertical = direction === "vertical"; - const currentSize = isVertical ? "height" : "width"; - const getCurrentSizeStyle = React.useCallback( - (size: number) => ({ - [currentSize]: `${size}px`, - }), - [currentSize], - ); - const collapsedStyles = React.useMemo(() => { - return { - ...getCurrentSizeStyle(contentSize), - overflow: "hidden", - }; - }, [contentSize, getCurrentSizeStyle]); - - const [styles, setStylesRaw] = React.useState( - visible ? {} : collapsedStyles, - ); - const setStyles = (newStyles: {} | ((oldStyles: {}) => {})): void => { - // We rely on reading information from layout - // at arbitrary times, so ensure all style changes - // happen before we might attempt to read them. - flushSync(() => { - setStylesRaw(newStyles); - }); - }; - const mergeStyles = React.useCallback((newStyles: {}): void => { - setStyles(oldStyles => ({ ...oldStyles, ...newStyles })); - }, []); - - function getTransitionStyles(size: number | string): { - transition?: string; - } { - const _duration = duration || getAutoSizeDuration(size); - - return { - transition: `${currentSize} ${_duration}ms ${easing}`, - }; - } - - useUpdateEffect(() => { - if (visible) { - raf(() => { - onExpandStart?.(); - - mergeStyles({ - willChange: `${currentSize}`, - overflow: "hidden", - }); - - raf(() => { - const size = isVertical - ? getElementHeight(ref) - : getElementWidth(ref); - - mergeStyles({ - ...getTransitionStyles(size), - ...(isVertical ? { height: size } : { width: size }), - }); - }); - }); - } else { - raf(() => { - onCollapseStart?.(); - - const size = isVertical - ? getElementHeight(ref) - : getElementWidth(ref); - - mergeStyles({ - willChange: `${currentSize}`, - ...(isVertical ? { height: size } : { width: size }), - ...getTransitionStyles(size), - }); - raf(() => { - mergeStyles({ - ...getCurrentSizeStyle(contentSize), - overflow: "hidden", - }); - }); - }); - } - }, [visible]); - - const onTransitionEnd = React.useCallback( - (event: React.TransitionEvent) => { - onTransitionEndRef.current?.(event); - - if (event.defaultPrevented) return; - - // Sometimes onTransitionEnd is triggered by another transition, - // such as a nested collapse panel transitioning. But we only - // want to handle this if this component's element is transitioning - if ( - event.target !== ref.current || - event.propertyName !== currentSize - ) { - return; - } - - // The height comparisons below are a final check before - // completing the transition - // Sometimes this callback is run even though we've already begun - // transitioning the other direction - // The conditions give us the opportunity to bail out, - // which will prevent the collapsed content from flashing on the screen - const stylesSize = isVertical ? styles.height : styles.width; - - if (visible) { - const size = isVertical - ? getElementHeight(ref) - : getElementWidth(ref); - - // If the height at the end of the transition - // matches the height we're animating to, - if (size === stylesSize) { - setStyles({}); - } else { - // If the heights don't match, this could be due the height - // of the content changing mid-transition - mergeStyles({ - ...getCurrentSizeStyle(contentSize), - }); - } - - onExpandEnd?.(); - - // If the height we should be animating to matches the collapsed height, - // it's safe to apply the collapsed overrides - } else if (stylesSize === `${contentSize}px`) { - setStyles(collapsedStyles); - - onCollapseEnd?.(); - } - }, - [ - onTransitionEndRef, - currentSize, - isVertical, - styles.height, - styles.width, - visible, - contentSize, - onExpandEnd, - mergeStyles, - getCurrentSizeStyle, - collapsedStyles, - onCollapseEnd, - ], - ); - - const style = { ...styles, ...htmlStyle }; - - return { - ref: useForkRef(ref, htmlRef), - id: options.baseId, - "aria-hidden": !visible, - style, - onTransitionEnd, - ...restHtmlProps, - }; - }, -}); - -export const useDisclosureCollapseContent = - disclosureCollapseComposableContent(); - -export const DisclosureCollapseContent = createComponent({ - as: "div", - memo: true, - useHook: useDisclosureCollapseContent, -}); diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx deleted file mode 100644 index 9a948ae04..000000000 --- a/src/disclosure/DisclosureContent.tsx +++ /dev/null @@ -1,121 +0,0 @@ -// Inspired from Radix UI https://github.com/radix-ui/primitives/tree/main/packages/react/collapsible -import * as React from "react"; -import { createComponent } from "reakit-system"; -import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; -import { useLiveRef } from "reakit-utils"; - -import { createComposableHook } from "../system"; - -import { DISCLOSURE_CONTENT_KEYS } from "./__keys"; -import { DisclosureStateReturn } from "./DisclosureState"; -import { - TransitionState, - useAnimation, - useAnimationReturnType, -} from "./helpers"; - -export type DisclosureContentOptions = BoxOptions & - Pick & { - animationPresent?: boolean; - state: TransitionState; - animating: useAnimationReturnType["animating"]; - onEnd: useAnimationReturnType["onEnd"]; - isVisible: boolean; - isHidden: boolean; - }; - -export type DisclosureContentHTMLProps = BoxHTMLProps; - -export type DisclosureContentProps = DisclosureContentOptions & - DisclosureContentHTMLProps; - -export const disclosureComposableContent = createComposableHook< - DisclosureContentOptions, - DisclosureContentHTMLProps ->({ - name: "DisclosureContent", - compose: useBox, - keys: DISCLOSURE_CONTENT_KEYS, - - useOptions(options, htmlProps) { - const { visible, animationPresent = false, ...restOptions } = options; - const { state, animating, onEnd } = useAnimation({ - visible, - }); - - const isVisible = visible && animating; - const isHidden = !visible && !animating; - - return { - animationPresent, - visible, - ...restOptions, - isVisible, - isHidden, - state, - animating, - onEnd, - }; - }, - - useProps(options, htmlProps) { - const { baseId, onEnd, isHidden, isVisible, state, animationPresent } = - options; - const { - style: htmlStyle, - onTransitionEnd: htmlOnTransitionEnd, - onAnimationEnd: htmlOnAnimationEnd, - ...restHtmlProps - } = htmlProps; - - const onTransitionEndRef = useLiveRef(htmlOnTransitionEnd); - const onAnimationEndRef = useLiveRef(htmlOnAnimationEnd); - - const onTransitionEnd = React.useCallback( - (event: React.TransitionEvent) => { - onTransitionEndRef.current?.(event); - - onEnd?.(event); - }, - [onEnd, onTransitionEndRef], - ); - - const onAnimationEnd = React.useCallback( - (event: React.AnimationEvent) => { - onAnimationEndRef.current?.(event); - onEnd?.(event); - }, - [onAnimationEndRef, onEnd], - ); - - const style = { - display: isHidden ? "none" : undefined, - ...htmlStyle, - }; - - return { - id: baseId, - hidden: isHidden, - "data-enter": animationPresent - ? isVisible - ? "" - : undefined - : state === "enter" - ? "" - : undefined, - "data-leave": state === "leave" ? "" : undefined, - onTransitionEnd, - onAnimationEnd, - style, - ...restHtmlProps, - }; - }, -}); - -export const useDisclosureContent = disclosureComposableContent(); - -export const DisclosureContent = createComponent({ - as: "div", - memo: true, - useHook: useDisclosureContent, -}); diff --git a/src/disclosure/DisclosureState.ts b/src/disclosure/DisclosureState.ts deleted file mode 100644 index e441c5de0..000000000 --- a/src/disclosure/DisclosureState.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as React from "react"; -import { - unstable_IdActions, - unstable_IdInitialState, - unstable_IdState, - unstable_useIdState, -} from "reakit"; - -import { useControllableState } from "../utils"; - -export type DisclosureState = unstable_IdState & { - /** - * Whether it's expanded or not. - */ - visible: boolean; -}; - -export type DisclosureActions = unstable_IdActions & { - /** - * Changes the `expanded` state to `true` - */ - show: () => void; - - /** - * Changes the `expanded` state to `false` - */ - hide: () => void; - - /** - * Toggles the `expanded` state - */ - toggle: () => void; - - /** - * Sets `expanded`. - */ - setVisible: React.Dispatch>; -}; - -export type DisclosureStateReturn = DisclosureState & DisclosureActions; - -export type DisclosureInitialState = unstable_IdInitialState & - Partial> & { - /** - * Default uncontrolled state. - */ - defaultVisible?: boolean; - - /** - * Controllabele state. - */ - visible?: boolean; - - /** - * controllable state callback. - */ - onVisibleChange?: (expanded: boolean) => void; - }; - -export const useDisclosureState = ( - props: DisclosureInitialState = {}, -): DisclosureStateReturn => { - const { - defaultVisible = false, - visible: initialVisible, - onVisibleChange, - } = props; - const id = unstable_useIdState(); - const [visible, setVisible] = useControllableState({ - defaultValue: defaultVisible, - value: initialVisible, - onChange: onVisibleChange, - }); - - const show = React.useCallback(() => setVisible(true), [setVisible]); - const hide = React.useCallback(() => setVisible(false), [setVisible]); - const toggle = React.useCallback(() => setVisible(e => !e), [setVisible]); - - return { - ...id, - visible, - setVisible, - show, - hide, - toggle, - }; -}; diff --git a/src/disclosure/__keys.ts b/src/disclosure/__keys.ts deleted file mode 100644 index 5fcf02277..000000000 --- a/src/disclosure/__keys.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Automatically generated -export const DISCLOSURE_STATE_KEYS = [ - "baseId", - "unstable_idCountRef", - "visible", - "setBaseId", - "show", - "hide", - "toggle", - "setVisible", -] as const; -export const USE_DISCLOSURE_STATE_KEYS = [ - "baseId", - "visible", - "defaultVisible", - "onVisibleChange", -] as const; -export const DISCLOSURE_KEYS = DISCLOSURE_STATE_KEYS; -export const DISCLOSURE_COLLAPSE_CONTENT_KEYS = [ - ...DISCLOSURE_KEYS, - "direction", - "contentSize", - "duration", - "easing", - "onExpandStart", - "onExpandEnd", - "onCollapseStart", - "onCollapseEnd", -] as const; -export const DISCLOSURE_CONTENT_KEYS = [ - ...DISCLOSURE_KEYS, - "animationPresent", - "state", - "animating", - "onEnd", - "isVisible", - "isHidden", -] as const; diff --git a/src/disclosure/__utils.ts b/src/disclosure/__utils.ts new file mode 100644 index 000000000..55227a892 --- /dev/null +++ b/src/disclosure/__utils.ts @@ -0,0 +1,31 @@ +// https://github.com/mui-org/material-ui/blob/da362266f7c137bf671d7e8c44c84ad5cfc0e9e2/packages/material-ui/src/styles/transitions.js#L89-L98 +export function getAutoSizeDuration(size: number | string): number { + if (!size || typeof size === "string") { + return 0; + } + + const constant = size / 36; + + // https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10 + return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10); +} + +export function getElementHeight( + el: React.RefObject | { current?: { scrollHeight: number } }, +): string | number { + if (!el?.current) { + return "auto"; + } + + return el.current.scrollHeight; +} + +export function getElementWidth( + el: React.RefObject | { current?: { scrollWidth: number } }, +): string | number { + if (!el?.current) { + return "auto"; + } + + return el.current.scrollWidth; +} diff --git a/src/disclosure/disclosure-collapsible-content.ts b/src/disclosure/disclosure-collapsible-content.ts new file mode 100644 index 000000000..cc7b73d65 --- /dev/null +++ b/src/disclosure/disclosure-collapsible-content.ts @@ -0,0 +1,261 @@ +import * as React from "react"; +import { flushSync } from "react-dom"; +import { DisclosureState } from "ariakit/disclosure"; +import { + useEvent, + useForkRef, + useId, + useUpdateEffect, +} from "ariakit-utils/hooks"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; +import raf from "raf"; + +import { + getAutoSizeDuration, + getElementHeight, + getElementWidth, +} from "./__utils"; + +/** + * A component hook that returns props that can be passed to `Role` or any other + * Ariakit component to render an element that can be shown or hidden. + * @see https://ariakit.org/components/disclosure + * @example + * ```jsx + * const state = useDisclosureState(); + * const props = useDisclosureCollapsibleContent({ state }); + * Disclosure + * Content + * ``` + */ +export const useDisclosureCollapsibleContent = + createHook( + ({ + direction = "vertical", + contentSize = 0, + easing = "cubic-bezier(0.4, 0, 0.2, 1)", + state, + ...props + }) => { + const id = useId(props.id); + const contentRef = React.useRef(null); + + const isVertical = direction === "vertical"; + const currentSize = isVertical ? "height" : "width"; + const getCurrentSizeStyle = React.useCallback( + (size: number) => ({ + [currentSize]: `${size}px`, + }), + [currentSize], + ); + const collapsedStyles = React.useMemo(() => { + return { + ...getCurrentSizeStyle(contentSize), + overflow: "hidden", + }; + }, [contentSize, getCurrentSizeStyle]); + + const [styles, setStylesRaw] = React.useState( + state.visible ? {} : collapsedStyles, + ); + const setStyles = (newStyles: {} | ((oldStyles: {}) => {})): void => { + // We rely on reading information from layout + // at arbitrary times, so ensure all style changes + // happen before we might attempt to read them. + flushSync(() => { + setStylesRaw(newStyles); + }); + }; + const mergeStyles = React.useCallback((newStyles: {}): void => { + setStyles(oldStyles => ({ ...oldStyles, ...newStyles })); + }, []); + + function getTransitionStyles(size: number | string): { + transition?: string; + } { + const _duration = props.duration || getAutoSizeDuration(size); + + return { + transition: `${currentSize} ${_duration}ms ${easing}`, + }; + } + + useUpdateEffect(() => { + if (state.visible) { + raf(() => { + props.onExpandStart?.(); + + mergeStyles({ + willChange: `${currentSize}`, + overflow: "hidden", + }); + + raf(() => { + const size = isVertical + ? getElementHeight(contentRef) + : getElementWidth(contentRef); + + mergeStyles({ + ...getTransitionStyles(size), + ...(isVertical ? { height: size } : { width: size }), + }); + }); + }); + } else { + raf(() => { + props.onCollapseStart?.(); + + const size = isVertical + ? getElementHeight(contentRef) + : getElementWidth(contentRef); + + mergeStyles({ + willChange: `${currentSize}`, + ...(isVertical ? { height: size } : { width: size }), + ...getTransitionStyles(size), + }); + raf(() => { + mergeStyles({ + ...getCurrentSizeStyle(contentSize), + overflow: "hidden", + }); + }); + }); + } + }, [state.visible]); + + const onTransitionEndProp = props.onTransitionEnd; + const onTransitionEnd = useEvent( + (event: React.TransitionEvent) => { + onTransitionEndProp?.(event); + + if (event.defaultPrevented) return; + + // Sometimes onTransitionEnd is triggered by another transition, + // such as a nested collapse panel transitioning. But we only + // want to handle this if this component's element is transitioning + if ( + event.target !== contentRef.current || + event.propertyName !== currentSize + ) { + return; + } + + // The height comparisons below are a final check before + // completing the transition + // Sometimes this callback is run even though we've already begun + // transitioning the other direction + // The conditions give us the opportunity to bail out, + // which will prevent the collapsed content from flashing on the screen + const stylesSize = isVertical ? styles.height : styles.width; + + if (state.visible) { + const size = isVertical + ? getElementHeight(contentRef) + : getElementWidth(contentRef); + + // If the height at the end of the transition + // matches the height we're animating to, + if (size === stylesSize) { + setStyles({}); + } else { + // If the heights don't match, this could be due the height + // of the content changing mid-transition + mergeStyles({ + ...getCurrentSizeStyle(contentSize), + }); + } + + props.onExpandEnd?.(); + + // If the height we should be animating to matches the collapsed height, + // it's safe to apply the collapsed overrides + } else if (stylesSize === `${contentSize}px`) { + setStyles(collapsedStyles); + + props.onCollapseEnd?.(); + } + }, + ); + + const style = { ...styles, ...props.style }; + + props = { + id, + hidden: !state.mounted, + ...props, + ref: useForkRef( + id ? state.setContentElement : null, + contentRef, + props.ref, + ), + onTransitionEnd, + style, + }; + + return props; + }, + ); + +export const DisclosureCollapsibleContent = + createComponent(props => { + const htmlProps = useDisclosureCollapsibleContent(props); + + return createElement("div", htmlProps); + }); + +export type DisclosureCollapsibleContentOptions = + Options & { + /** + * Object returned by the `useDisclosureState` hook. + */ + state: DisclosureState; + /** + * Direction of the transition. + * + * @default vertical + */ + direction?: "vertical" | "horizontal"; + /** + * Size of the content. + * + * @default 0 + */ + contentSize?: number; + /** + * Transition Easing. + * + * @default cubic-bezier(0.4, 0, 0.2, 1) + */ + easing?: string; + /** + * Duration of the transition. + * By default the duration is calculated based on the size of change. + */ + duration?: number; + /** + * Callback called before the expand transition starts. + */ + onExpandStart?: () => void; + /** + * Callback called after the expand transition ends. + */ + onExpandEnd?: () => void; + /** + * Callback called before the collapse transition starts. + */ + onCollapseStart?: () => void; + /** + * Callback called after the collapse transition ends.. + */ + onCollapseEnd?: () => void; + }; + +export type DisclosureCollapsibleContentProps = Props< + DisclosureCollapsibleContentOptions +>; diff --git a/src/disclosure/helpers.ts b/src/disclosure/helpers.ts deleted file mode 100644 index 8bc3b2a69..000000000 --- a/src/disclosure/helpers.ts +++ /dev/null @@ -1,104 +0,0 @@ -import * as React from "react"; -import { isSelfTarget } from "reakit-utils"; -import { useSafeLayoutEffect } from "@chakra-ui/hooks"; - -function useLastValue(value: T) { - const lastValue = React.useRef(null); - - useSafeLayoutEffect(() => { - lastValue.current = value; - }, [value]); - - return lastValue; -} - -export type useAnimationProps = { - visible: boolean; -}; - -export const useAnimation = (props: useAnimationProps) => { - const { visible } = props; - - const [animating, setAnimating] = React.useState(false); - const lastVisible = useLastValue(visible); - const visibleHasChanged = - lastVisible.current != null && lastVisible.current !== visible; - const [state, setState] = React.useState(null); - const raf = React.useRef(0); - - if (!animating && visibleHasChanged) { - // Sets animating to true when when visible is updated - setAnimating(true); - } - - React.useEffect(() => { - // Double RAF is needed so the browser has enough time to paint the - // default styles before processing the `data-enter` attribute. Otherwise - // it wouldn't be considered a transition. - // See https://github.com/reakit/reakit/issues/643 - raf.current = window.requestAnimationFrame(() => { - raf.current = window.requestAnimationFrame(() => { - if (visible) { - setState("enter"); - } else if (animating) { - setState("leave"); - } else { - setState(null); - } - }); - }); - - return () => window.cancelAnimationFrame(raf.current); - }, [visible, animating]); - - const stopAnimation = React.useCallback(() => setAnimating(false), []); - - const onEnd = React.useCallback( - (event: React.SyntheticEvent) => { - if (!isSelfTarget(event)) return; - if (!animating) return; - - // Ignores number animated - stopAnimation(); - }, - [animating, stopAnimation], - ); - - return { state, animating, onEnd }; -}; - -export type useAnimationReturnType = ReturnType; - -export type TransitionState = "enter" | "leave" | null; - -export function getElementHeight( - el: React.RefObject | { current?: { scrollHeight: number } }, -): string | number { - if (!el?.current) { - return "auto"; - } - - return el.current.scrollHeight; -} - -export function getElementWidth( - el: React.RefObject | { current?: { scrollWidth: number } }, -): string | number { - if (!el?.current) { - return "auto"; - } - - return el.current.scrollWidth; -} - -// https://github.com/mui-org/material-ui/blob/da362266f7c137bf671d7e8c44c84ad5cfc0e9e2/packages/material-ui/src/styles/transitions.js#L89-L98 -export function getAutoSizeDuration(size: number | string): number { - if (!size || typeof size === "string") { - return 0; - } - - const constant = size / 36; - - // https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10 - return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10); -} diff --git a/src/disclosure/index.ts b/src/disclosure/index.ts index 591ed0ab2..f11215a8d 100644 --- a/src/disclosure/index.ts +++ b/src/disclosure/index.ts @@ -1,5 +1 @@ -export * from "./__keys"; -export * from "./Disclosure"; -export * from "./DisclosureCollapseContent"; -export * from "./DisclosureContent"; -export * from "./DisclosureState"; +export * from "./disclosure-collapsible-content"; diff --git a/src/disclosure/stories/DisclosureBasic.component.tsx b/src/disclosure/stories/DisclosureBasic.component.tsx deleted file mode 100644 index 84125ad55..000000000 --- a/src/disclosure/stories/DisclosureBasic.component.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from "react"; - -import { - Disclosure, - DisclosureContent, - DisclosureInitialState, - useDisclosureState, -} from "../../index"; - -export type DisclosureBasicProps = DisclosureInitialState & {}; - -export const DisclosureBasic: React.FC = props => { - const state = useDisclosureState(props); - - return ( -
- Show More - - Item 1 - Item 2 - Item 3 - Item 4 - Item 5 - Item 6 - -
- ); -}; - -export default DisclosureBasic; diff --git a/src/disclosure/stories/DisclosureBasic.css b/src/disclosure/stories/DisclosureBasic.css deleted file mode 100644 index 8166f139a..000000000 --- a/src/disclosure/stories/DisclosureBasic.css +++ /dev/null @@ -1,20 +0,0 @@ -.content { - display: flex; - flex-direction: column; - overflow: hidden; -} - -.content { - transition: opacity 250ms ease-in-out, transform 250ms ease-in-out; - opacity: 0; - transform: translate3d(0, -100%, 0); -} - -.content[data-enter] { - opacity: 1; - transform: translate3d(0, 0, 0); -} - -.content[data-leave] { - transform: translate3d(0, 100%, 0); -} diff --git a/src/disclosure/stories/DisclosureBasic.stories.tsx b/src/disclosure/stories/DisclosureBasic.stories.tsx deleted file mode 100644 index 2c6bda881..000000000 --- a/src/disclosure/stories/DisclosureBasic.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createPreviewTabs } from "../../../.storybook/utils"; -import { DisclosureState } from "../index"; - -import css from "./templates/DisclosureBasicCss"; -import js from "./templates/DisclosureBasicJsx"; -import ts from "./templates/DisclosureBasicTsx"; -import { - DisclosureBasic, - DisclosureBasicProps, -} from "./DisclosureBasic.component"; - -import "./DisclosureBasic.css"; - -export default { - component: DisclosureBasic, - title: "Disclosure/Basic", - parameters: { - layout: "centered", - options: { showPanel: true }, - preview: createPreviewTabs({ js, ts, css }), - }, -} as Meta; - -export const Default: Story = args => ( - -); - -export const Controlled = () => { - const [value, setValue] = React.useState(false); - console.log("%cvalue", "color: #997326", value); - - return ; -}; diff --git a/src/disclosure/stories/DisclosureCollapseHorizontal.stories.tsx b/src/disclosure/stories/DisclosureCollapseHorizontal.stories.tsx deleted file mode 100644 index 0d357d8c8..000000000 --- a/src/disclosure/stories/DisclosureCollapseHorizontal.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createPreviewTabs } from "../../../.storybook/utils"; -import { DisclosureState } from "../index"; - -import js from "./templates/DisclosureCollapseHorizontalJsx"; -import ts from "./templates/DisclosureCollapseHorizontalTsx"; -import { - DisclosureCollapseHorizontal, - DisclosureCollapseHorizontalProps, -} from "./DisclosureCollapseHorizontal.component"; - -export default { - component: DisclosureCollapseHorizontal, - title: "Disclosure/CollapseHorizontal", - parameters: { - layout: "centered", - options: { showPanel: true }, - preview: createPreviewTabs({ js, ts }), - }, -} as Meta; - -export const Default: Story = args => ( - -); - -export const Controlled = () => { - const [value, setValue] = React.useState(false); - console.log("%cvalue", "color: #997326", value); - - return ( - - ); -}; diff --git a/src/disclosure/stories/DisclosureCollapseVertical.component.tsx b/src/disclosure/stories/DisclosureCollapseVertical.component.tsx deleted file mode 100644 index f23a73cdd..000000000 --- a/src/disclosure/stories/DisclosureCollapseVertical.component.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from "react"; - -import { - Disclosure, - DisclosureCollapseContent, - DisclosureInitialState, - useDisclosureState, -} from "../../index"; - -export type DisclosureCollapseVerticalProps = DisclosureInitialState & {}; - -export const DisclosureCollapseVertical: React.FC< - DisclosureCollapseVerticalProps -> = props => { - const state = useDisclosureState(props); - - return ( -
- Show More - - Item 1 - Item 2 - Item 3 - Item 4 - Item 5 - Item 6 - -
- ); -}; - -export default DisclosureCollapseVertical; diff --git a/src/disclosure/stories/DisclosureCollapseVertical.stories.tsx b/src/disclosure/stories/DisclosureCollapseVertical.stories.tsx deleted file mode 100644 index acd6c4d60..000000000 --- a/src/disclosure/stories/DisclosureCollapseVertical.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createPreviewTabs } from "../../../.storybook/utils"; -import { DisclosureState } from "../index"; - -import js from "./templates/DisclosureCollapseVerticalJsx"; -import ts from "./templates/DisclosureCollapseVerticalTsx"; -import { - DisclosureCollapseVertical, - DisclosureCollapseVerticalProps, -} from "./DisclosureCollapseVertical.component"; - -export default { - component: DisclosureCollapseVertical, - title: "Disclosure/CollapseVertical", - parameters: { - layout: "centered", - options: { showPanel: true }, - preview: createPreviewTabs({ js, ts }), - }, -} as Meta; - -export const Default: Story = args => ( - -); - -export const Controlled = () => { - const [value, setValue] = React.useState(false); - console.log("%cvalue", "color: #997326", value); - - return ( - - ); -}; diff --git a/src/disclosure/stories/DisclosureHorizontal.component.tsx b/src/disclosure/stories/DisclosureHorizontal.component.tsx deleted file mode 100644 index 8329e27ed..000000000 --- a/src/disclosure/stories/DisclosureHorizontal.component.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from "react"; - -import { - Disclosure, - DisclosureContent, - DisclosureInitialState, - useDisclosureState, -} from "../../index"; - -export type DisclosureHorizontalProps = DisclosureInitialState & {}; - -export const DisclosureHorizontal: React.FC< - DisclosureHorizontalProps -> = props => { - const state = useDisclosureState(props); - - return ( -
- Show More - -
Item 1
-
Item 2
-
Item 3
-
Item 4
-
Item 5
-
Item 6
-
-
- ); -}; - -export default DisclosureHorizontal; diff --git a/src/disclosure/stories/DisclosureHorizontal.css b/src/disclosure/stories/DisclosureHorizontal.css deleted file mode 100644 index 4279463ed..000000000 --- a/src/disclosure/stories/DisclosureHorizontal.css +++ /dev/null @@ -1,46 +0,0 @@ -.root { - display: flex; - width: 100%; -} -.content { - display: flex; - flex-direction: row; - /* opacity: 0; */ -} - -.content { - transform-origin: top center; -} - -.content[data-enter] { - /* opacity: 1; */ - animation: fadedIn 500ms ease-in-out; -} - -.content[data-leave] { - animation: fadedOut 500ms ease-in-out; -} - -@keyframes fadedIn { - from { - opacity: 0; - transform: translate(-10px, 0); - } - - to { - opacity: 1; - transform: translate(0, 0px); - } -} - -@keyframes fadedOut { - from { - opacity: 1; - transform: translate(0, 0px); - } - - to { - opacity: 0; - transform: translate(-10px, 0); - } -} diff --git a/src/disclosure/stories/DisclosureHorizontal.stories.tsx b/src/disclosure/stories/DisclosureHorizontal.stories.tsx deleted file mode 100644 index 7c4534584..000000000 --- a/src/disclosure/stories/DisclosureHorizontal.stories.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; - -import { createPreviewTabs } from "../../../.storybook/utils"; -import { DisclosureState } from "../DisclosureState"; - -import css from "./templates/DisclosureBasicCss"; -import js from "./templates/DisclosureBasicJsx"; -import ts from "./templates/DisclosureBasicTsx"; -import { - DisclosureHorizontal, - DisclosureHorizontalProps, -} from "./DisclosureHorizontal.component"; - -import "./DisclosureHorizontal.css"; - -export default { - component: DisclosureHorizontal, - title: "Disclosure/Horizontal", - parameters: { - layout: "centered", - options: { showPanel: true }, - preview: createPreviewTabs({ js, ts, css }), - }, -} as Meta; - -export const Default: Story = args => ( - -); - -export const Controlled = () => { - const [value, setValue] = React.useState(false); - console.log("%cvalue", "color: #997326", value); - - return ; -}; diff --git a/src/disclosure/stories/DisclosureCollapseHorizontal.component.tsx b/src/disclosure/stories/DisclosureHorizontalCollapseBasic.component.tsx similarity index 54% rename from src/disclosure/stories/DisclosureCollapseHorizontal.component.tsx rename to src/disclosure/stories/DisclosureHorizontalCollapseBasic.component.tsx index ac33fbff7..0a1ea8429 100644 --- a/src/disclosure/stories/DisclosureCollapseHorizontal.component.tsx +++ b/src/disclosure/stories/DisclosureHorizontalCollapseBasic.component.tsx @@ -1,29 +1,25 @@ import * as React from "react"; +import { Disclosure, DisclosureStateProps, useDisclosureState } from "ariakit"; -import { - Disclosure, - DisclosureCollapseContent, - DisclosureInitialState, - useDisclosureState, -} from "../../index"; +import { DisclosureCollapsibleContent } from "../../index"; -export type DisclosureCollapseHorizontalProps = DisclosureInitialState & {}; +export type DisclosureHorizontalCollapseBasicProps = DisclosureStateProps & {}; -export const DisclosureCollapseHorizontal: React.FC< - DisclosureCollapseHorizontalProps +export const DisclosureHorizontalCollapseBasic: React.FC< + DisclosureHorizontalCollapseBasicProps > = props => { const state = useDisclosureState(props); return (
- Show More - Show More + Item 1 Item 2 @@ -31,9 +27,9 @@ export const DisclosureCollapseHorizontal: React.FC< Item 4 Item 5 Item 6 - +
); }; -export default DisclosureCollapseHorizontal; +export default DisclosureHorizontalCollapseBasic; diff --git a/src/disclosure/stories/DisclosureHorizontalCollapseBasic.stories.tsx b/src/disclosure/stories/DisclosureHorizontalCollapseBasic.stories.tsx new file mode 100644 index 000000000..752d851d8 --- /dev/null +++ b/src/disclosure/stories/DisclosureHorizontalCollapseBasic.stories.tsx @@ -0,0 +1,23 @@ +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/DisclosureHorizontalCollapseBasicJsx"; +import ts from "./templates/DisclosureHorizontalCollapseBasicTsx"; +import { DisclosureHorizontalCollapseBasic } from "./DisclosureHorizontalCollapseBasic.component"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + +export default { + title: "Disclosure/Horizontal", + component: DisclosureHorizontalCollapseBasic, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = { + args: {}, +}; diff --git a/src/disclosure/stories/DisclosureVerticalCollapseBasic.component.tsx b/src/disclosure/stories/DisclosureVerticalCollapseBasic.component.tsx new file mode 100644 index 000000000..7bb4cd1d0 --- /dev/null +++ b/src/disclosure/stories/DisclosureVerticalCollapseBasic.component.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { Disclosure, DisclosureStateProps, useDisclosureState } from "ariakit"; + +import { DisclosureCollapsibleContent } from "../../index"; + +export type DisclosureVerticalCollapseBasicProps = DisclosureStateProps & {}; + +export const DisclosureVerticalCollapseBasic: React.FC< + DisclosureVerticalCollapseBasicProps +> = props => { + const state = useDisclosureState(props); + + return ( +
+ Show More + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + +
+ ); +}; + +export default DisclosureVerticalCollapseBasic; diff --git a/src/disclosure/stories/DisclosureVerticalCollapseBasic.stories.tsx b/src/disclosure/stories/DisclosureVerticalCollapseBasic.stories.tsx new file mode 100644 index 000000000..cbcb2a3d7 --- /dev/null +++ b/src/disclosure/stories/DisclosureVerticalCollapseBasic.stories.tsx @@ -0,0 +1,23 @@ +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/DisclosureVerticalCollapseBasicJsx"; +import ts from "./templates/DisclosureVerticalCollapseBasicTsx"; +import { DisclosureVerticalCollapseBasic } from "./DisclosureVerticalCollapseBasic.component"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + +export default { + title: "Disclosure/Vertical", + component: DisclosureVerticalCollapseBasic, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = { + args: {}, +}; diff --git a/src/drawer/Drawer.ts b/src/drawer/Drawer.ts deleted file mode 100644 index de92dca05..000000000 --- a/src/drawer/Drawer.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { DialogHTMLProps, DialogOptions, useDialog } from "../dialog"; -import { createComponent, createHook } from "../system"; - -import { DRAWER_KEYS } from "./__keys"; - -export type DrawerOptions = DialogOptions & { - /** - * Direction to place the drawer. - * - * @default left - */ - placement: Placement; -}; - -export type DrawerHTMLProps = DialogHTMLProps; - -export type DrawerProps = DrawerOptions & DrawerHTMLProps; - -export const useDrawer = createHook({ - name: "Drawer", - compose: useDialog, - keys: DRAWER_KEYS, - - useOptions(options, htmlProps) { - const { placement = "left" } = options; - - return { - ...options, - placement, - }; - }, - - useProps(options, htmlProps) { - const { placement } = options; - const { style: htmlStyles, ...restHtmlProps } = htmlProps; - - return { - style: { - ...PLACEMENTS[placement], - position: "fixed", - ...htmlStyles, - }, - ...restHtmlProps, - }; - }, -}); - -export const Drawer = createComponent({ - as: "div", - useHook: useDrawer, -}); - -export type Placement = keyof typeof PLACEMENTS; - -const PLACEMENTS = { - left: { - left: 0, - top: 0, - bottom: 0, - height: "100vh", - }, - right: { - right: 0, - top: 0, - bottom: 0, - height: "100vh", - }, - top: { - right: 0, - left: 0, - top: 0, - width: "100vw", - }, - bottom: { - right: 0, - left: 0, - bottom: 0, - width: "100vw", - }, -}; diff --git a/src/drawer/DrawerBackdrop.ts b/src/drawer/DrawerBackdrop.ts deleted file mode 100644 index ed8a7dae5..000000000 --- a/src/drawer/DrawerBackdrop.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - DialogBackdropHTMLProps, - DialogBackdropOptions, - useDialogBackdrop, -} from "../dialog"; -import { createComponent, createHook } from "../system"; - -import { DRAWER_BACKDROP_KEYS } from "./__keys"; - -export type DrawerBackdropOptions = DialogBackdropOptions; - -export type DrawerBackdropHTMLProps = DialogBackdropHTMLProps; - -export type DrawerBackdropProps = DrawerBackdropOptions & - DrawerBackdropHTMLProps; - -export const useDrawerBackdrop = createHook< - DrawerBackdropOptions, - DrawerBackdropHTMLProps ->({ - name: "DrawerBackdrop", - compose: useDialogBackdrop, - keys: DRAWER_BACKDROP_KEYS, - - useOptions({ modal = false, ...options }) { - return { modal, ...options }; - }, -}); - -export const DrawerBackdrop = createComponent({ - as: "div", - memo: true, - useHook: useDrawerBackdrop, -}); diff --git a/src/drawer/DrawerDisclosure.tsx b/src/drawer/DrawerDisclosure.tsx deleted file mode 100644 index 20652f79d..000000000 --- a/src/drawer/DrawerDisclosure.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { - DialogDisclosureHTMLProps, - DialogDisclosureOptions, - useDialogDisclosure, -} from "../dialog"; -import { createComponent, createHook } from "../system"; - -import { DRAWER_DISCLOSURE_KEYS } from "./__keys"; - -export type DrawerDisclosureOptions = DialogDisclosureOptions & {}; -export type DrawerDisclosureHTMLProps = DialogDisclosureHTMLProps; - -export type DrawerDisclosureProps = DrawerDisclosureOptions & - DrawerDisclosureHTMLProps; - -export const useDrawerDisclosure = createHook< - DrawerDisclosureOptions, - DrawerDisclosureHTMLProps ->({ - name: "DrawerDisclosure", - compose: useDialogDisclosure, - keys: DRAWER_DISCLOSURE_KEYS, - - useOptions(options, htmlProps) { - return options; - }, - - useProps(options, htmlProps) { - return htmlProps; - }, -}); - -export const DrawerDisclosure = createComponent({ - as: "button", - memo: true, - useHook: useDrawerDisclosure, -}); diff --git a/src/drawer/DrawerState.ts b/src/drawer/DrawerState.ts deleted file mode 100644 index f27d29e5c..000000000 --- a/src/drawer/DrawerState.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - DialogActions, - DialogInitialState, - DialogState, - useDialogState, -} from "../dialog"; - -export type DrawerState = DialogState & {}; - -export type DrawerActions = DialogActions & {}; - -export type DrawerInitialState = DialogInitialState; - -export type DrawerStateReturn = DrawerState & DrawerActions; - -export function useDrawerState( - props: DrawerInitialState = {}, -): DrawerStateReturn { - const dialog = useDialogState(props); - - return { - ...dialog, - }; -} diff --git a/src/drawer/__keys.ts b/src/drawer/__keys.ts deleted file mode 100644 index cef160ba5..000000000 --- a/src/drawer/__keys.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Automatically generated -export const USE_DRAWER_STATE_KEYS = [ - "baseId", - "visible", - "defaultVisible", - "onVisibleChange", - "modal", -] as const; -export const DRAWER_STATE_KEYS = [ - "baseId", - "unstable_idCountRef", - "visible", - "modal", - "disclosureRef", - "setBaseId", - "show", - "hide", - "toggle", - "setVisible", - "setModal", -] as const; -export const DRAWER_KEYS = [...DRAWER_STATE_KEYS, "placement"] as const; -export const DRAWER_BACKDROP_KEYS = DRAWER_STATE_KEYS; -export const DRAWER_DISCLOSURE_KEYS = DRAWER_BACKDROP_KEYS; diff --git a/src/drawer/index.ts b/src/drawer/index.ts deleted file mode 100644 index 2933bf42d..000000000 --- a/src/drawer/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./__keys"; -export * from "./Drawer"; -export * from "./DrawerBackdrop"; -export * from "./DrawerDisclosure"; -export * from "./DrawerState"; diff --git a/src/drawer/stories/DrawerBasic.component.tsx b/src/drawer/stories/DrawerBasic.component.tsx deleted file mode 100644 index 4c9d8901b..000000000 --- a/src/drawer/stories/DrawerBasic.component.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from "react"; -import { css } from "@emotion/css"; - -import { - Drawer, - DrawerBackdrop, - DrawerDisclosure, - DrawerInitialState, - Placement, - useDrawerState, -} from "../../index"; - -export const DrawerBasic: React.FC = props => { - const drawer = useDrawerState(props); - const inputRef = React.useRef(null); - const [placement, setPlacement] = React.useState("left"); - - return ( -
- {`Open Drawer`} - - - - X -

Welcome to Reakit!

- -
-
-
- ); -}; - -export default DrawerBasic; - -const backdropStyles = css` - opacity: 0; - position: fixed; - top: 0; - left: 0; - bottom: 0; - right: 0; - transition: opacity 250ms ease-in-out; - background-color: rgba(0, 0, 0, 0.2); - &[data-enter] { - opacity: 1; - } -`; - -const cssTransforms = { - top: "translate(0, -200px)", - bottom: "translate(0, 200px)", - left: "translate(-200px, 0)", - right: "translate(200px, 0)", -}; diff --git a/src/index.ts b/src/index.ts index 5e70f503b..f75835b0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,24 +1,15 @@ export * from "./accordion"; export * from "./breadcrumbs"; export * from "./calendar"; -export * from "./checkbox"; +export * from "./datefield"; export * from "./datepicker"; export * from "./dialog"; export * from "./disclosure"; -export * from "./drawer"; export * from "./link"; export * from "./meter"; -export * from "./number-input"; +export * from "./numberfield"; export * from "./pagination"; -export * from "./picker-base"; -export * from "./popover"; export * from "./progress"; -export * from "./radio"; -export * from "./segment"; -export * from "./select"; export * from "./slider"; -export * from "./system"; -export * from "./timepicker"; +export * from "./timefield"; export * from "./toast"; -export * from "./tooltip"; -export * from "./utils"; diff --git a/src/link/Link.ts b/src/link/Link.ts deleted file mode 100644 index e7e176491..000000000 --- a/src/link/Link.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as React from "react"; -import { ClickableHTMLProps, ClickableOptions, useClickable } from "reakit"; -import { useForkRef } from "reakit-utils"; -import { useWarning } from "reakit-warning"; - -import { createComponent, createHook } from "../system"; - -import { LINK_KEYS } from "./__keys"; - -export type LinkOptions = ClickableOptions & { - /** - * Opens the link in a new tab - */ - isExternal?: boolean; -}; - -export type LinkHTMLProps = ClickableHTMLProps; - -export type LinkProps = LinkOptions & LinkHTMLProps; - -export const useLink = createHook({ - name: "Link", - compose: useClickable, - keys: LINK_KEYS, - - useOptions(options) { - return { unstable_clickOnSpace: false, ...options }; - }, - - useProps({ isExternal }, { ref: htmlRef, ...htmlProps }) { - const ref = React.useRef(null); - const [role, setRole] = React.useState<"link" | undefined>(undefined); - - React.useEffect(() => { - const element = ref.current; - - if (!element) { - // eslint-disable-next-line react-hooks/rules-of-hooks - useWarning( - true, - "Can't determine whether the element is a native link because `ref` wasn't passed to the component", - ); - return; - } - - if (element.tagName !== "A") { - setRole("link"); - } - }, []); - - return { - ref: useForkRef(ref, htmlRef), - role, - ...(isExternal && { target: "_blank", rel: "noopener noreferrer" }), - ...htmlProps, - }; - }, -}); - -export const Link = createComponent({ - as: "a", - memo: true, - useHook: useLink, -}); diff --git a/src/link/__keys.ts b/src/link/__keys.ts deleted file mode 100644 index c70420987..000000000 --- a/src/link/__keys.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Automatically generated -export const LINK_KEYS = ["isExternal"] as const; diff --git a/src/link/__tests__/Link.test.tsx b/src/link/__tests__/Link.test.tsx deleted file mode 100644 index 58d9c5249..000000000 --- a/src/link/__tests__/Link.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import * as React from "react"; -import { axe, render } from "reakit-test-utils"; - -import { Link } from "../index"; - -describe("ReakitLink", () => { - it("should render correctly", () => { - const { baseElement } = render(link); - - expect(baseElement).toMatchInlineSnapshot(` - - - - `); - }); - - it("should render correctly with isExternal", () => { - const { baseElement } = render( - - link - , - ); - - expect(baseElement).toMatchInlineSnapshot(` - - - - `); - }); - - it("should render correctly with isExternal & disabled", () => { - const { baseElement } = render( - - link - , - ); - - expect(baseElement).toMatchInlineSnapshot(` - - - - `); - }); - - it("should render correctly with non native link tag", () => { - const { baseElement } = render( - alert("Custom Link")}> - Reakit - , - ); - - expect(baseElement).toMatchInlineSnapshot(` - -
- - Reakit - -
- - `); - }); - - test("Link renders with no a11y violations", async () => { - const { container } = render(link); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/link/index.ts b/src/link/index.ts index 1161ac6db..5d429140a 100644 --- a/src/link/index.ts +++ b/src/link/index.ts @@ -1,2 +1 @@ -export * from "./__keys"; -export * from "./Link"; +export * from "./link-base"; diff --git a/src/link/link-base.ts b/src/link/link-base.ts new file mode 100644 index 000000000..2bea094b4 --- /dev/null +++ b/src/link/link-base.ts @@ -0,0 +1,59 @@ +import * as React from "react"; +import { CommandOptions, useCommand } from "ariakit"; +import { useForkRef, useTagName } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Props } from "ariakit-utils/types"; + +export const useLink = createHook( + ({ isExternal = false, ...props }) => { + const ref = React.useRef(null); + const tagName = useTagName(ref, props.as || "a"); + const [isNativeLink, setIsNativeLink] = React.useState( + () => !!tagName && isLink({ tagName }), + ); + + React.useEffect(() => { + if (!ref.current) return; + + setIsNativeLink(isLink(ref.current)); + }, []); + + props = { + role: !isNativeLink && tagName !== "a" ? "link" : undefined, + ...(isExternal && { target: "_blank", rel: "noopener noreferrer" }), + ...props, + ref: useForkRef(ref, props.ref), + }; + + props = useCommand({ clickOnSpace: false, ...props }); + + return props; + }, +); + +export const Link = createComponent(props => { + const htmlProps = useLink(props); + + return createElement("a", htmlProps); +}); + +export type LinkOptions = CommandOptions & { + /** + * Opens the link in a new tab + * @default false + */ + isExternal?: boolean; +}; + +export type LinkProps = Props>; + +export function isLink(element: { tagName: string }) { + const tagName = element.tagName.toLowerCase(); + if (tagName === "a") return true; + + return false; +} diff --git a/src/link/stories/LinkBasic.component.tsx b/src/link/stories/LinkBasic.component.tsx index db01f0d38..4ad90eae6 100644 --- a/src/link/stories/LinkBasic.component.tsx +++ b/src/link/stories/LinkBasic.component.tsx @@ -1,15 +1,11 @@ import * as React from "react"; -import { - Link as RenderlesskitLink, - LinkProps as RenderlesskitLinkProps, -} from "../../index"; +import { Link, LinkProps } from "../../index"; -export type LinkProps = RenderlesskitLinkProps & - React.AnchorHTMLAttributes<"a"> & {}; +export type LinkBasicProps = LinkProps & {}; -export const Link: React.FC = props => { - return Reakit; +export const LinkBasic: React.FC = props => { + return ; }; -export default Link; +export default LinkBasic; diff --git a/src/link/stories/LinkBasic.stories.tsx b/src/link/stories/LinkBasic.stories.tsx index 9f9de260e..0cb1aa54c 100644 --- a/src/link/stories/LinkBasic.stories.tsx +++ b/src/link/stories/LinkBasic.stories.tsx @@ -1,48 +1,40 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; import js from "./templates/LinkBasicJsx"; import ts from "./templates/LinkBasicTsx"; -import { Link, LinkProps } from "./LinkBasic.component"; +import { LinkBasic } from "./LinkBasic.component"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; export default { - component: Link, title: "Link/Basic", + component: LinkBasic, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts }), }, } as Meta; -export const Default: Story = args => ; -Default.args = { - href: "#", -}; - -export const ExternalLink = Default.bind({}); -ExternalLink.args = { - href: "https://reakit.io/", - isExternal: true, +export const Default: Story = { + args: { href: "https://timeless.co/", children: "Timeless" }, }; -export const SpanLink = Default.bind({}); -SpanLink.args = { - as: "span", - onClick: () => alert("Custom Link"), -}; - -export const DisabledExternalLink = Default.bind({}); -DisabledExternalLink.args = { - href: "https://reakit.io/", - isExternal: true, - disabled: true, +export const ExternlaLink: Story = { + args: { + children: "Timeless", + href: "https://timeless.co/", + isExternal: true, + }, }; -export const DisabledSpanLink = Default.bind({}); -DisabledSpanLink.args = { - as: "span", - onClick: () => alert("Custom Link"), - disabled: true, +export const DisabledExternalLink: Story = { + args: { + children: "Timeless", + href: "https://timeless.co/", + isExternal: true, + disabled: true, + }, }; diff --git a/src/link/stories/LinkSpan.component.tsx b/src/link/stories/LinkSpan.component.tsx new file mode 100644 index 000000000..ef1608b34 --- /dev/null +++ b/src/link/stories/LinkSpan.component.tsx @@ -0,0 +1,11 @@ +import * as React from "react"; + +import { Link, LinkProps } from "../../index"; + +export type LinkSpanProps = LinkProps & {}; + +export const LinkSpan: React.FC = props => { + return ; +}; + +export default LinkSpan; diff --git a/src/link/stories/LinkSpan.stories.tsx b/src/link/stories/LinkSpan.stories.tsx new file mode 100644 index 000000000..e1c861e87 --- /dev/null +++ b/src/link/stories/LinkSpan.stories.tsx @@ -0,0 +1,48 @@ +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/LinkSpanJsx"; +import ts from "./templates/LinkSpanTsx"; +import { LinkSpan } from "./LinkSpan.component"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + +export default { + title: "Link/Span", + component: LinkSpan, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = { + args: { + children: "Timeless", + onClick: event => goToLink(event, "https://timeless.co/"), + onKeyDown: event => goToLink(event, "https://timeless.co/"), + }, +}; + +export const DisabledLink: Story = { + args: { + children: "Timeless", + onClick: event => goToLink(event, "https://timeless.co/"), + onKeyDown: event => goToLink(event, "https://timeless.co/"), + disabled: true, + }, +}; + +function goToLink(event: React.MouseEvent | React.KeyboardEvent, url: string) { + var type = event.type; + + // @ts-ignore + if (type === "click" || (type === "keydown" && event.key === "Enter")) { + window.location.href = url; + + event.preventDefault(); + event.stopPropagation(); + } +} diff --git a/src/meter/Meter.ts b/src/meter/Meter.ts deleted file mode 100644 index 48ed7c6c5..000000000 --- a/src/meter/Meter.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createComponent, createHook, useCreateElement } from "reakit-system"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { useWarning } from "reakit-warning"; - -import { METER_KEYS } from "./__keys"; -import { MeterStateReturn } from "./MeterState"; - -export type MeterOptions = RoleOptions & - Pick; - -export type MeterHTMLProps = RoleHTMLProps; - -export type MeterProps = MeterOptions & MeterHTMLProps; - -export const useMeter = createHook({ - name: "Meter", - compose: useRole, - keys: METER_KEYS, - - useProps(options, htmlProps) { - const { value, max, min, percent } = options; - - // Use the meter role if available, but fall back to progressbar if not - // Chrome currently falls back from meter automatically, and Firefox - // does not support meter at all. Safari 13+ seems to support meter properly. - // https://bugs.chromium.org/p/chromium/issues/detail?id=944542 - // https://bugzilla.mozilla.org/show_bug.cgi?id=1460378 - // @see https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/meter/src/useMeter.ts - return { - role: "meter progressbar", - "aria-valuemax": max, - "aria-valuemin": min, - "aria-valuenow": value, - "aria-valuetext": !percent.toString() ? undefined : `${percent}%`, - ...htmlProps, - }; - }, -}); - -export const Meter = createComponent({ - as: "div", - memo: true, - useHook: useMeter, - useCreateElement: (type, props, children) => { - useWarning( - !props["aria-label"] && !props["aria-labelledby"], - "You should provide either `aria-label` or `aria-labelledby` props.", - "See https://w3c.github.io/aria-practices/#wai-aria-roles-states-and-properties-15", - ); - return useCreateElement(type, props, children); - }, -}); diff --git a/src/meter/__keys.ts b/src/meter/__keys.ts deleted file mode 100644 index 56c0112d0..000000000 --- a/src/meter/__keys.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Automatically generated -export const USE_METER_STATE_KEYS = [ - "value", - "min", - "max", - "low", - "optimum", - "high", -] as const; -export const METER_STATE_KEYS = [ - ...USE_METER_STATE_KEYS, - "percent", - "status", -] as const; -export const METER_KEYS = METER_STATE_KEYS; diff --git a/src/meter/__tests__/Meter.test.tsx b/src/meter/__tests__/Meter.test.tsx deleted file mode 100644 index 423c0908d..000000000 --- a/src/meter/__tests__/Meter.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import * as React from "react"; -import { render } from "reakit-test-utils"; -import { renderHook } from "reakit-test-utils/hooks"; -import { jestSerializerStripFunctions } from "reakit-test-utils/jestSerializerStripFunctions"; -import { screen } from "@testing-library/react"; -import cases from "jest-in-case"; - -import { MeterInitialState, useMeterState } from "../../index"; -import { Meter } from "../Meter"; - -import { data } from "./statehook-test-data"; - -expect.addSnapshotSerializer(jestSerializerStripFunctions); - -function renderMeterStateHook(props: MeterInitialState = {}) { - return renderHook(() => useMeterState(props)).result; -} - -const MeterComp: React.FC = props => { - const { value, low, high, optimum, min, max, ...rest } = props; - const meter = useMeterState({ - value, - low, - high, - optimum, - min, - max, - }); - - return ; -}; - -describe("Meter", () => { - test("default meter markup", () => { - const { container } = render(); - - expect(container).toMatchInlineSnapshot(` -
-
-
- `); - }); - - it("checks role", function () { - render(); - const meter = screen.getByRole("meter"); - const alsoProgressBar = screen.getByRole("progressbar", { - queryFallbacks: true, - }); - expect(meter).toBe(alsoProgressBar); - }); - - it("no value", function () { - render(); - const meter = screen.getByRole("meter"); - - expect(meter).toHaveAttribute("aria-valuemin", "0"); - expect(meter).toHaveAttribute("aria-valuemax", "1"); - expect(meter).toHaveAttribute("aria-valuenow", "0"); - expect(meter).toHaveAttribute("aria-valuetext", "0%"); - }); - - it("value between min and max", function () { - render(); - const meter = screen.getByRole("meter"); - - expect(meter).toHaveAttribute("aria-valuemin", "0"); - expect(meter).toHaveAttribute("aria-valuemax", "1"); - expect(meter).toHaveAttribute("aria-valuenow", "0.5"); - expect(meter).toHaveAttribute("aria-valuetext", "50%"); - }); - - it("value below min", function () { - render(); - const meter = screen.getByRole("meter"); - - expect(meter).toHaveAttribute("aria-valuemin", "0"); - expect(meter).toHaveAttribute("aria-valuemax", "1"); - expect(meter).toHaveAttribute("aria-valuenow", "0"); - expect(meter).toHaveAttribute("aria-valuetext", "0%"); - }); - - it("value above max", function () { - render(); - const meter = screen.getByRole("meter"); - - expect(meter).toHaveAttribute("aria-valuemin", "0"); - expect(meter).toHaveAttribute("aria-valuemax", "1"); - expect(meter).toHaveAttribute("aria-valuenow", "1"); - expect(meter).toHaveAttribute("aria-valuetext", "100%"); - }); - - it("custom min and max", function () { - render(); - const meter = screen.getByRole("meter"); - - expect(meter).toHaveAttribute("aria-valuemin", "0"); - expect(meter).toHaveAttribute("aria-valuemax", "10"); - expect(meter).toHaveAttribute("aria-valuenow", "5"); - expect(meter).toHaveAttribute("aria-valuetext", "50%"); - }); - - it("supports aria-label", function () { - render(); - const meter = screen.getByRole("meter"); - - expect(meter).toHaveAttribute("aria-label", "Meter"); - }); - - it("supports custom DOM props", function () { - render(); - const meter = screen.getByTestId("testid-test"); - - expect(meter).toBeInTheDocument(); - }); - - cases( - "meter state hook tests", - (opts: any) => { - const { current } = renderMeterStateHook(opts.in); - expect(current).toMatchObject(opts.out); - }, - data, - ); - - test("useMeterState: low >= high", function () { - const { current } = renderMeterStateHook({ low: 1, high: 0.2 }); - - expect(current.low).toBe(0.2); - expect(current.high).toBe(0.2); - }); - - test("useMeterState: high >= low", function () { - const { current } = renderMeterStateHook({ high: 1, low: 0.2 }); - - expect(current.low).toBe(0.2); - expect(current.high).toBe(1); - }); -}); diff --git a/src/meter/__tests__/helpers.test.ts b/src/meter/__tests__/helpers.test.ts deleted file mode 100644 index c1490bc28..000000000 --- a/src/meter/__tests__/helpers.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { clamp, isInRange } from "../helpers"; - -describe("Meter Helpers", () => { - test("isInRange", () => { - expect(isInRange(100, 0, 50)).toBe(false); - expect(isInRange(50, 0, 50)).toBe(true); - expect(isInRange(50, 0, 40)).toBe(false); - expect(isInRange(50, 40, 0)).toBe(false); - expect(isInRange(50, 100, 0)).toBe(false); - }); - - test("clamp", () => { - // @ts-ignore - expect(clamp(null, 1, 2)).toBe(0); - expect(clamp(5, 0, 2)).toBe(2); - expect(clamp(-5, 0, 2)).toBe(0); - expect(clamp(2, 5, 8)).toBe(5); - expect(clamp(6, 5, 8)).toBe(6); - expect(clamp(6, -5, -2)).toBe(-2); - expect(clamp(-8, -5, -10)).toBe(-10); - }); -}); diff --git a/src/meter/__tests__/statehook-test-data.ts b/src/meter/__tests__/statehook-test-data.ts deleted file mode 100644 index 0ec2ed78d..000000000 --- a/src/meter/__tests__/statehook-test-data.ts +++ /dev/null @@ -1,336 +0,0 @@ -export const data = [ - { - name: "meter state initial state", - in: {}, - out: { - high: 1, - low: 0, - max: 1, - min: 0, - optimum: 0.5, - percent: 0, - status: "safe", - value: 0, - }, - }, - { - name: "meter state with value", - in: { value: 0.5 }, - out: { - high: 1, - low: 0, - max: 1, - min: 0, - optimum: 0.5, - percent: 50, - status: "safe", - value: 0.5, - }, - }, - { - name: "meter state with custom props", - in: { - min: 0, - low: 2.5, - value: 5, - high: 7.5, - max: 10, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 5, - percent: 50, - status: "safe", - value: 5, - }, - }, - - { - name: "meter state with optimum < min", - in: { - min: 0, - low: 2.5, - value: 5, - high: 7.5, - max: 10, - optimum: -5, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 0, - percent: 50, - status: "caution", - value: 5, - }, - }, - - { - name: "meter state with optimum > max", - in: { - min: 0, - low: 2.5, - value: 5, - high: 7.5, - max: 10, - optimum: 15, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 10, - percent: 50, - status: "caution", - value: 5, - }, - }, - - { - name: "meter state with optimum between low & high and value at same range", - in: { - min: 0, - low: 2.5, - value: 5, - high: 7.5, - max: 10, - optimum: 5, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 5, - percent: 50, - status: "safe", - value: 5, - }, - }, - - { - name: "meter state with optimum between low & high and value below low", - in: { - min: 0, - low: 2.5, - value: 2, - high: 7.5, - max: 10, - optimum: 5, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 5, - percent: 20, - status: "caution", - value: 2, - }, - }, - - { - name: "meter state with optimum between low & high and value above high", - in: { - min: 0, - low: 2.5, - value: 8, - high: 7.5, - max: 10, - optimum: 5, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 5, - percent: 80, - status: "caution", - value: 8, - }, - }, - - { - name: "meter state with optimum at high", - in: { - min: 0, - low: 2.5, - value: 5, - high: 7.5, - max: 10, - optimum: 7.5, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 7.5, - percent: 50, - status: "safe", - value: 5, - }, - }, - - { - name: "meter state with optimum at low", - in: { - min: 0, - low: 2.5, - value: 5, - high: 7.5, - max: 10, - optimum: 2.5, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 2.5, - percent: 50, - status: "safe", - value: 5, - }, - }, - - { - name: "meter state with optimum < low & >= min and value at same range", - in: { - min: 0, - low: 2.5, - value: 0, - high: 7.5, - max: 10, - optimum: 2, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 2, - percent: 0, - status: "safe", - value: 0, - }, - }, - - { - name: "meter state with optimum < low & >= min and value > low", - in: { - min: 0, - low: 2.5, - value: 5, - high: 7.5, - max: 10, - optimum: 2, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 2, - percent: 50, - status: "caution", - value: 5, - }, - }, - - { - name: "meter state with optimum < low & >= min and value > high", - in: { - min: 0, - low: 2.5, - value: 8, - high: 7.5, - max: 10, - optimum: 2, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 2, - percent: 80, - status: "danger", - value: 8, - }, - }, - - { - name: "meter state with optimum <= max & >= high & value at same range", - in: { - min: 0, - low: 2.5, - value: 8, - high: 7.5, - max: 10, - optimum: 9, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 9, - percent: 80, - status: "safe", - value: 8, - }, - }, - - { - name: "meter state with optimum <= max & >= high & value < high", - in: { - min: 0, - low: 2.5, - value: 5, - high: 7.5, - max: 10, - optimum: 9, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 9, - percent: 50, - status: "caution", - value: 5, - }, - }, - - { - name: "meter state with optimum <= max & >= high & value < low", - in: { - min: 0, - low: 2.5, - value: 2, - high: 7.5, - max: 10, - optimum: 9, - }, - out: { - high: 7.5, - low: 2.5, - max: 10, - min: 0, - optimum: 9, - percent: 20, - status: "danger", - value: 2, - }, - }, -]; diff --git a/src/meter/helpers.ts b/src/meter/__utils.ts similarity index 66% rename from src/meter/helpers.ts rename to src/meter/__utils.ts index 18e963e1e..6226aea9f 100644 --- a/src/meter/helpers.ts +++ b/src/meter/__utils.ts @@ -1,5 +1,3 @@ -import { isNull } from "../utils"; - /** * Handle Inequalities with received values * @@ -10,12 +8,6 @@ import { isNull } from "../utils"; * * @see https://html.spec.whatwg.org/multipage/form-elements.html#the-meter-element:attr-meter-max-3:~:text=following%20inequalities%20must%20hold */ -export function clamp(value: number, min: number, max: number) { - if (isNull(value)) return 0; - - return Math.min(Math.max(value, min), max); -} - type CalculateStatusProps = { value: number; optimum: number; @@ -52,3 +44,30 @@ export const calculateStatus = (props: CalculateStatusProps) => { export const isInRange = (value: number, min: number, max: number) => value >= min && value <= max; + +export function clamp(value: number, min: number, max: number) { + if (value == null) return 0; + + return Math.min(Math.max(value, min), max); +} + +/** + * Convert a value to percentage based on lower and upper bound values + * + * @param value the value in number + * @param min the minimum value + * @param max the maximum value + */ +export function valueToPercent(value: number, min: number, max: number) { + return ((value - min) * 100) / (max - min); +} + +/** + * The candidate optimum point is the midpoint between the minimum value and + * the maximum value. + * + * @see https://html.spec.whatwg.org/multipage/form-elements.html#the-meter-element:attr-meter-high-8:~:text=boundary.-,The%20optimum%20point + */ +export function getOptimumValue(min: number, max: number) { + return max < min ? min : min + (max - min) / 2; +} diff --git a/src/meter/index.ts b/src/meter/index.ts index cc5add382..78e7e50de 100644 --- a/src/meter/index.ts +++ b/src/meter/index.ts @@ -1,3 +1,2 @@ -export * from "./__keys"; -export * from "./Meter"; -export * from "./MeterState"; +export * from "./meter-base"; +export * from "./meter-state"; diff --git a/src/meter/meter-base.tsx b/src/meter/meter-base.tsx new file mode 100644 index 000000000..5fc9c6624 --- /dev/null +++ b/src/meter/meter-base.tsx @@ -0,0 +1,38 @@ +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { MeterState } from "./meter-state"; + +export const useMeter = createHook(({ state, ...props }) => { + const { value, max, min, percent } = state; + + props = { + role: "meter progressbar", + "aria-valuemax": max, + "aria-valuemin": min, + "aria-valuenow": value, + "aria-valuetext": !percent.toString() ? undefined : `${percent}%`, + ...props, + }; + + return props; +}); + +export const Meter = createComponent(props => { + const htmlProps = useMeter(props); + + return createElement("div", htmlProps); +}); + +export type MeterOptions = Options & { + /** + * Object returned by the `useMeterState` hook. + */ + state: MeterState; +}; + +export type MeterProps = Props>; diff --git a/src/meter/MeterState.ts b/src/meter/meter-state.ts similarity index 67% rename from src/meter/MeterState.ts rename to src/meter/meter-state.ts index 1ebc88435..5cba9d0df 100644 --- a/src/meter/MeterState.ts +++ b/src/meter/meter-state.ts @@ -1,6 +1,58 @@ -import { getOptimumValue, valueToPercent } from "../utils"; +import { useMemo } from "react"; -import { calculateStatus, clamp } from "./helpers"; +import { + calculateStatus, + clamp, + getOptimumValue, + valueToPercent, +} from "./__utils"; + +/** + * Provides state for the `Meter` components. + * @example + * ```jsx + * const meter = useMeterState(); + * + * ``` + */ +export function useMeterState({ + value: defaultValue = 0, + min = 0, + max = 1, + ...props +}: MeterStateProps = {}): MeterState { + const initialLow = props.low ?? min; + const initialHigh = props.high ?? max; + const initialOptimum = + props.optimum ?? getOptimumValue(initialLow, initialHigh); + + const value = clamp(defaultValue, min, max); + const optimum = clamp(initialOptimum, min, max); + let low = clamp(initialLow, min, max); + let high = clamp(initialHigh, min, max); + + // More inequalities handled + // low ≤ high (if both low and high are specified) + if (low >= high) low = high; + if (high <= low) high = low; + + const status: Status = calculateStatus({ + value, + min, + max, + low, + optimum, + high, + }); + const percent = valueToPercent(value, min, max); + + const state = useMemo( + () => ({ value, min, max, low, optimum, high, status, percent }), + [value, min, max, low, optimum, high, status, percent], + ); + + return state; +} type Status = "safe" | "caution" | "danger" | undefined; @@ -54,50 +106,7 @@ export type MeterState = { status: Status; }; -export type MeterInitialState = Pick< +export type MeterStateProps = Pick< Partial, "value" | "min" | "max" | "low" | "optimum" | "high" >; - -export type MeterStateReturn = MeterState; - -export const useMeterState = ( - props: MeterInitialState = {}, -): MeterStateReturn => { - const { value: defaultValue = 0, min = 0, max = 1, ...sealed } = props; - const initialLow = sealed.low ?? min; - const initialHigh = sealed.high ?? max; - const initialOptimum = - sealed.optimum ?? getOptimumValue(initialLow, initialHigh); - - const value = clamp(defaultValue, min, max); - const optimum = clamp(initialOptimum, min, max); - let low = clamp(initialLow, min, max); - let high = clamp(initialHigh, min, max); - - // More inequalities handled - // low ≤ high (if both low and high are specified) - if (low >= high) low = high; - if (high <= low) high = low; - - const status: Status = calculateStatus({ - value, - min, - max, - low, - optimum, - high, - }); - const percent = valueToPercent(value, min, max); - - return { - value, - min, - max, - low, - optimum, - high, - status, - percent, - }; -}; diff --git a/src/meter/stories/MeterBasic.component.tsx b/src/meter/stories/MeterBasic.component.tsx index 007bc9b30..906d37507 100644 --- a/src/meter/stories/MeterBasic.component.tsx +++ b/src/meter/stories/MeterBasic.component.tsx @@ -1,31 +1,27 @@ import * as React from "react"; -import { - Meter as RenderlesskitMeter, - MeterInitialState, - useMeterState, -} from "../../index"; +import { Meter, MeterStateProps, useMeterState } from "../../index"; -export const Meter: React.FC = props => { +export const MeterBasic: React.FC = props => { const state = useMeterState(props); const { percent, status } = state; return (
- + state={state} + >
); }; -export default Meter; +export default MeterBasic; const background = { safe: "#8bcf69", diff --git a/src/meter/stories/MeterBasic.stories.tsx b/src/meter/stories/MeterBasic.stories.tsx index fa7c682da..1b8f70519 100644 --- a/src/meter/stories/MeterBasic.stories.tsx +++ b/src/meter/stories/MeterBasic.stories.tsx @@ -1,34 +1,36 @@ 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/MeterBasicCss"; import js from "./templates/MeterBasicJsx"; import ts from "./templates/MeterBasicTsx"; -import { Meter } from "./MeterBasic.component"; +import MeterBasic from "./MeterBasic.component"; import "./MeterBasic.css"; +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + export default { - component: Meter, title: "Meter/Basic", + component: MeterBasic, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts, css }), }, } as Meta; -const Default: Story = args => ; - -export const Playground = Default.bind({}); -Playground.args = { - value: 5, - min: 0, - max: 10, - low: 0, - high: 10, - optimum: 5, +export const Default: Story = { + args: { + value: 5, + min: 0, + max: 10, + low: 0, + high: 10, + optimum: 5, + }, }; // Examples from https://css-tricks.com/html5-meter-element/ @@ -40,119 +42,119 @@ export const AllUseCases = () => { {``}

- +

{`Case 2 - value < max (default: min=0, max=1)`}

{``}

- +

{`Case 3 - value = max (default: min=0, max=1)`}

{``}

- +

{`Case 4 - value > max (default: min=0, max=1)`}

{``}

- +

{`Case 6 - value = min (default: min=0, max=1)`}

{``}

- +

{`Case 7 - value > min (default: min=0, max=1)`}

{``}

- +

{`Case 8 - value < high (default: min=0, max=1)`}

{``}

- +

{`Case 9 - value = high (default: min=0, max=1)`}

{``}

- +

{`Case 10 - value > high (default: min=0, max=1)`}

{``}

- +

{`Case 11 - value < low (default: min=0, max=1)`}

{``}

- +

{`Case 12 - value = low (default: min=0, max=1)`}

{``}

- +

{`Case 13 - value > low (default: min=0, max=1)`}

{``}

- +

{`Case 14 - optimum < low < high (default: min=0, max=1)`}

{``}

- +

{`Case 15 - low < optimum < high (default: min=0, max=1)`}

{``}

- +

{`Case 16 - low < high < optimum (default: min=0, max=1)`}

{``}

- +

{`Case 17 - value < low < high < optimum (default: min=0, max=1)`}

{``}

- +

{`Case 18 - value > high > low > optimum (default: min=0, max=1)`}

{``}

- +
); }; diff --git a/src/meter/stories/MeterStyled.component.tsx b/src/meter/stories/MeterStyled.component.tsx index 06efec781..fe4ff0cc8 100644 --- a/src/meter/stories/MeterStyled.component.tsx +++ b/src/meter/stories/MeterStyled.component.tsx @@ -1,14 +1,9 @@ import * as React from "react"; import { css, keyframes } from "@emotion/css"; -import { - Meter as RenderlesskitMeter, - MeterInitialState, - MeterStateReturn, - useMeterState, -} from "../../index"; +import { Meter, MeterState, MeterStateProps, useMeterState } from "../../index"; -export interface MeterProps extends MeterInitialState { +export interface MeterBasicProps extends MeterStateProps { /** * Adds a label to meter. * @default false @@ -26,25 +21,19 @@ export interface MeterProps extends MeterInitialState { withStripeAnimation?: boolean; } -export const Meter: React.FC = props => { - const { - children, - withLabel = false, - withStripe, - withStripeAnimation, - ...rest - } = props; - const meter = useMeterState(rest); +export const MeterBasic: React.FC = props => { + const { withLabel = false, withStripe, withStripeAnimation, ...rest } = props; + const state = useMeterState(rest); return (
- - {withLabel &&
{`${meter.percent}%`}
} + /> + {withLabel &&
{`${state.percent}%`}
}
); }; @@ -100,7 +89,7 @@ const generateStripe = { backgroundSize: "1rem 1rem", }; -function meterBarStyle(meter: MeterStateReturn, props: MeterProps) { +function meterBarStyle(meter: MeterState, props: MeterBasicProps) { const { percent, status } = meter; const { withStripe, withStripeAnimation } = props; diff --git a/src/meter/stories/MeterStyled.stories.tsx b/src/meter/stories/MeterStyled.stories.tsx index dc5f7e04d..baa2d28a3 100644 --- a/src/meter/stories/MeterStyled.stories.tsx +++ b/src/meter/stories/MeterStyled.stories.tsx @@ -1,14 +1,16 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; import js from "./templates/MeterStyledJsx"; import ts from "./templates/MeterStyledTsx"; -import { Meter, MeterProps } from "./MeterStyled.component"; +import { MeterBasic } from "./MeterStyled.component"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; export default { - component: Meter, + component: MeterBasic, title: "Meter/Styled", parameters: { layout: "centered", @@ -16,153 +18,14 @@ export default { }, } as Meta; -const Default: Story = args => ; - -export const Playground = Default.bind({}); -Playground.args = { - value: 5, - min: 0, - max: 10, - low: 0, - high: 10, - optimum: 5, +const Default: Story = { + args: { value: 5, min: 0, max: 10, low: 0, high: 10, optimum: 5 }, }; -export const WithLabel = Default.bind({}); -WithLabel.args = { ...Playground.args, withLabel: true }; +export const WithLabel = { args: { ...Default.args, withLabel: true } }; -export const WithStripe = Default.bind({}); -WithStripe.args = { ...Playground.args, withStripe: true }; - -export const WithStripeAnimation = Default.bind({}); -WithStripeAnimation.args = { - ...Playground.args, - withStripe: true, - withStripeAnimation: true, -}; +export const WithStripe = { args: { ...Default.args, withStripe: true } }; -// Examples from https://css-tricks.com/html5-meter-element/ -export const AllUseCases = () => { - return ( -
-

Case 1 - No attributes

-
- {``} -
-
- -
-

{`Case 2 - value < max (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 3 - value = max (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 4 - value > max (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 6 - value = min (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 7 - value > min (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 8 - value < high (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 9 - value = high (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 10 - value > high (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 11 - value < low (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 12 - value = low (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 13 - value > low (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 14 - optimum < low < high (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 15 - low < optimum < high (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 16 - low < high < optimum (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 17 - value < low < high < optimum (default: min=0, max=1)`}

-
- {``} -
-
- -
-

{`Case 18 - value > high > low > optimum (default: min=0, max=1)`}

-
- {``} -
-
- -
- ); +export const WithStripeAnimation = { + args: { ...Default.args, withStripe: true, withStripeAnimation: true }, }; diff --git a/src/number-input/NumberInput.ts b/src/number-input/NumberInput.ts deleted file mode 100644 index bce1db3a5..000000000 --- a/src/number-input/NumberInput.ts +++ /dev/null @@ -1,267 +0,0 @@ -import * as React from "react"; -import { InputHTMLProps, InputOptions, useInput } from "reakit"; -import { EventKeyMap, mergeRefs } from "@chakra-ui/react-utils"; -import { - callAllHandlers, - normalizeEventKey, - StringOrNumber, -} from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; -import { ariaAttr } from "../utils"; - -import { NUMBER_INPUT_KEYS } from "./__keys"; -import { - getStepFactor, - isValidNumericKeyboardEvent, - sanitize, -} from "./helpers"; -import { NumberInputStateReturn } from "./NumberInputState"; - -export type NumberInputOptions = InputOptions & - Pick, "keepWithinRange"> & - Pick< - NumberInputStateReturn, - | "min" - | "max" - | "step" - | "updateValue" - | "increment" - | "decrement" - | "value" - | "valueAsNumber" - | "isOutOfRange" - | "setCastedValue" - | "inputRef" - | "isInvalid" - | "isDisabled" - | "isReadOnly" - | "isRequired" - > & { - /** - * This controls the value update when you blur out of the input. - * - If `true` and the value is greater than `max`, the value will be reset to `max` - * - Else, the value remains the same. - * - * @default true - */ - clampValueOnBlur?: boolean; - /** - * If `true`, the input's value will change based on mouse wheel - * - * @default true - */ - allowMouseWheel?: boolean; - }; - -export type NumberInputHTMLProps = InputHTMLProps; - -export type NumberInputProps = NumberInputOptions & NumberInputHTMLProps; - -export const useNumberInput = createHook< - NumberInputOptions, - NumberInputHTMLProps ->({ - name: "NumberInput", - compose: useInput, - keys: NUMBER_INPUT_KEYS, - - useOptions({ allowMouseWheel = true, clampValueOnBlur = true, ...options }) { - const disabled = options.disabled || options.isDisabled; - - return { - allowMouseWheel, - clampValueOnBlur, - ...options, - disabled, - }; - }, - - useProps( - options, - { - ref: htmlRef, - onChange: htmlOnChange, - onKeyDown: htmlOnKeyDown, - onFocus: htmlOnFocus, - onBlur: htmlOnBlur, - onWheel: htmlOnWheel, - required: htmlRequired, - readOnly: htmlReadOnly, - ...htmlProps - }, - ) { - const { - min, - max, - step, - updateValue, - increment, - decrement, - value, - valueAsNumber, - clampValueOnBlur, - setCastedValue, - inputRef, - disabled, - isInvalid, - isReadOnly, - isRequired, - isOutOfRange, - } = options; - - const readOnly = htmlReadOnly || isReadOnly; - const required = htmlRequired || isRequired; - - /** - * The `onChange` handler filters out any character typed - * that isn't floating point compatible. - */ - const onChange = React.useCallback( - (event: React.ChangeEvent) => { - updateValue(sanitize(event.target.value)); - }, - [updateValue], - ); - - const onKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - /** - * only allow valid numeric keys - */ - if (!isValidNumericKeyboardEvent(event)) { - event.preventDefault(); - } - - /** - * Keyboard Accessibility - * - * We want to increase or decrease the input's value - * based on if the user the arrow keys. - * - * @see https://www.w3.org/TR/wai-aria-practices-1.1/#keyboard-interaction-17 - */ - const stepFactor = getStepFactor(event) * step; - - const eventKey = normalizeEventKey(event); - - const keyMap: EventKeyMap = { - ArrowUp: () => increment(stepFactor), - ArrowDown: () => decrement(stepFactor), - Home: () => updateValue(min), - End: () => updateValue(max), - }; - - const action = keyMap[eventKey]; - - if (action) { - event.preventDefault(); - action(event); - } - }, - [decrement, increment, max, min, step, updateValue], - ); - - const onBlur = React.useCallback(() => { - if (!clampValueOnBlur) return; - - let next = value as StringOrNumber; - - if (next === "") return; - - if (valueAsNumber < min) { - next = min; - } - - if (valueAsNumber > max) { - next = max; - } - - /** - * `setCastedValue` does 2 things: - * - * - sanitize the value by using parseFloat and some Regex - * - used to round value to computed precision or decimal points - */ - setCastedValue(next); - }, [setCastedValue, clampValueOnBlur, max, min, value, valueAsNumber]); - - React.useEffect(() => { - const input = inputRef.current; - if (!input) return undefined; - - function onWheel(event: WheelEvent) { - const isInputFocused = document.activeElement === inputRef.current; - if (!options.allowMouseWheel || !isInputFocused) return; - - event.preventDefault(); - - const stepFactor = getStepFactor(event as any) * step; - const direction = Math.sign(event.deltaY); - - if (direction === -1) { - increment(stepFactor); - } else if (direction === 1) { - decrement(stepFactor); - } - } - - input.addEventListener("wheel", onWheel); - - return () => { - input.removeEventListener("wheel", onWheel); - }; - }, [decrement, increment, inputRef, options.allowMouseWheel, step]); - - const onWheel = React.useCallback( - (event: React.WheelEvent) => { - if (!options.allowMouseWheel) return; - - event.preventDefault(); - - const stepFactor = getStepFactor(event as any) * step; - const direction = Math.sign(event.deltaY); - - if (direction === -1) { - increment(stepFactor); - } else if (direction === 1) { - decrement(stepFactor); - } - }, - [decrement, increment, options.allowMouseWheel, step], - ); - - return { - ref: mergeRefs(htmlRef, inputRef), - value, - type: "text", - role: "spinbutton", - inputMode: "decimal", - pattern: "[0-9]*(.[0-9]+)?", - autoComplete: "off", - autoCorrect: "off", - spellCheck: false, - disabled, - readOnly, - required, - onChange: callAllHandlers(htmlOnChange, onChange), - onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), - onBlur: callAllHandlers(htmlOnBlur, onBlur), - onWheel: callAllHandlers(htmlOnWheel, onWheel), - "aria-readonly": readOnly, - "aria-required": required, - "aria-invalid": ariaAttr(isInvalid ?? isOutOfRange), - "aria-valuemin": min, - "aria-valuemax": max, - "aria-valuenow": Number.isNaN(valueAsNumber) ? undefined : valueAsNumber, - "aria-valuetext": !value.toString() ? undefined : value.toString(), - ...htmlProps, - }; - }, -}); - -export const NumberInput = createComponent({ - as: "input", - memo: true, - useHook: useNumberInput, -}); diff --git a/src/number-input/NumberInputDecrementButton.ts b/src/number-input/NumberInputDecrementButton.ts deleted file mode 100644 index a1c6af5ce..000000000 --- a/src/number-input/NumberInputDecrementButton.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as React from "react"; -import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; - -import { NUMBER_INPUT_DECREMENT_BUTTON_KEYS } from "./__keys"; -import { NumberInputStateReturn } from "./index"; - -export type NumberInputDecrementButtonOptions = ButtonOptions & - Pick, "keepWithinRange"> & - Pick< - NumberInputStateReturn, - "focusInput" | "isAtMin" | "spinDown" | "spinStop" | "isDisabled" - >; - -export type NumberInputDecrementButtonHTMLProps = ButtonHTMLProps; - -export type NumberInputDecrementButtonProps = - NumberInputDecrementButtonOptions & NumberInputDecrementButtonHTMLProps; - -export const useNumberInputDecrementButton = createHook< - NumberInputDecrementButtonOptions, - NumberInputDecrementButtonHTMLProps ->({ - name: `NumberInput`, - compose: useButton, - keys: NUMBER_INPUT_DECREMENT_BUTTON_KEYS, - - useOptions(options) { - const { keepWithinRange, isAtMin } = options; - const disabled = - options.disabled || (keepWithinRange && isAtMin) || options.isDisabled; - - return { ...options, disabled }; - }, - - useProps( - options, - { - onMouseDown, - onMouseUp, - onMouseLeave, - onTouchStart, - onTouchEnd, - ...htmlProps - }, - ) { - const { - spinDown: spinDownProp, - spinStop: spinStopProp, - focusInput, - } = options; - - const spinDown = React.useCallback( - (event: any) => { - event.preventDefault(); - spinDownProp(); - focusInput(); - }, - [focusInput, spinDownProp], - ); - - const spinStop = React.useCallback( - (event: any) => { - event.preventDefault(); - spinStopProp(); - }, - [spinStopProp], - ); - - React.useEffect(() => { - // Need to stop the spinner when isAtMin - if (options.disabled) spinStopProp(); - }, [options.disabled, spinStopProp]); - - return { - "aria-hidden": true, - tabIndex: -1, - onMouseDown: callAllHandlers(onMouseDown, spinDown), - onTouchStart: callAllHandlers(onTouchStart, spinDown), - onMouseUp: callAllHandlers(onMouseUp, spinStop), - onMouseLeave: callAllHandlers(onMouseLeave, spinStop), - onTouchEnd: callAllHandlers(onTouchEnd, spinStop), - ...htmlProps, - }; - }, -}); - -export const NumberInputDecrementButton = createComponent({ - as: "button", - memo: true, - useHook: useNumberInputDecrementButton, -}); diff --git a/src/number-input/NumberInputIncrementButton.ts b/src/number-input/NumberInputIncrementButton.ts deleted file mode 100644 index 462e65778..000000000 --- a/src/number-input/NumberInputIncrementButton.ts +++ /dev/null @@ -1,90 +0,0 @@ -import * as React from "react"; -import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; - -import { NUMBER_INPUT_INCREMENT_BUTTON_KEYS } from "./__keys"; -import { NumberInputStateReturn } from "./index"; - -export type NumberInputIncrementButtonOptions = ButtonOptions & - Pick, "keepWithinRange"> & - Pick< - NumberInputStateReturn, - "focusInput" | "isAtMax" | "spinUp" | "spinStop" | "isDisabled" - >; - -export type NumberInputIncrementButtonHTMLProps = ButtonHTMLProps; - -export type NumberInputIncrementButtonProps = - NumberInputIncrementButtonOptions & NumberInputIncrementButtonHTMLProps; - -export const useNumberInputIncrementButton = createHook< - NumberInputIncrementButtonOptions, - NumberInputIncrementButtonHTMLProps ->({ - name: `NumberInput`, - compose: useButton, - keys: NUMBER_INPUT_INCREMENT_BUTTON_KEYS, - - useOptions(options) { - const { keepWithinRange, isAtMax } = options; - const disabled = - options.disabled || (keepWithinRange && isAtMax) || options.isDisabled; - - return { ...options, disabled }; - }, - - useProps( - options, - { - onMouseDown, - onMouseUp, - onMouseLeave, - onTouchStart, - onTouchEnd, - ...htmlProps - }, - ) { - const { spinUp: spinUpProp, spinStop: spinStopProp, focusInput } = options; - - const spinUp = React.useCallback( - (event: any) => { - event.preventDefault(); - spinUpProp(); - focusInput(); - }, - [focusInput, spinUpProp], - ); - - const spinStop = React.useCallback( - (event: any) => { - event.preventDefault(); - spinStopProp(); - }, - [spinStopProp], - ); - - React.useEffect(() => { - // Need to stop the spinner when isAtMax - if (options.disabled) spinStopProp(); - }, [options.disabled, spinStopProp]); - - return { - "aria-hidden": true, - tabIndex: -1, - onMouseDown: callAllHandlers(onMouseDown, spinUp), - onTouchStart: callAllHandlers(onTouchStart, spinUp), - onMouseUp: callAllHandlers(onMouseUp, spinStop), - onMouseLeave: callAllHandlers(onMouseLeave, spinStop), - onTouchEnd: callAllHandlers(onTouchEnd, spinStop), - ...htmlProps, - }; - }, -}); - -export const NumberInputIncrementButton = createComponent({ - as: "button", - memo: true, - useHook: useNumberInputIncrementButton, -}); diff --git a/src/number-input/NumberInputState.ts b/src/number-input/NumberInputState.ts deleted file mode 100644 index bc4d2b48c..000000000 --- a/src/number-input/NumberInputState.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * All credit goes to [Segun Adebayo](https://github.com/segunadebayo) for - * creating an Awesome Library [Chakra UI](https://github.com/chakra-ui/chakra-ui/) - * We improved the hook [useNumberInput](https://github.com/chakra-ui/chakra-ui/blob/develop/packages/number-input/src/use-number-input.ts) - * to work with Reakit System - */ -import * as React from "react"; -import { useCounter } from "@chakra-ui/counter"; -import { useSafeLayoutEffect } from "@chakra-ui/hooks"; -import { - focus, - maxSafeInteger, - minSafeInteger, - StringOrNumber, -} from "@chakra-ui/utils"; - -import { sanitize, useSpinner, useSpinnerReturn } from "./helpers"; - -export type NumberInputState = { - /** - * The value of the counter. Should be less than `max` and greater than `min` - * - * If no value, initial value is set to `""` - */ - value: StringOrNumber; - /** - * The minimum value of the counter - * - * @default Number.MIN_SAFE_INTEGER - */ - min: number; - /** - * The maximum value of the counter - * - * @default Number.MAX_SAFE_INTEGER - */ - max: number; - /** - * The step used to increment or decrement the value - * - * @default 1 - */ - step: number; - /** - * The number of decimal points used to round the value - * - * If no precision, initial value is from the decimal places from value/step - `0` - */ - precision: number; - /** - * This controls the value update behavior in general. - * - * - If `true` and you use the stepper or up/down arrow keys, - * the value will not exceed the `max` or go lower than `min` - * - * - If `false`, the value will be allowed to go out of range. - * - * @default true - */ - keepWithinRange: boolean; - /** - * The value of the counter in number. - */ - valueAsNumber: number; - /** - * True, if value is less than `min` & greater than `max`. - */ - isOutOfRange: boolean; - /** - * True, if value is equal to max. - */ - isAtMax: boolean; - /** - * Truw, if value is equal to min. - */ - isAtMin: boolean; - /** - * The Input Element. - */ - inputRef: React.RefObject; - /** - * If `true`, the input will be in readonly mode - */ - isReadOnly?: boolean; - /** - * If `true`, the input will have `aria-invalid` set to `true` - */ - isInvalid?: boolean; - /** - * If `true`, the input will be disabled - */ - isDisabled?: boolean; - /** - * If `true`, the input will required to be given a value - */ - isRequired?: boolean; -}; - -export type NumberInputAction = { - /** - * Set the value which will be converted to string. - */ - updateValue: (next: StringOrNumber) => void; - /** - * Set the value which will be converted to string. - */ - setValue: (next: StringOrNumber) => void; - /** - * Set the casted value based on precision & step. - */ - setCastedValue: (value: StringOrNumber) => void; - /** - * Increment the value based on the step - */ - increment: (step: NumberInputState["step"]) => void; - /** - * Decrement the value based on the step - */ - decrement: (step: NumberInputState["step"]) => void; - /** - * Reset the value back to initial value - */ - reset: () => void; - /** - * Clamp value with precision - */ - clampToPrecision: (value: number) => void; - /** - * Focus input if focus input on value change is `true` - */ - focusInput: () => void; - /** - * Spinner handler that increments the value after an interval - */ - spinUp: useSpinnerReturn["up"]; - /** - * Spinner handler that decrements the value after an interval - */ - spinDown: useSpinnerReturn["down"]; - /** - * Spinner handler that Stop it from incrementing or decrementing - */ - spinStop: useSpinnerReturn["stop"]; -}; - -export type NumberinputInitialState = Pick< - Partial, - | "value" - | "keepWithinRange" - | "min" - | "max" - | "step" - | "precision" - | "isDisabled" - | "isInvalid" - | "isReadOnly" - | "isRequired" -> & { - /** - * The initial value of the counter. Should be less than `max` and greater than `min` - * - * @default "" - */ - defaultValue?: StringOrNumber; - /** - * The callback fired when the value changes - */ - onChange?(valueAsString: string, valueAsNumber: number): void; - /** - * If `true`, the input will be focused as you increment - * or decrement the value with the stepper - * - * @default true - */ - focusInputOnChange?: boolean; -}; - -export type NumberInputStateReturn = NumberInputState & NumberInputAction; - -export function useNumberInputState( - props: NumberinputInitialState = {}, -): NumberInputStateReturn { - const { - min = minSafeInteger, - max = maxSafeInteger, - step: stepProp = 1, - keepWithinRange = true, - focusInputOnChange = true, - isReadOnly, - isDisabled, - isInvalid, - isRequired, - } = props; - - /** - * Leverage the `useCounter` hook since it provides - * the functionality to `increment`, `decrement` and `update` - * counter values - */ - const counter = useCounter(props); - - const { - update: updateValue, - cast: setCastedValue, - clamp: clampToPrecision, - increment: incrementFn, - decrement: decrementFn, - ...counterProps - } = counter; - - const isInteractive = !(isReadOnly || isDisabled); - - const increment = React.useCallback( - (step = stepProp) => { - if (isInteractive) { - incrementFn(step); - } - }, - [incrementFn, isInteractive, stepProp], - ); - - const decrement = React.useCallback( - (step = stepProp) => { - if (isInteractive) { - decrementFn(step); - } - }, - [decrementFn, isInteractive, stepProp], - ); - - /** - * Leverage the `useSpinner` hook to spin the input's value - * when long press on the up and down buttons. - * - * This leverages `setInterval` internally - */ - const spinner = useSpinner(increment, decrement); - - const inputRef = React.useRef(null); - - /** - * Sync state with uncontrolled form libraries like `react-hook-form`. - */ - useSafeLayoutEffect(() => { - if (!inputRef.current) return; - - const notInSync = inputRef.current.value !== counter.value; - - if (notInSync) { - counter.setValue(sanitize(inputRef.current.value)); - } - }, [counter.value]); - - const focusInput = React.useCallback(() => { - if (focusInputOnChange && inputRef.current) { - focus(inputRef.current, { nextTick: true }); - } - }, [focusInputOnChange]); - - return { - min, - max, - step: stepProp, - keepWithinRange, - updateValue, - setCastedValue, - clampToPrecision, - increment, - decrement, - ...counterProps, - inputRef, - focusInput, - spinUp: spinner.up, - spinDown: spinner.down, - spinStop: spinner.stop, - isReadOnly, - isDisabled, - isInvalid, - isRequired, - }; -} diff --git a/src/number-input/__keys.ts b/src/number-input/__keys.ts deleted file mode 100644 index 0201cdc0a..000000000 --- a/src/number-input/__keys.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Automatically generated -export const USE_NUMBERINPUT_STATE_KEYS = [ - "isDisabled", - "isReadOnly", - "value", - "min", - "max", - "keepWithinRange", - "step", - "precision", - "isInvalid", - "isRequired", - "defaultValue", - "onChange", - "focusInputOnChange", -] as const; -export const NUMBER_INPUT_STATE_KEYS = [ - "value", - "min", - "max", - "step", - "precision", - "keepWithinRange", - "valueAsNumber", - "isOutOfRange", - "isAtMax", - "isAtMin", - "inputRef", - "isReadOnly", - "isInvalid", - "isDisabled", - "isRequired", - "updateValue", - "setValue", - "setCastedValue", - "increment", - "decrement", - "reset", - "clampToPrecision", - "focusInput", - "spinUp", - "spinDown", - "spinStop", -] as const; -export const NUMBER_INPUT_KEYS = [ - ...NUMBER_INPUT_STATE_KEYS, - "clampValueOnBlur", - "allowMouseWheel", -] as const; -export const NUMBER_INPUT_DECREMENT_BUTTON_KEYS = NUMBER_INPUT_STATE_KEYS; -export const NUMBER_INPUT_INCREMENT_BUTTON_KEYS = - NUMBER_INPUT_DECREMENT_BUTTON_KEYS; diff --git a/src/number-input/__tests__/NumberInput.test.tsx b/src/number-input/__tests__/NumberInput.test.tsx deleted file mode 100644 index 47bcfd7e3..000000000 --- a/src/number-input/__tests__/NumberInput.test.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import * as React from "react"; -import { act } from "react-dom/test-utils"; -import { - axe, - click, - fireEvent, - press, - render, - screen, -} from "reakit-test-utils"; -import { cleanup } from "@testing-library/react"; - -import { repeat } from "../../utils/test-utils"; -import { - NumberInput, - NumberInputDecrementButton, - NumberInputIncrementButton, - useNumberInputState, -} from "../index"; -import { NumberInputProps } from "../stories/NumberInputBasic.component"; - -beforeEach(() => { - jest - .spyOn(window, "requestAnimationFrame") - .mockImplementation((cb: any) => cb()); -}); - -afterEach(() => { - jest.useRealTimers(); - cleanup(); - (window.requestAnimationFrame as any).mockRestore(); -}); - -const NumberInputComp = (props: NumberInputProps) => { - const state = useNumberInputState(props); - const { clampValueOnBlur, allowMouseWheel } = props; - - return ( - - ); -}; - -describe("NumberInput", () => { - it("should start with empty string", () => { - render(); - - const numberInput = screen.getByTestId("testid-numberinput"); - - expect(numberInput).toHaveValue(""); - }); - - it("should render correctly", () => { - render(); - - const numberInput = screen.getByTestId("testid-numberinput"); - - expect(numberInput).not.toHaveFocus(); - press.Tab(); - expect(numberInput).toHaveFocus(); - }); - - it("should increase/decrease with keyboard", () => { - render(); - const numberInput = screen.getByTestId("testid-numberinput"); - - expect(numberInput).not.toHaveFocus(); - press.Tab(); - expect(numberInput).toHaveFocus(); - expect(numberInput).toHaveValue("0"); - - repeat(press.ArrowUp, 3); - expect(numberInput).toHaveValue("3"); - - repeat(press.ArrowDown, 3); - expect(numberInput).toHaveValue("0"); - - press.ArrowUp(); - expect(numberInput).toHaveValue("1"); - - press.Home(); - expect(numberInput).toHaveValue("0"); - - press.End(); - expect(numberInput).toHaveValue("10"); - }); - - it("should increase/decrease by 0.1*step on ctrl+Arrow", () => { - render(); - const numberInput = screen.getByTestId("testid-numberinput"); - - press.ArrowUp(numberInput); - expect(numberInput).toHaveValue("0.10"); - press.ArrowUp(numberInput, { ctrlKey: true }); - expect(numberInput).toHaveValue("0.11"); - - press.ArrowDown(numberInput, { ctrlKey: true }); - expect(numberInput).toHaveValue("0.10"); - press.ArrowDown(numberInput); - expect(numberInput).toHaveValue("0.00"); - }); - - it("should increase/decrease by 10*step on shift+Arrow", () => { - render(); - const numberInput = screen.getByTestId("testid-numberinput"); - - press.ArrowUp(numberInput); - expect(numberInput).toHaveValue("1"); - press.ArrowUp(numberInput, { shiftKey: true }); - expect(numberInput).toHaveValue("11"); - - press.ArrowDown(numberInput, { shiftKey: true }); - expect(numberInput).toHaveValue("1"); - press.ArrowDown(numberInput); - expect(numberInput).toHaveValue("0"); - }); - - it("should increase/decrease with buttons", () => { - render(); - - const incBtn = screen.getByTestId("testid-inc"); - const decBtn = screen.getByTestId("testid-dec"); - const numberInput = screen.getByTestId("testid-numberinput"); - - expect(numberInput).not.toHaveFocus(); - expect(numberInput).toHaveValue("0"); - repeat(() => click(incBtn), 3); - expect(numberInput).toHaveValue("3"); - expect(numberInput).toHaveFocus(); - - repeat(() => click(decBtn), 3); - expect(numberInput).toHaveValue("0"); - }); - - it("should increase/decrease with scrollwheel", () => { - jest.useFakeTimers(); - - render(); - const numberInput = screen.getByTestId("testid-numberinput"); - - press.Tab(); - expect(numberInput).toHaveFocus(); - expect(numberInput).toHaveValue("0"); - - // eslint-disable-next-line testing-library/no-unnecessary-act - act(() => { - fireEvent.wheel(numberInput, { deltaY: -100 }); - }); - // eslint-disable-next-line testing-library/no-unnecessary-act - act(() => { - fireEvent.wheel(numberInput, { deltaY: -100 }); - }); - expect(numberInput).toHaveValue("2"); - - // eslint-disable-next-line testing-library/no-unnecessary-act - act(() => { - fireEvent.wheel(numberInput, { deltaY: 100 }); - }); - // eslint-disable-next-line testing-library/no-unnecessary-act - act(() => { - fireEvent.wheel(numberInput, { deltaY: 100 }); - }); - expect(numberInput).toHaveValue("0"); - }); - - it("should behave properly with min/max/step options", () => { - render(); - - const incBtn = screen.getByTestId("testid-inc"); - const decBtn = screen.getByTestId("testid-dec"); - const numberInput = screen.getByTestId("testid-numberinput"); - - press.Tab(); - expect(numberInput).toHaveFocus(); - // 3 times pressing wont matter since min value is 10 - repeat(() => click(decBtn), 3); - expect(numberInput).toHaveValue("10"); - - click(incBtn); - // step is 10 - expect(numberInput).toHaveValue("20"); - click(incBtn); - expect(numberInput).toHaveValue("30"); - click(incBtn); - expect(numberInput).toHaveValue("40"); - click(incBtn); - expect(numberInput).toHaveValue("50"); - // 3 times pressing wont matter since max value is 50 - repeat(() => click(incBtn), 3); - expect(numberInput).toHaveValue("50"); - }); - - it("should behave properly precision value", () => { - render(); - - const incBtn = screen.getByTestId("testid-inc"); - const decBtn = screen.getByTestId("testid-dec"); - const numberInput = screen.getByTestId("testid-numberinput"); - - press.Tab(); - expect(numberInput).toHaveFocus(); - expect(numberInput).toHaveValue("0.00"); - click(incBtn); - expect(numberInput).toHaveValue("0.65"); - click(incBtn); - expect(numberInput).toHaveValue("1.30"); - click(incBtn); - expect(numberInput).toHaveValue("1.95"); - click(decBtn); - expect(numberInput).toHaveValue("1.30"); - }); - - it("should behave properly clampValueOnBlur/keepWithinRange", () => { - // note clampValueOnBlur/keepWithinRange is true by default - render( - , - ); - const numberInput = screen.getByTestId("testid-numberinput"); - - press.Tab(); - expect(numberInput).toHaveFocus(); - expect(numberInput).toHaveValue("15"); - - fireEvent.change(numberInput, { target: { value: "25" } }); - expect(numberInput).toHaveValue("25"); - - fireEvent.change(numberInput, { target: { value: "999999" } }); - click(document.body); // blur - expect(numberInput).not.toHaveFocus(); - expect(numberInput).toHaveValue("50"); - - press.Tab(); // get back focus - fireEvent.change(numberInput, { target: { value: "0" } }); - click(document.body); // blur - expect(numberInput).not.toHaveFocus(); - expect(numberInput).toHaveValue("10"); - }); - - test("NumberInput renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/number-input/__tests__/useSpinner.test.tsx b/src/number-input/__tests__/useSpinner.test.tsx deleted file mode 100644 index d97a50cdb..000000000 --- a/src/number-input/__tests__/useSpinner.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from "react"; -import { fireEvent, render } from "reakit-test-utils"; -import { renderHook } from "reakit-test-utils/hooks"; -import { cleanup } from "@testing-library/react"; - -import { useSpinner } from "../helpers"; - -afterEach(cleanup); - -const noop = () => {}; -function renderUseSpinner(increment = noop, decrement = noop) { - return renderHook(() => useSpinner(increment, decrement)).result; -} - -const Example = () => { - const [value, setValue] = React.useState(0); - const { up, down } = useSpinner( - () => setValue(v => v + 1), - () => setValue(v => v - 1), - ); - - return ( -
-

{value}

- - -
- ); -}; - -describe("useSpinner", () => { - it("should render properly", () => { - const inc = jest.fn(); - const dec = jest.fn(); - const { current } = renderUseSpinner(inc, dec); - - expect(current).toMatchInlineSnapshot(` - Object { - "down": [Function], - "stop": [Function], - "up": [Function], - } - `); - }); - - it("press up", () => { - const { getByTestId: testId, getByText: text } = render(); - - fireEvent.click(text("+")); - expect(testId("testid-value")).toHaveTextContent("1"); - }); - - it("press down", () => { - const { getByTestId: testId, getByText: text } = render(); - - fireEvent.click(text("-")); - expect(testId("testid-value")).toHaveTextContent("-1"); - }); - - // TODO: Simulate mouse hold to check for timeout and intervals of increments -}); diff --git a/src/number-input/__tests__/utils.test.ts b/src/number-input/__tests__/utils.test.ts deleted file mode 100644 index f150e952a..000000000 --- a/src/number-input/__tests__/utils.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getStepFactor, isFloatingPointNumericCharacter } from "../helpers"; - -describe("NumberInput Utils", () => { - test("isFloatingPointNumericCharacter", () => { - expect(isFloatingPointNumericCharacter("0")).toBeTruthy(); - expect(isFloatingPointNumericCharacter("1")).toBeTruthy(); - expect(isFloatingPointNumericCharacter("-")).toBeTruthy(); - expect(isFloatingPointNumericCharacter("+")).toBeTruthy(); - expect(isFloatingPointNumericCharacter(".")).toBeTruthy(); - expect(isFloatingPointNumericCharacter("e")).toBeTruthy(); - expect(isFloatingPointNumericCharacter("E")).toBeTruthy(); - expect(isFloatingPointNumericCharacter("Nope")).toBeFalsy(); - expect(isFloatingPointNumericCharacter("abcd")).toBeFalsy(); - }); - - test("getStepFactor", () => { - expect(getStepFactor({} as React.KeyboardEvent)).toBe(1); - expect(getStepFactor({ shiftKey: true } as React.KeyboardEvent)).toBe(10); - expect(getStepFactor({ metaKey: true } as React.KeyboardEvent)).toBe(0.1); - expect(getStepFactor({ ctrlKey: true } as React.KeyboardEvent)).toBe(0.1); - }); -}); diff --git a/src/number-input/helpers/index.ts b/src/number-input/helpers/index.ts deleted file mode 100644 index a80e1f059..000000000 --- a/src/number-input/helpers/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { KeyboardEvent } from "react"; - -const FLOATING_POINT_REGEX = /^[Ee0-9+\-.]$/; - -/** - * Determine if a character is a DOM floating point character - * @see https://www.w3.org/TR/2012/WD-html-markup-20120329/datatypes.html#common.data.float - */ -export function isFloatingPointNumericCharacter(character: string) { - return FLOATING_POINT_REGEX.test(character); -} - -export const sanitize = (value: string) => - value.split("").filter(isFloatingPointNumericCharacter).join(""); - -/** - * Determine if the event is a valid numeric keyboard event. - * We use this so we can prevent non-number characters in the input - */ -export function isValidNumericKeyboardEvent(event: React.KeyboardEvent) { - if (event.key == null) return true; - - const isModifierKey = event.ctrlKey || event.altKey || event.metaKey; - - if (isModifierKey) { - return true; - } - - const isSingleCharacterKey = event.key.length === 1; - - if (!isSingleCharacterKey) { - return true; - } - - return isFloatingPointNumericCharacter(event.key); -} - -export function getStepFactor(event: KeyboardEvent) { - let ratio = 1; - - if (event.metaKey || event.ctrlKey) { - ratio = 0.1; - } - - if (event.shiftKey) { - ratio = 10; - } - - return ratio; -} - -export * from "./useSpinner"; diff --git a/src/number-input/helpers/useSpinner.ts b/src/number-input/helpers/useSpinner.ts deleted file mode 100644 index f3f1721e5..000000000 --- a/src/number-input/helpers/useSpinner.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { useCallback, useRef, useState } from "react"; -import { useInterval, useUnmountEffect } from "@chakra-ui/hooks"; - -/** - * When click and hold on a button - the speed of auto changing the value. - */ -const CONTINUOUS_CHANGE_INTERVAL = 50; - -/** - * When click and hold on a button - the delay before auto changing the value. - */ -const CONTINUOUS_CHANGE_DELAY = 300; - -type Action = "increment" | "decrement"; - -/** - * React hook used in the number input to spin it's - * value on long press of the spin buttons - * - * @param increment the function to increment - * @param decrement the function to decrement - */ -export function useSpinner(increment: Function, decrement: Function) { - /** - * To keep incrementing/decrementing on press, we call that `spinning` - */ - const [isSpinning, setIsSpinning] = useState(false); - - // This state keeps track of the action ("increment" or "decrement") - const [action, setAction] = useState(null); - - // To increment the value the first time you mousedown, we call that `runOnce` - const [runOnce, setRunOnce] = useState(true); - - // Store the timeout instance id in a ref, so we can clear the timeout later - const timeoutRef = useRef(null); - - // Clears the timeout from memory - const removeTimeout = () => clearTimeout(timeoutRef.current); - - /** - * useInterval hook provides a performant way to - * update the state value at specific interval - */ - useInterval( - () => { - if (action === "increment") { - increment(); - } - if (action === "decrement") { - decrement(); - } - }, - isSpinning ? CONTINUOUS_CHANGE_INTERVAL : null, - ); - - // Function to activate the spinning and increment the value - const up = useCallback(() => { - // increment the first fime - if (runOnce) { - increment(); - } - - // after a delay, keep incrementing at interval ("spinning up") - timeoutRef.current = setTimeout(() => { - setRunOnce(false); - setIsSpinning(true); - setAction("increment"); - }, CONTINUOUS_CHANGE_DELAY); - }, [increment, runOnce]); - - // Function to activate the spinning and increment the value - const down = useCallback(() => { - // decrement the first fime - if (runOnce) { - decrement(); - } - - // after a delay, keep decrementing at interval ("spinning down") - timeoutRef.current = setTimeout(() => { - setRunOnce(false); - setIsSpinning(true); - setAction("decrement"); - }, CONTINUOUS_CHANGE_DELAY); - }, [decrement, runOnce]); - - // Function to stop spinng (useful for mouseup, keyup handlers) - const stop = useCallback(() => { - setRunOnce(true); - setIsSpinning(false); - removeTimeout(); - }, []); - - /** - * If the component unmounts while spinning, - * let's clear the timeout as well - */ - useUnmountEffect(removeTimeout); - - return { up, down, stop }; -} - -export type useSpinnerReturn = ReturnType; diff --git a/src/number-input/index.ts b/src/number-input/index.ts deleted file mode 100644 index 64de08a9a..000000000 --- a/src/number-input/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./__keys"; -export * from "./NumberInput"; -export * from "./NumberInputDecrementButton"; -export * from "./NumberInputIncrementButton"; -export * from "./NumberInputState"; diff --git a/src/number-input/stories/NumberInputBasic.component.tsx b/src/number-input/stories/NumberInputBasic.component.tsx deleted file mode 100644 index e9a81519a..000000000 --- a/src/number-input/stories/NumberInputBasic.component.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import * as React from "react"; - -import { - NumberInput as RenderlesskitNumberInput, - NumberInputDecrementButton, - NumberInputIncrementButton, - NumberinputInitialState, - NumberInputProps as RenderlesskitNumberInputProps, - useNumberInputState, -} from "../../index"; - -export type NumberInputProps = NumberinputInitialState & - Pick< - RenderlesskitNumberInputProps, - "clampValueOnBlur" | "allowMouseWheel" - > & {}; - -export const NumberInput: React.FC = props => { - const state = useNumberInputState(props); - const { clampValueOnBlur, allowMouseWheel } = props; - - // const initialProps = { - // defaultValue: 15, - // min: 10, - // max: 30, - // step: 1, - // precision: 2, - // keepWithinRange: false, - // focusInputOnChange: false, - // }; - - // const state = useNumberInputState(initialProps); - - return ( - - ); -}; - -export default NumberInput; diff --git a/src/number-input/stories/NumberInputBasic.stories.tsx b/src/number-input/stories/NumberInputBasic.stories.tsx deleted file mode 100644 index 3bbae24e5..000000000 --- a/src/number-input/stories/NumberInputBasic.stories.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import * as React from "react"; -import { Controller, useForm } from "react-hook-form"; -import { Meta, Story } from "@storybook/react"; - -import { createPreviewTabs } from "../../../.storybook/utils"; -import { - NumberInput as RenderlesskitNumberInput, - NumberInputDecrementButton, - NumberInputIncrementButton, - useNumberInputState, -} from "../index"; - -import js from "./templates/NumberInputBasicJsx"; -import ts from "./templates/NumberInputBasicTsx"; -import { NumberInput } from "./NumberInputBasic.component"; - -export default { - component: NumberInput, - title: "NumberInput/Basic", - parameters: { - layout: "centered", - preview: createPreviewTabs({ js, ts }), - }, -} as Meta; - -export const Default: Story = args => ; - -export const DefaultValue = Default.bind({}); -DefaultValue.args = { - defaultValue: 15, - min: 10, - max: 20, -}; - -export const Step = Default.bind({}); -Step.args = { - defaultValue: 15, - min: 10, - max: 30, - step: 5, -}; - -export const Precision = Default.bind({}); -Precision.args = { - defaultValue: 15, - min: 10, - max: 30, - step: 0.5, - precision: 2, -}; - -export const KeepWithinRangeFalse = Default.bind({}); -KeepWithinRangeFalse.args = { - defaultValue: 15, - min: 10, - max: 30, - step: 5, - keepWithinRange: false, -}; - -export const FocusInputOnChangeFalse = Default.bind({}); -FocusInputOnChangeFalse.args = { - defaultValue: 15, - min: 10, - max: 30, - step: 5, - focusInputOnChange: false, -}; - -export const ClampValueOnBlurFalse = Default.bind({}); -ClampValueOnBlurFalse.args = { - defaultValue: 15, - min: 10, - max: 30, - step: 5, - clampValueOnBlur: false, -}; - -export const MouseWheelScrollFalse = Default.bind({}); -MouseWheelScrollFalse.args = { - defaultValue: 15, - min: 10, - max: 30, - step: 5, - allowMouseWheel: false, -}; - -export const Disabled = Default.bind({}); -Disabled.args = { - defaultValue: 15, - isDisabled: true, -}; - -export const Readonly = Default.bind({}); -Readonly.args = { - defaultValue: 15, - isReadOnly: true, -}; - -export const Invalid = Default.bind({}); -Invalid.args = { - defaultValue: 15, - max: 10, - keepWithinRange: false, - clampValueOnBlur: false, -}; - -export const Controlled = () => { - const [value, setValue] = React.useState(0); - const onChange = (value: any) => setValue(value); - - return ( -
- onChange(event.target.value)} - style={{ display: "block", marginBottom: "1rem" }} - /> - -
- ); -}; - -const NumberComponent: React.FC = props => { - const { value, onChange, ...rest } = props.field; - const state = useNumberInputState({ defaultValue: value, onChange }); - - return ( - <> - - - - + - - ); -}; - -export const ReactHookForm = () => { - const { control, handleSubmit } = useForm<{ num: number }>(); - - return ( -
{ - alert(JSON.stringify(values)); - })} - > -
- -
-
- ); -}; diff --git a/src/numberfield/index.ts b/src/numberfield/index.ts new file mode 100644 index 000000000..1d056a13a --- /dev/null +++ b/src/numberfield/index.ts @@ -0,0 +1,7 @@ +export * from "./numberfield-base-state"; +export * from "./numberfield-decrement-button"; +export * from "./numberfield-group"; +export * from "./numberfield-increment-button"; +export * from "./numberfield-input"; +export * from "./numberfield-label"; +export * from "./numberfield-state"; diff --git a/src/numberfield/numberfield-base-state.ts b/src/numberfield/numberfield-base-state.ts new file mode 100644 index 000000000..05f449420 --- /dev/null +++ b/src/numberfield/numberfield-base-state.ts @@ -0,0 +1,17 @@ +import { + NumberFieldState, + NumberFieldStateProps, + useNumberFieldState, +} from "@react-stately/numberfield"; + +export function useNumberFieldBaseState( + props: NumberFieldBaseStateProps, +): NumberFieldBaseState { + const state = useNumberFieldState(props); + + return state; +} + +export type NumberFieldBaseState = NumberFieldState & {}; + +export type NumberFieldBaseStateProps = NumberFieldStateProps & {}; diff --git a/src/numberfield/numberfield-decrement-button.ts b/src/numberfield/numberfield-decrement-button.ts new file mode 100644 index 000000000..3ffa38a82 --- /dev/null +++ b/src/numberfield/numberfield-decrement-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 { NumberFieldState } from "./numberfield-state"; + +export const useNumberFieldDecrementButton = + createHook(({ state, ...props }) => { + const ref = useRef(null); + const { buttonProps } = useButton(state.decrementButtonProps, ref); + + props = { ...props, ref: useForkRef(ref, props.ref) }; + props = mergeProps(buttonProps, props); + + return props; + }); + +export const NumberFieldDecrementButton = + createComponent(props => { + const htmlProps = useNumberFieldDecrementButton(props); + + return createElement("button", htmlProps); + }); + +export type NumberFieldDecrementButtonOptions = + Options & { + /** + * Object returned by the `useNumberFieldState` hook. + */ + state: NumberFieldState; + }; + +export type NumberFieldDecrementButtonProps = Props< + NumberFieldDecrementButtonOptions +>; diff --git a/src/numberfield/numberfield-group.ts b/src/numberfield/numberfield-group.ts new file mode 100644 index 000000000..b403c38ec --- /dev/null +++ b/src/numberfield/numberfield-group.ts @@ -0,0 +1,36 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { NumberFieldState } from "./numberfield-state"; + +export const useNumberFieldGroup = createHook( + ({ state, ...props }) => { + props = mergeProps(state.groupProps, props); + + return props; + }, +); + +export const NumberFieldGroup = createComponent( + props => { + const htmlProps = useNumberFieldGroup(props); + + return createElement("div", htmlProps); + }, +); + +export type NumberFieldGroupOptions = Options & { + /** + * Object returned by the `useNumberFieldState` hook. + */ + state: NumberFieldState; +}; + +export type NumberFieldGroupProps = Props< + NumberFieldGroupOptions +>; diff --git a/src/numberfield/numberfield-increment-button.ts b/src/numberfield/numberfield-increment-button.ts new file mode 100644 index 000000000..c09c0bcac --- /dev/null +++ b/src/numberfield/numberfield-increment-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 { NumberFieldState } from "./numberfield-state"; + +export const useNumberFieldIncrementButton = + createHook(({ state, ...props }) => { + const ref = useRef(null); + const { buttonProps } = useButton(state.incrementButtonProps, ref); + + props = { ...props, ref: useForkRef(ref, props.ref) }; + props = mergeProps(buttonProps, props); + + return props; + }); + +export const NumberFieldIncrementButton = + createComponent(props => { + const htmlProps = useNumberFieldIncrementButton(props); + + return createElement("button", htmlProps); + }); + +export type NumberFieldIncrementButtonOptions = + Options & { + /** + * Object returned by the `useNumberFieldState` hook. + */ + state: NumberFieldState; + }; + +export type NumberFieldIncrementButtonProps = Props< + NumberFieldIncrementButtonOptions +>; diff --git a/src/numberfield/numberfield-input.ts b/src/numberfield/numberfield-input.ts new file mode 100644 index 000000000..9ed6c8a6a --- /dev/null +++ b/src/numberfield/numberfield-input.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 { NumberFieldState } from "./numberfield-state"; + +export const useNumberFieldInput = createHook( + ({ state, ...props }) => { + props = { ...props, ref: useForkRef(state.inputRef, props.ref) }; + props = mergeProps(state.inputProps, props); + + return props; + }, +); + +export const NumberFieldInput = createComponent( + props => { + const htmlProps = useNumberFieldInput(props); + + return createElement("input", htmlProps); + }, +); + +export type NumberFieldInputOptions = Options & { + /** + * Object returned by the `useNumberFieldState` hook. + */ + state: NumberFieldState; +}; + +export type NumberFieldInputProps = Props< + NumberFieldInputOptions +>; diff --git a/src/numberfield/numberfield-label.ts b/src/numberfield/numberfield-label.ts new file mode 100644 index 000000000..d1d1ef124 --- /dev/null +++ b/src/numberfield/numberfield-label.ts @@ -0,0 +1,36 @@ +import { mergeProps } from "@react-aria/utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { NumberFieldState } from "./numberfield-state"; + +export const useNumberFieldLabel = createHook( + ({ state, ...props }) => { + props = mergeProps(state.labelProps, props); + + return props; + }, +); + +export const NumberFieldLabel = createComponent( + props => { + const htmlProps = useNumberFieldLabel(props); + + return createElement("label", htmlProps); + }, +); + +export type NumberFieldLabelOptions = Options & { + /** + * Object returned by the `useNumberFieldState` hook. + */ + state: NumberFieldState; +}; + +export type NumberFieldLabelProps = Props< + NumberFieldLabelOptions +>; diff --git a/src/numberfield/numberfield-state.ts b/src/numberfield/numberfield-state.ts new file mode 100644 index 000000000..773a65dbe --- /dev/null +++ b/src/numberfield/numberfield-state.ts @@ -0,0 +1,33 @@ +import { RefObject, useRef } from "react"; +import { NumberFieldAria, useNumberField } from "@react-aria/numberfield"; +import { AriaNumberFieldProps } from "@react-types/numberfield"; + +import { NumberFieldBaseState } from "./numberfield-base-state"; + +export function useNumberFieldState( + props: NumberFieldStateProps, +): NumberFieldState { + const { state: baseState, ...rest } = props; + const inputRef = useRef(null); + const state = useNumberField(rest, baseState, inputRef); + + return { ...state, baseState, inputRef }; +} + +export type NumberFieldState = NumberFieldAria & { + /** + * Reference for the input element in number field element, if any. + */ + inputRef: RefObject; + /** + * Object returned by the `useNumberFieldBaseState` hook. + */ + baseState: NumberFieldBaseState; +}; + +export type NumberFieldStateProps = AriaNumberFieldProps & { + /** + * Object returned by the `useNumberFieldBaseState` hook. + */ + state: NumberFieldBaseState; +}; diff --git a/src/numberfield/stories/NumberFieldBasic.component.tsx b/src/numberfield/stories/NumberFieldBasic.component.tsx new file mode 100644 index 000000000..0b2d3aacf --- /dev/null +++ b/src/numberfield/stories/NumberFieldBasic.component.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { useLocale } from "@react-aria/i18n"; + +import { + NumberFieldBaseStateProps, + NumberFieldDecrementButton, + NumberFieldGroup, + NumberFieldIncrementButton, + NumberFieldInput, + NumberFieldLabel, + useNumberFieldBaseState, + useNumberFieldState, +} from "../../index"; + +export type NumberFieldBasicProps = NumberFieldBaseStateProps & {}; + +export const NumberFieldBasic: React.FC = props => { + let { locale } = useLocale(); + const baseState = useNumberFieldBaseState({ ...props, locale }); + const state = useNumberFieldState({ ...props, state: baseState }); + + return ( +
+ NumberField + + - + + + + +
+ ); +}; + +export default NumberFieldBasic; diff --git a/src/numberfield/stories/NumberFieldBasic.stories.tsx b/src/numberfield/stories/NumberFieldBasic.stories.tsx new file mode 100644 index 000000000..3f3bf8f95 --- /dev/null +++ b/src/numberfield/stories/NumberFieldBasic.stories.tsx @@ -0,0 +1,23 @@ +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/NumberFieldBasicJsx"; +import ts from "./templates/NumberFieldBasicTsx"; +import { NumberFieldBasic } from "./NumberFieldBasic.component"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; + +export default { + title: "NumberField/Basic", + component: NumberFieldBasic, + parameters: { + layout: "centered", + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = { + args: { label: "NumberField" }, +}; diff --git a/src/pagination/Pagination.ts b/src/pagination/Pagination.ts deleted file mode 100644 index 5a67a9da0..000000000 --- a/src/pagination/Pagination.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createComponent, createHook, useCreateElement } from "reakit-system"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { useWarning } from "reakit-warning"; - -import { PAGINATION_KEYS } from "./__keys"; -import { PaginationStateReturn } from "./PaginationState"; - -export type PaginationOptions = RoleOptions & PaginationStateReturn; - -export type PaginationHTMLProps = RoleHTMLProps; - -export type PaginationProps = PaginationOptions & PaginationHTMLProps; - -export const usePagination = createHook( - { - name: "Pagination", - compose: useRole, - keys: PAGINATION_KEYS, - }, -); - -export const Pagination = createComponent({ - as: "nav", - memo: true, - useHook: usePagination, - useCreateElement: (type, props, children) => { - useWarning( - !props["aria-label"] && !props["aria-labelledby"], - "You should provide either `aria-label` or `aria-labelledby` props.", - "See https://mzl.la/2VCL8ys", - ); - return useCreateElement(type, props, children); - }, -}); diff --git a/src/pagination/PaginationButton.ts b/src/pagination/PaginationButton.ts deleted file mode 100644 index b24e8506a..000000000 --- a/src/pagination/PaginationButton.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as React from "react"; -import { ButtonHTMLProps, ButtonOptions, useButton } from "reakit"; -import { callAllHandlers, isNumber } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; - -import { PAGINATION_BUTTON_KEYS } from "./__keys"; -import { PaginationStateReturn } from "./PaginationState"; - -export type TGoto = "nextPage" | "prevPage" | "lastPage" | "firstPage" | number; - -export type PaginationButtonOptions = ButtonOptions & - Pick< - PaginationStateReturn, - | "currentPage" - | "movePage" - | "nextPage" - | "prevPage" - | "firstPage" - | "lastPage" - | "isAtLastPage" - | "isAtFirstPage" - > & { - goto: TGoto; - }; - -export type PaginationButtonHTMLProps = ButtonHTMLProps; - -export type PaginationButtonProps = PaginationButtonOptions & - PaginationButtonHTMLProps; - -export const usePaginationButton = createHook< - PaginationButtonOptions, - PaginationButtonHTMLProps ->({ - name: "PaginationButton", - compose: useButton, - keys: PAGINATION_BUTTON_KEYS, - - useOptions(options, { disabled: htmlDisabled }) { - const { goto, isAtLastPage, isAtFirstPage } = options; - let disabled = false; - - if (goto === "nextPage" || goto === "lastPage") { - disabled = isAtLastPage; - } - - if (goto === "prevPage" || goto === "firstPage") { - disabled = isAtFirstPage; - } - - return { - disabled: htmlDisabled || disabled, - ...options, - }; - }, - - useProps(options, { onClick: htmlOnClick, ...htmlProps }) { - const { currentPage, goto } = options; - const isCurrent = currentPage === goto; - - const onClick = React.useCallback(() => { - if (options.disabled) return; - - if (isNumber(goto)) { - options.movePage?.(goto); - return; - } - - if (["nextPage", "prevPage", "lastPage", "firstPage"].includes(goto)) { - options[goto]?.(); - return; - } - }, [goto, options]); - - const ariaLabel = isCurrent - ? `Page ${goto}` - : `Go to ${goto === "prevPage" ? "previous" : goto} Page`; - - return { - "aria-label": ariaLabel, - "aria-current": isCurrent ? true : undefined, - onClick: callAllHandlers(onClick, htmlOnClick), - ...htmlProps, - }; - }, -}); - -export const PaginationButton = createComponent({ - as: "button", - memo: true, - useHook: usePaginationButton, -}); diff --git a/src/pagination/__keys.ts b/src/pagination/__keys.ts deleted file mode 100644 index 2697a1804..000000000 --- a/src/pagination/__keys.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Automatically generated -export const USE_PAGINATION_STATE_KEYS = [ - "defaultPage", - "page", - "onChange", - "count", - "boundaryCount", - "siblingCount", -] as const; -export const PAGINATION_STATE_KEYS = [ - "currentPage", - "pages", - "isAtFirstPage", - "isAtLastPage", - "movePage", - "nextPage", - "prevPage", - "firstPage", - "lastPage", -] as const; -export const PAGINATION_KEYS = PAGINATION_STATE_KEYS; -export const PAGINATION_BUTTON_KEYS = [...PAGINATION_KEYS, "goto"] as const; diff --git a/src/pagination/__tests__/Pagination.test.tsx b/src/pagination/__tests__/Pagination.test.tsx deleted file mode 100644 index 438ddc50b..000000000 --- a/src/pagination/__tests__/Pagination.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import * as React from "react"; -import { axe, press, render } from "reakit-test-utils"; - -import { repeat } from "../../utils/test-utils"; -import { - Pagination, - PaginationButton, - PaginationInitialState, - TGoto, - usePaginationState, -} from "../index"; - -const PaginationComp: React.FC = props => { - const state = usePaginationState({ count: 10, ...props }); - - return ( - -
    -
  • - - First - -
  • -
  • - - Previous - -
  • - {state.pages.map(page => { - if (page === "start-ellipsis" || page === "end-ellipsis") { - return
  • ...
  • ; - } - - return ( -
  • - - {page} - -
  • - ); - })} -
  • - - Next - -
  • -
  • - - Last - -
  • -
-
- ); -}; - -describe("Pagination", () => { - it("should render correctly", () => { - const { queryByText: text } = render(); - - const prev = text("Previous"); - const next = text("Next"); - const first = text("First"); - const last = text("Last"); - - press.Tab(); - expect(text("1")).toHaveFocus(); - expect(prev).toBeDisabled(); - expect(first).toBeDisabled(); - - press.Tab(); - expect(text("2")).toHaveFocus(); - - press.Enter(); - expect(first).not.toBeDisabled(); - expect(prev).not.toBeDisabled(); - - repeat(press.Tab, 3); - press.Enter(); - expect(text("2")).toBeNull(); - - repeat(press.Tab, 2); - press.Enter(); - expect(text("5")).toBeNull(); - expect(last).toBeDisabled(); - expect(next).toBeDisabled(); - }); - - test("Pagination renders with no a11y violations", async () => { - const { container } = render(); - const results = await axe(container); - - expect(results).toHaveNoViolations(); - }); -}); diff --git a/src/pagination/__tests__/PaginationState.test.ts b/src/pagination/__tests__/PaginationState.test.ts deleted file mode 100644 index 5e19f17a3..000000000 --- a/src/pagination/__tests__/PaginationState.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { act, renderHook } from "reakit-test-utils/hooks"; - -import { usePaginationState } from ".."; - -describe("usePaginationState", () => { - it("should render correctly", () => { - const { - result: { current }, - } = renderHook(() => usePaginationState({ count: 5 })); - - expect(current).toMatchInlineSnapshot(` - Object { - "currentPage": 1, - "firstPage": [Function], - "isAtFirstPage": true, - "isAtLastPage": false, - "lastPage": [Function], - "movePage": [Function], - "nextPage": [Function], - "pages": Array [ - 1, - 2, - 3, - 4, - 5, - ], - "prevPage": [Function], - } - `); - }); - - it("should rover to next/prev/last/first/move", async () => { - const { result } = renderHook(() => usePaginationState({ count: 5 })); - - expect(result.current.isAtFirstPage).toBe(true); - expect(result.current.isAtLastPage).toBe(false); - expect(result.current.currentPage).toBe(1); - - act(() => { - result.current.nextPage(); - }); - - expect(result.current.isAtFirstPage).toBe(false); - expect(result.current.isAtLastPage).toBe(false); - expect(result.current.currentPage).toBe(2); - - act(() => { - result.current.nextPage(); - }); - - expect(result.current.currentPage).toBe(3); - - // last - act(() => { - result.current.lastPage(); - }); - - expect(result.current.isAtFirstPage).toBe(false); - expect(result.current.isAtLastPage).toBe(true); - expect(result.current.currentPage).toBe(5); - - // first - act(() => { - result.current.firstPage(); - }); - - expect(result.current.isAtFirstPage).toBe(true); - expect(result.current.isAtLastPage).toBe(false); - expect(result.current.currentPage).toBe(1); - - // move - act(() => { - result.current.movePage(3); - }); - - expect(result.current.isAtFirstPage).toBe(false); - expect(result.current.isAtLastPage).toBe(false); - expect(result.current.currentPage).toBe(3); - }); - - it("has a disabled previous button & an enabled next button when count > 1", () => { - const { isAtFirstPage, isAtLastPage } = renderHook(() => - usePaginationState({ count: 2 }), - ).result.current; - - expect(isAtFirstPage).toBe(true); - expect(isAtLastPage).toBe(false); - }); - - it("should have disabled next button if currentPage === count & enabled previous button", () => { - const { isAtFirstPage, isAtLastPage } = renderHook(() => - usePaginationState({ count: 2, defaultPage: 2 }), - ).result.current; - - expect(isAtFirstPage).toBe(false); - expect(isAtLastPage).toBe(true); - }); - - it("has no ellipses when count <= 7", () => { - const { pages } = renderHook(() => usePaginationState({ count: 7 })).result - .current; - - expect(pages).not.toContain("end-ellipsis"); - }); - - it("should have end-ellipses when count >= 8", () => { - const { pages } = renderHook(() => usePaginationState({ count: 8 })).result - .current; - - expect(pages).toContain("end-ellipsis"); - }); - - it("should have start-ellipses when currentPage >= 5", () => { - const { pages } = renderHook(() => - usePaginationState({ count: 8, defaultPage: 5 }), - ).result.current; - - expect(pages).toContain("start-ellipsis"); - }); - - it("should have start-ellipses & end-ellipsis when count >= 5", () => { - const { pages } = renderHook(() => - usePaginationState({ count: 9, defaultPage: 5 }), - ).result.current; - - expect(pages).toContain("start-ellipsis"); - expect(pages).toContain("end-ellipsis"); - }); - - it("should have proper boundryCount", () => { - const { - result: { - current: { pages }, - }, - } = renderHook(() => - usePaginationState({ - count: 40, - boundaryCount: 5, - defaultPage: 20, - }), - ); - - expect(pages).toHaveLength(15); - expect(pages.slice(0, 6)).toEqual([1, 2, 3, 4, 5, "start-ellipsis"]); - expect(pages.slice(-6)).toEqual(["end-ellipsis", 36, 37, 38, 39, 40]); - }); - - it("should have proper siblingCount", () => { - const { - result: { - current: { pages }, - }, - } = renderHook(() => - usePaginationState({ - count: 11, - siblingCount: 2, - defaultPage: 6, - }), - ); - - expect(pages).toHaveLength(9); - expect(pages).toEqual([ - 1, - "start-ellipsis", - 4, // siblings - 5, // siblings - 6, // default page - 7, // siblings - 8, // siblings - "end-ellipsis", - 11, - ]); - }); -}); diff --git a/src/pagination/__utils.ts b/src/pagination/__utils.ts new file mode 100644 index 000000000..9bbe28112 --- /dev/null +++ b/src/pagination/__utils.ts @@ -0,0 +1,5 @@ +import { createStoreContext } from "ariakit-utils/store"; + +import { PaginationState } from "./pagination-state"; + +export const PaginationContextState = createStoreContext(); diff --git a/src/pagination/index.ts b/src/pagination/index.ts index 615bb03f7..ad0b7688c 100644 --- a/src/pagination/index.ts +++ b/src/pagination/index.ts @@ -1,4 +1,3 @@ -export * from "./__keys"; -export * from "./Pagination"; -export * from "./PaginationButton"; -export * from "./PaginationState"; +export * from "./pagination-base"; +export * from "./pagination-button"; +export * from "./pagination-state"; diff --git a/src/pagination/pagination-base.ts b/src/pagination/pagination-base.ts new file mode 100644 index 000000000..5dc4e14f7 --- /dev/null +++ b/src/pagination/pagination-base.ts @@ -0,0 +1,34 @@ +import { useStoreProvider } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { PaginationContextState } from "./__utils"; +import { PaginationState } from "./pagination-state"; + +export const usePagination = createHook( + ({ state, ...props }) => { + props = useStoreProvider({ state, ...props }, PaginationContextState); + props = { "aria-label": "pagination", ...props }; + + return props; + }, +); + +export const Pagination = createComponent(props => { + const htmlProps = usePagination(props); + + return createElement("div", htmlProps); +}); + +export type PaginationOptions = Options & { + /** + * Object returned by the `usePaginationState` hook. + */ + state: PaginationState; +}; + +export type PaginationProps = Props>; diff --git a/src/pagination/pagination-button.ts b/src/pagination/pagination-button.ts new file mode 100644 index 000000000..a8ae38c4c --- /dev/null +++ b/src/pagination/pagination-button.ts @@ -0,0 +1,92 @@ +import { MouseEvent } from "react"; +import { isNumber } from "@chakra-ui/utils"; +import { useEvent, useStore } from "ariakit-utils"; +import { + createComponent, + createElement, + createHook, +} from "ariakit-utils/system"; +import { As, Options, Props } from "ariakit-utils/types"; + +import { PaginationContextState } from "./__utils"; +import { PaginationState } from "./pagination-state"; + +export const usePaginationButton = createHook( + ({ state, goto, ...props }) => { + state = useStore(state || PaginationContextState, [ + "isAtFirstPage", + "isAtLastPage", + "currentPage", + ]); + + if (!state) return props; + + const { isAtLastPage, isAtFirstPage, currentPage } = state; + const isCurrent = currentPage === goto; + let disabled = false; + + if (goto === "nextPage" || goto === "lastPage") { + disabled = isAtLastPage; + } + + if (goto === "prevPage" || goto === "firstPage") { + disabled = isAtFirstPage; + } + + disabled = props.disabled || disabled; + + const ariaLabel = isCurrent + ? `Page ${goto}` + : `Go to ${goto === "prevPage" ? "previous" : goto} Page`; + + const onClickProp = props.onClick; + + const onClick = useEvent((event: MouseEvent) => { + onClickProp?.(event); + if (event.defaultPrevented) return; + if (disabled) return; + + if (isNumber(goto)) { + state?.movePage?.(goto); + return; + } + + if (["nextPage", "prevPage", "lastPage", "firstPage"].includes(goto)) { + state?.[goto]?.(); + return; + } + }); + + props = { + "aria-label": ariaLabel, + "aria-current": isCurrent ? true : undefined, + disabled, + onClick, + ...props, + }; + + return props; + }, +); + +export const PaginationButton = createComponent( + props => { + const htmlProps = usePaginationButton(props); + + return createElement("button", htmlProps); + }, +); + +export type Goto = "nextPage" | "prevPage" | "lastPage" | "firstPage" | number; + +export type PaginationButtonOptions = Options & { + /** + * Object returned by the `usePaginationButtonState` hook. + */ + state?: PaginationState; + goto: Goto; +}; + +export type PaginationButtonProps = Props< + PaginationButtonOptions +>; diff --git a/src/pagination/PaginationState.ts b/src/pagination/pagination-state.ts similarity index 68% rename from src/pagination/PaginationState.ts rename to src/pagination/pagination-state.ts index 1f0eda26a..a466600bf 100644 --- a/src/pagination/PaginationState.ts +++ b/src/pagination/pagination-state.ts @@ -2,9 +2,142 @@ * Thanks to [Material UI](https://material-ui.com/) * Based on the logic from [usePagination Hook](https://github.com/mui-org/material-ui/blob/master/packages/material-ui-lab/src/Pagination/usePagination.js) */ -import React from "react"; +import React, { useMemo } from "react"; +import { useControlledState, useStorePublisher } from "ariakit-utils"; -import { useControllableState } from "../utils"; +export const usePaginationState = ( + props: PaginationStateProps = {}, +): PaginationState => { + const { + defaultPage = 1, + page: currentPageProp, + onChange, + count = 1, + boundaryCount = 1, + siblingCount = 1, + } = props; + + const [currentPage, setCurrentPage] = useControlledState( + defaultPage, + currentPageProp, + onChange, + ); + + const startPages = range(1, Math.min(boundaryCount, count)); + const endPages = range( + Math.max(count - boundaryCount + 1, boundaryCount + 1), + count, + ); + + const siblingsStart = Math.max( + Math.min( + // Natural start + currentPage - siblingCount, + // Lower boundary when page is high + count - boundaryCount - siblingCount * 2 - 1, + ), + // Greater than startPages + boundaryCount + 2, + ); + + const siblingsEnd = Math.min( + Math.max( + // Natural end + currentPage + siblingCount, + // Upper boundary when page is low + boundaryCount + siblingCount * 2 + 2, + ), + // Less than endPages + endPages[0] - 2, + ); + + // Page to render + // e.g. pages = [1, 'start-ellipsis', 4, 5, 6, 'end-ellipsis', 10] + const pages = useMemo( + () => [ + ...startPages, + + // Start ellipsis + ...(siblingsStart > boundaryCount + 2 + ? ["start-ellipsis"] + : boundaryCount + 1 < count - boundaryCount + ? [boundaryCount + 1] + : []), + + // Sibling pages + ...range(siblingsStart, siblingsEnd), + + // End ellipsis + ...(siblingsEnd < count - boundaryCount - 1 + ? ["end-ellipsis"] + : count - boundaryCount > boundaryCount + ? [count - boundaryCount] + : []), + + ...endPages, + ], + [boundaryCount, count, endPages, siblingsEnd, siblingsStart, startPages], + ); + + const nextPage = React.useCallback(() => { + setCurrentPage(prevPage => prevPage + 1); + }, [setCurrentPage]); + + const prevPage = React.useCallback(() => { + setCurrentPage(prevPage => prevPage - 1); + }, [setCurrentPage]); + + const firstPage = React.useCallback(() => { + setCurrentPage(1); + }, [setCurrentPage]); + + const lastPage = React.useCallback(() => { + setCurrentPage(count); + }, [count, setCurrentPage]); + + const movePage = React.useCallback( + (page: number) => { + if (page >= 1 && page <= count) setCurrentPage(page); + }, + [count, setCurrentPage], + ); + + const isAtLastPage = currentPage >= count; + const isAtFirstPage = currentPage <= 1; + + const state = useMemo( + () => ({ + pages, + currentPage, + isAtLastPage, + isAtFirstPage, + nextPage, + prevPage, + movePage, + firstPage, + lastPage, + }), + [ + pages, + currentPage, + isAtLastPage, + isAtFirstPage, + nextPage, + prevPage, + movePage, + firstPage, + lastPage, + ], + ); + + return useStorePublisher(state); +}; + +function range(start: number, end: number) { + const length = end - start + 1; + + return Array.from({ length }, (_, i) => start + i); +} export type PaginationState = { /** @@ -23,9 +156,6 @@ export type PaginationState = { * True, if the currentPage is at last page */ isAtLastPage: boolean; -}; - -export type PaginationAction = { /** * Go to the specified page number */ @@ -48,7 +178,7 @@ export type PaginationAction = { lastPage: () => void; }; -export type PaginationInitialState = { +export type PaginationStateProps = { /** * Set the default page(uncontrollable) * @@ -82,118 +212,3 @@ export type PaginationInitialState = { */ siblingCount?: number; }; - -export type PaginationStateReturn = PaginationState & PaginationAction; - -export const usePaginationState = ( - props: PaginationInitialState = {}, -): PaginationStateReturn => { - const { - defaultPage = 1, - page: currentPageProp, - onChange, - count = 1, - boundaryCount = 1, - siblingCount = 1, - } = props; - - const [currentPage, setCurrentPage] = useControllableState({ - value: currentPageProp, - defaultValue: defaultPage, - onChange, - }); - - const startPages = range(1, Math.min(boundaryCount, count)); - const endPages = range( - Math.max(count - boundaryCount + 1, boundaryCount + 1), - count, - ); - - const siblingsStart = Math.max( - Math.min( - // Natural start - currentPage - siblingCount, - // Lower boundary when page is high - count - boundaryCount - siblingCount * 2 - 1, - ), - // Greater than startPages - boundaryCount + 2, - ); - - const siblingsEnd = Math.min( - Math.max( - // Natural end - currentPage + siblingCount, - // Upper boundary when page is low - boundaryCount + siblingCount * 2 + 2, - ), - // Less than endPages - endPages[0] - 2, - ); - - // Page to render - // e.g. pages = [1, 'start-ellipsis', 4, 5, 6, 'end-ellipsis', 10] - const pages = [ - ...startPages, - - // Start ellipsis - ...(siblingsStart > boundaryCount + 2 - ? ["start-ellipsis"] - : boundaryCount + 1 < count - boundaryCount - ? [boundaryCount + 1] - : []), - - // Sibling pages - ...range(siblingsStart, siblingsEnd), - - // End ellipsis - ...(siblingsEnd < count - boundaryCount - 1 - ? ["end-ellipsis"] - : count - boundaryCount > boundaryCount - ? [count - boundaryCount] - : []), - - ...endPages, - ]; - - const nextPage = React.useCallback(() => { - setCurrentPage(prevPage => prevPage + 1); - }, [setCurrentPage]); - - const prevPage = React.useCallback(() => { - setCurrentPage(prevPage => prevPage - 1); - }, [setCurrentPage]); - - const firstPage = React.useCallback(() => { - setCurrentPage(1); - }, [setCurrentPage]); - - const lastPage = React.useCallback(() => { - setCurrentPage(count); - }, [count, setCurrentPage]); - - const movePage = React.useCallback( - page => { - if (page >= 1 && page <= count) setCurrentPage(page); - }, - [count, setCurrentPage], - ); - - return { - pages, - currentPage, - isAtLastPage: currentPage >= count, - isAtFirstPage: currentPage <= 1, - nextPage, - prevPage, - movePage, - firstPage, - lastPage, - }; -}; - -function range(start: number, end: number) { - const length = end - start + 1; - - return Array.from({ length }, (_, i) => start + i); -} diff --git a/src/pagination/stories/PaginationBasic.component.tsx b/src/pagination/stories/PaginationBasic.component.tsx index 6fddca828..8d677c2fc 100644 --- a/src/pagination/stories/PaginationBasic.component.tsx +++ b/src/pagination/stories/PaginationBasic.component.tsx @@ -1,28 +1,24 @@ import * as React from "react"; import { - Pagination as RenderlesskitPagination, + Goto, + Pagination, PaginationButton, - PaginationInitialState, - TGoto, + PaginationStateProps, usePaginationState, } from "../../index"; -export const Pagination: React.FC = props => { +export const PaginationBasic: React.FC = props => { const state = usePaginationState({ count: 10, ...props }); return ( - +
  • - - First - + First
  • - - Previous - + Previous
  • {state.pages.map(page => { if (page === "start-ellipsis" || page === "end-ellipsis") { @@ -32,11 +28,10 @@ export const Pagination: React.FC = props => { return (
  • {page} @@ -44,18 +39,14 @@ export const Pagination: React.FC = props => { ); })}
  • - - Next - + Next
  • - - Last - + Last
-
+ ); }; -export default Pagination; +export default PaginationBasic; diff --git a/src/pagination/stories/PaginationBasic.stories.tsx b/src/pagination/stories/PaginationBasic.stories.tsx index 4fbfa44e8..3553a4b8a 100644 --- a/src/pagination/stories/PaginationBasic.stories.tsx +++ b/src/pagination/stories/PaginationBasic.stories.tsx @@ -1,39 +1,46 @@ -import * as React from "react"; -import { Meta, Story } from "@storybook/react"; +import { ComponentMeta, ComponentStoryObj } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; import js from "./templates/PaginationBasicJsx"; import ts from "./templates/PaginationBasicTsx"; -import { Pagination } from "./PaginationBasic.component"; +import { PaginationBasic } from "./PaginationBasic.component"; + +type Meta = ComponentMeta; +type Story = ComponentStoryObj; export default { - component: Pagination, title: "Pagination/Basic", + component: PaginationBasic, parameters: { layout: "centered", preview: createPreviewTabs({ js, ts }), }, } as Meta; -export const Default: Story = args => ; +export const Default: Story = { + args: {}, +}; -export const DefaultPage = Default.bind({}); -DefaultPage.args = { - defaultPage: 5, - count: 10, +export const DefaultPage = { + args: { + defaultPage: 5, + count: 10, + }, }; -export const BoundaryCount = Default.bind({}); -BoundaryCount.args = { - defaultPage: 25, - count: 50, - boundaryCount: 5, +export const BoundaryCount = { + args: { + defaultPage: 25, + count: 50, + boundaryCount: 5, + }, }; -export const SibilingCount = Default.bind({}); -SibilingCount.args = { - defaultPage: 25, - count: 50, - sibilingCount: 5, +export const SibilingCount = { + args: { + defaultPage: 25, + count: 50, + sibilingCount: 5, + }, }; diff --git a/src/picker-base/PickerBase.ts b/src/picker-base/PickerBase.ts deleted file mode 100644 index 11014a21b..000000000 --- a/src/picker-base/PickerBase.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as React from "react"; -import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; -import { createOnKeyDown } from "reakit-utils"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; -import { ariaAttr, isTouch } from "../utils"; - -import { PICKER_BASE_KEYS } from "./__keys"; -import { PickerBaseStateReturn } from "./PickerBaseState"; - -export type PickerBaseOptions = RoleOptions & - Pick< - PickerBaseStateReturn, - | "visible" - | "isDisabled" - | "isReadOnly" - | "show" - | "pickerId" - | "dialogId" - | "segmentFocus" - >; - -export type PickerBaseHTMLProps = RoleHTMLProps; - -export type PickerBaseProps = PickerBaseOptions & PickerBaseHTMLProps; - -export const usePickerBase = createHook( - { - name: "PickerBase", - compose: useRole, - keys: PICKER_BASE_KEYS, - - useProps( - options, - { - onKeyDown: htmlOnKeyDown, - onClick: htmlOnClick, - onMouseDown: htmlOnMouseDown, - ...htmlProps - }, - ) { - const { - visible, - isDisabled, - isReadOnly, - show, - pickerId, - dialogId, - segmentFocus, - } = options; - - const onClick = React.useCallback(() => { - if (isTouch()) show(); - }, [show]); - - // Open the popover on alt + arrow down - const onKeyDown = createOnKeyDown({ - onKey: htmlOnKeyDown, - preventDefault: true, - keyMap: event => { - const isAlt = event.altKey; - - return { - ArrowDown: () => { - isAlt && show(); - }, - }; - }, - }); - - const onMouseDown = React.useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - segmentFocus?.(); - }, - [segmentFocus], - ); - - return { - id: pickerId, - role: "button", - "aria-haspopup": "dialog", - "aria-expanded": visible, - "aria-owns": visible ? dialogId : undefined, - "aria-disabled": ariaAttr(isDisabled), - "aria-readonly": ariaAttr(isReadOnly), - onKeyDown: callAllHandlers(htmlOnKeyDown, onKeyDown), - onClick: callAllHandlers(htmlOnClick, onClick), - onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), - ...htmlProps, - }; - }, - }, -); - -export const PickerBase = createComponent({ - as: "div", - memo: true, - useHook: usePickerBase, -}); diff --git a/src/picker-base/PickerBaseContent.ts b/src/picker-base/PickerBaseContent.ts deleted file mode 100644 index 333befcf3..000000000 --- a/src/picker-base/PickerBaseContent.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { PopoverHTMLProps, PopoverOptions, usePopover } from "reakit"; - -import { createComponent, createHook } from "../system"; - -import { PICKER_BASE_CONTENT_KEYS } from "./__keys"; -import { PickerBaseStateReturn } from "./PickerBaseState"; - -export type PickerBaseContentOptions = PopoverOptions & - Pick; - -export type PickerBaseContentHTMLProps = PopoverHTMLProps; - -export type PickerBaseContentProps = PickerBaseContentOptions & - PickerBaseContentHTMLProps; - -export const usePickerBaseContent = createHook< - PickerBaseContentOptions, - PickerBaseContentHTMLProps ->({ - name: "PickerBaseContent", - compose: usePopover, - keys: PICKER_BASE_CONTENT_KEYS, - - useProps(options, htmlProps) { - return { id: options.dialogId, ...htmlProps }; - }, -}); - -export const PickerBaseContent = createComponent({ - as: "div", - memo: true, - useHook: usePickerBaseContent, -}); diff --git a/src/picker-base/PickerBaseState.ts b/src/picker-base/PickerBaseState.ts deleted file mode 100644 index ac0a133b6..000000000 --- a/src/picker-base/PickerBaseState.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - PopoverInitialState, - unstable_useId as useId, - usePopoverState, -} from "reakit"; -import { InputBase } from "@react-types/shared"; - -export type PickerBaseInitialState = PopoverInitialState & - InputBase & { - /** - * Picker wrapper Id - */ - pickerId?: string; - /** - * Dialog Id - */ - dialogId?: string; - /** - * Function to be called on picker mousedown - * for focusing first tabbable element - */ - segmentFocus?: () => void; - }; - -export const usePickerBaseState = (props: PickerBaseInitialState = {}) => { - const { - pickerId: pickerIdProp, - dialogId: dialogIdProp, - isDisabled, - isReadOnly, - segmentFocus, - } = props; - - const { id: pickerId } = useId({ id: pickerIdProp, baseId: "picker" }); - const { id: dialogId } = useId({ id: dialogIdProp, baseId: "dialog" }); - - const popover = usePopoverState({ modal: true, ...props }); - - return { - pickerId, - dialogId, - isDisabled, - isReadOnly, - segmentFocus, - ...popover, - }; -}; - -export type PickerBaseStateReturn = ReturnType; diff --git a/src/picker-base/PickerBaseTrigger.ts b/src/picker-base/PickerBaseTrigger.ts deleted file mode 100644 index c940dc398..000000000 --- a/src/picker-base/PickerBaseTrigger.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - PopoverDisclosureHTMLProps, - PopoverDisclosureOptions, - usePopoverDisclosure, -} from "reakit"; -import { callAllHandlers } from "@chakra-ui/utils"; - -import { createComponent, createHook } from "../system"; - -import { PICKER_BASE_TRIGGER_KEYS } from "./__keys"; -import { PickerBaseStateReturn } from "./PickerBaseState"; - -export type PickerBaseTriggerOptions = PopoverDisclosureOptions & - Pick; - -export type PickerBaseTriggerHTMLProps = PopoverDisclosureHTMLProps; - -export type PickerBaseTriggerProps = PickerBaseTriggerOptions & - PickerBaseTriggerHTMLProps; - -export const usePickerBaseTrigger = createHook< - PickerBaseTriggerOptions, - PickerBaseTriggerHTMLProps ->({ - name: "PickerBaseTrigger", - compose: usePopoverDisclosure, - keys: PICKER_BASE_TRIGGER_KEYS, - - useOptions(options, _) { - return { - disabled: options.isDisabled || options.isReadOnly, - ...options, - }; - }, - - useProps(_, { onMouseDown: htmlOnMouseDown, ...htmlProps }) { - const onMouseDown = (e: React.MouseEvent) => { - e.stopPropagation(); - }; - - return { - tabIndex: -1, - onMouseDown: callAllHandlers(htmlOnMouseDown, onMouseDown), - ...htmlProps, - }; - }, -}); - -export const PickerBaseTrigger = createComponent({ - as: "button", - memo: true, - useHook: usePickerBaseTrigger, -}); diff --git a/src/picker-base/__keys.ts b/src/picker-base/__keys.ts deleted file mode 100644 index c73e84774..000000000 --- a/src/picker-base/__keys.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Automatically generated -export const USE_PICKER_BASE_STATE_KEYS = [ - "baseId", - "visible", - "animated", - "modal", - "placement", - "unstable_fixed", - "unstable_flip", - "unstable_offset", - "gutter", - "unstable_preventOverflow", - "isDisabled", - "isReadOnly", - "pickerId", - "dialogId", - "segmentFocus", -] as const; -export const PICKER_BASE_STATE_KEYS = [ - "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", -] as const; -export const PICKER_BASE_KEYS = PICKER_BASE_STATE_KEYS; -export const PICKER_BASE_CONTENT_KEYS = PICKER_BASE_KEYS; -export const PICKER_BASE_TRIGGER_KEYS = PICKER_BASE_CONTENT_KEYS; diff --git a/src/picker-base/__tests__/BasePicker.test.tsx b/src/picker-base/__tests__/BasePicker.test.tsx deleted file mode 100644 index efba8c029..000000000 --- a/src/picker-base/__tests__/BasePicker.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from "react"; -import { axe, fireEvent, press, render, screen } from "reakit-test-utils"; - -import { - PickerBase, - PickerBaseContent, - PickerBaseInitialState, - PickerBaseTrigger, - usePickerBaseState, -} from "../index"; - -const PickerBaseComp: React.FC = props => { - const state = usePickerBaseState({ - ...props, - pickerId: "picker-1", - dialogId: "dialog-1", - baseId: "picker-test", - }); - - return ( - <> - - open - - - Content - - - ); -}; - -describe("PickerBase", () => { - it("should render correctly", () => { - const { baseElement } = render(); - - expect(baseElement).toMatchSnapshot(); - }); - - it("should open/close properly", () => { - render(); - - const pickerContent = screen.getByTestId("testid-picker-content"); - - expect(pickerContent).not.toBeVisible(); - fireEvent.click(screen.getByText("open")); - expect(pickerContent).toBeVisible(); - - press.Escape(); - expect(pickerContent).not.toBeVisible(); - }); - - test("PickerBase 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/picker-base/__tests__/__snapshots__/BasePicker.test.tsx.snap b/src/picker-base/__tests__/__snapshots__/BasePicker.test.tsx.snap deleted file mode 100644 index 4d442e1aa..000000000 --- a/src/picker-base/__tests__/__snapshots__/BasePicker.test.tsx.snap +++ /dev/null @@ -1,55 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`PickerBase should render correctly 1`] = ` - -
-
- -
-
-