diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index dac1e09ec..0d8aac78c 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -10,5 +10,6 @@ "Noto Color Emoji"; min-width: 100%; min-height: 100%; + -webkit-overflow-scrolling: touch; } diff --git a/package.json b/package.json index dfdc2cf2e..cc947ca43 100644 --- a/package.json +++ b/package.json @@ -90,12 +90,17 @@ "@chakra-ui/hooks": "^1.6.1", "@chakra-ui/react-utils": "^1.1.2", "@chakra-ui/utils": "^1.8.3", + "@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.2", "@react-aria/interactions": "^3.6.0", "@react-aria/spinbutton": "^3.0.1", "@react-aria/utils": "^3.9.0", "date-fns": "^2.25.0", - "raf": "^3.4.1", + "framer-motion": "^5.3.1", + "react-remove-scroll": "^2.4.3", + "react-spring": "^9.3.1", "reakit-system": "^0.15.2", "reakit-utils": "^0.15.2", "reakit-warning": "^0.6.2" @@ -132,7 +137,6 @@ "@types/jest-in-case": "1.0.5", "@types/mockdate": "3.0.0", "@types/node": "16.11.7", - "@types/raf": "3.4.0", "@types/react": "17.0.34", "@types/react-dom": "17.0.11", "@types/react-transition-group": "4.4.4", diff --git a/src/checkbox/Checkbox.tsx b/src/checkbox/Checkbox.tsx index f4ba8d19f..d1072342e 100644 --- a/src/checkbox/Checkbox.tsx +++ b/src/checkbox/Checkbox.tsx @@ -67,7 +67,6 @@ export const useCheckbox = createHook({ warning( true, "Can't determine whether the element is a native checkbox because `ref` wasn't passed to the component", - "See https://reakit.io/docs/checkbox", ); return; } diff --git a/src/checkbox/helpers.tsx b/src/checkbox/helpers.tsx index 6d6b48d38..77b0a1ed6 100644 --- a/src/checkbox/helpers.tsx +++ b/src/checkbox/helpers.tsx @@ -43,7 +43,6 @@ export function useIndeterminateState( warning( state === "indeterminate", "Can't set indeterminate state because `ref` wasn't passed to component.", - "See https://reakit.io/docs/checkbox/#indeterminate-state", ); return; } diff --git a/src/dialog/Dialog.tsx b/src/dialog/Dialog.tsx new file mode 100644 index 000000000..7d42c0215 --- /dev/null +++ b/src/dialog/Dialog.tsx @@ -0,0 +1,234 @@ +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 new file mode 100644 index 000000000..c874f6570 --- /dev/null +++ b/src/dialog/DialogBackdrop.tsx @@ -0,0 +1,89 @@ +import * as React from "react"; +import { RemoveScroll } from "react-remove-scroll"; +import { createComponent, createHook } from "reakit-system"; +import { Portal } from "reakit"; + +import { + DisclosureContentHTMLProps, + DisclosureContentOptions, + useDisclosureContent, +} from "../disclosure/DisclosureContent"; + +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 new file mode 100644 index 000000000..8b882ed18 --- /dev/null +++ b/src/dialog/DialogDisclosure.ts @@ -0,0 +1,85 @@ +import * as React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { useForkRef, useLiveRef } from "reakit-utils"; +import { warning } from "reakit-warning"; +import { useSafeLayoutEffect } from "@chakra-ui/hooks"; + +import { + DisclosureHTMLProps, + DisclosureOptions, + useDisclosure, +} from "../disclosure"; + +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 new file mode 100644 index 000000000..df13fbf98 --- /dev/null +++ b/src/dialog/DialogState.ts @@ -0,0 +1,55 @@ +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 new file mode 100644 index 000000000..ecac0c78d --- /dev/null +++ b/src/dialog/__keys.ts @@ -0,0 +1,37 @@ +// 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/helpers/DialogBackdropContext.ts b/src/dialog/helpers/DialogBackdropContext.ts new file mode 100644 index 000000000..6c090a44a --- /dev/null +++ b/src/dialog/helpers/DialogBackdropContext.ts @@ -0,0 +1,5 @@ +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 new file mode 100644 index 000000000..0538f4645 --- /dev/null +++ b/src/dialog/helpers/index.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 000000000..bdacdb351 --- /dev/null +++ b/src/dialog/helpers/useDisableHoverOutside.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 000000000..26f2a225f --- /dev/null +++ b/src/dialog/helpers/useDisclosureRef.ts @@ -0,0 +1,59 @@ +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.present) { + // 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.present, options.disclosureRef, dialogRef]); + + React.useEffect(() => { + if (!options.visible && options.present) { + // 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.present, options.disclosureRef]); + + return options.disclosureRef || ref; +} diff --git a/src/dialog/helpers/useEventListenerOutside.ts b/src/dialog/helpers/useEventListenerOutside.ts new file mode 100644 index 000000000..9033145cf --- /dev/null +++ b/src/dialog/helpers/useEventListenerOutside.ts @@ -0,0 +1,86 @@ +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 new file mode 100644 index 000000000..8dda64c89 --- /dev/null +++ b/src/dialog/helpers/useFocusOnBlur.ts @@ -0,0 +1,56 @@ +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 new file mode 100644 index 000000000..d9c93d339 --- /dev/null +++ b/src/dialog/helpers/useFocusOnChildUnmount.ts @@ -0,0 +1,39 @@ +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 new file mode 100644 index 000000000..c6942c458 --- /dev/null +++ b/src/dialog/helpers/useFocusOnHide.ts @@ -0,0 +1,73 @@ +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.present) return; + + // 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.present, dialogRef, disclosureRef]); +} diff --git a/src/dialog/helpers/useFocusOnShow.ts b/src/dialog/helpers/useFocusOnShow.ts new file mode 100644 index 000000000..16295ade9 --- /dev/null +++ b/src/dialog/helpers/useFocusOnShow.ts @@ -0,0 +1,56 @@ +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.present) 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.present, nestedDialogs, initialFocusRef]); +} diff --git a/src/dialog/helpers/useFocusTrap.ts b/src/dialog/helpers/useFocusTrap.ts new file mode 100644 index 000000000..2afd528f4 --- /dev/null +++ b/src/dialog/helpers/useFocusTrap.ts @@ -0,0 +1,103 @@ +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 new file mode 100644 index 000000000..2e5b6eef9 --- /dev/null +++ b/src/dialog/helpers/useHideOnClickOutside.ts @@ -0,0 +1,69 @@ +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 new file mode 100644 index 000000000..e1a8386b5 --- /dev/null +++ b/src/dialog/helpers/useNestedDialogs.tsx @@ -0,0 +1,118 @@ +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]); + }, + [context.addDialog], + ); + + const removeDialog = React.useCallback( + (ref: DialogRef) => { + context.removeDialog?.(ref); + setDialogs(prevDialogs => removeItemFromArray(prevDialogs, ref)); + }, + [context.removeDialog], + ); + + const showDialog = React.useCallback( + (ref: DialogRef) => { + context.showDialog?.(ref); + setVisibleModals(prevDialogs => [...prevDialogs, ref]); + }, + [context.showDialog], + ); + + const hideDialog = React.useCallback( + (ref: DialogRef) => { + context.hideDialog?.(ref); + setVisibleModals(prevDialogs => removeItemFromArray(prevDialogs, ref)); + }, + [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); + }; + }, [ + 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); + }; + }, [ + 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?.(); + } + }, [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 new file mode 100644 index 000000000..a6d048595 --- /dev/null +++ b/src/dialog/helpers/usePortalRef.ts @@ -0,0 +1,23 @@ +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 new file mode 100644 index 000000000..5adb63727 --- /dev/null +++ b/src/dialog/index.ts @@ -0,0 +1,6 @@ +export * from "./__keys"; +export * from "./Dialog"; +export * from "./DialogBackdrop"; +export * from "./DialogDisclosure"; +export * from "./DialogState"; +export * from "./helpers"; diff --git a/src/dialog/stories/DialogBasic.component.tsx b/src/dialog/stories/DialogBasic.component.tsx new file mode 100644 index 000000000..7a4466d41 --- /dev/null +++ b/src/dialog/stories/DialogBasic.component.tsx @@ -0,0 +1,51 @@ +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 new file mode 100644 index 000000000..9f4d362f2 --- /dev/null +++ b/src/dialog/stories/DialogBasic.css @@ -0,0 +1,68 @@ +.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 new file mode 100644 index 000000000..a05fff5b8 --- /dev/null +++ b/src/dialog/stories/DialogBasic.stories.tsx @@ -0,0 +1,32 @@ +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/disclosure/DisclosureButton.tsx b/src/disclosure/Disclosure.tsx similarity index 56% rename from src/disclosure/DisclosureButton.tsx rename to src/disclosure/Disclosure.tsx index dadb05b38..11042cfb4 100644 --- a/src/disclosure/DisclosureButton.tsx +++ b/src/disclosure/Disclosure.tsx @@ -9,35 +9,32 @@ import { useLiveRef } from "reakit-utils"; import { createComposableHook } from "../system"; -import { DISCLOSURE_BUTTON_KEYS } from "./__keys"; +import { DISCLOSURE_KEYS } from "./__keys"; import { DisclosureStateReturn } from "./DisclosureState"; -export type DisclosureButtonOptions = ButtonOptions & - Pick; +export type DisclosureOptions = ButtonOptions & + Pick; -export type DisclosureButtonHTMLProps = ButtonHTMLProps; +export type DisclosureHTMLProps = ButtonHTMLProps; -export type DisclosureButtonProps = DisclosureButtonOptions & - DisclosureButtonHTMLProps; +export type DisclosureProps = DisclosureOptions & DisclosureHTMLProps; export const disclosureComposableButton = createComposableHook< - DisclosureButtonOptions, - DisclosureButtonHTMLProps + DisclosureOptions, + DisclosureHTMLProps >({ - name: "DisclosureButton", + name: "Disclosure", compose: useReakitButton, - keys: DISCLOSURE_BUTTON_KEYS, + keys: DISCLOSURE_KEYS, useProps(options, htmlProps) { - const { toggle, expanded } = options; + const { toggle, visible, baseId } = options; const { onClick: htmlOnClick, "aria-controls": ariaControls, ...restHtmlProps } = htmlProps; - const controls = ariaControls - ? `${ariaControls} ${options.baseId}` - : options.baseId; + const controls = ariaControls ? `${ariaControls} ${baseId}` : baseId; const onClickRef = useLiveRef(htmlOnClick); @@ -53,17 +50,19 @@ export const disclosureComposableButton = createComposableHook< return { "aria-controls": controls, - "aria-expanded": expanded, + "aria-expanded": visible, + "data-enter": visible ? "" : undefined, + "data-leave": !visible ? "" : undefined, onClick, ...restHtmlProps, }; }, }); -export const useDisclosureButton = disclosureComposableButton(); +export const useDisclosure = disclosureComposableButton(); -export const DisclosureButton = createComponent({ +export const Disclosure = createComponent({ as: "button", memo: true, - useHook: useDisclosureButton, + useHook: useDisclosure, }); diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx index a33cb2826..047812fa7 100644 --- a/src/disclosure/DisclosureContent.tsx +++ b/src/disclosure/DisclosureContent.tsx @@ -1,35 +1,49 @@ -// Core Logic for transition is based on https://github.com/roginfarrer/react-collapsed +// Inspired from Radix UI https://github.com/radix-ui/primitives/tree/main/packages/react/collapsible 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 { useForkRef, useLiveRef } from "reakit-utils"; import { createComposableHook } from "../system"; +import { useAnimationPresence } from "../utils"; import { DISCLOSURE_CONTENT_KEYS } from "./__keys"; import { DisclosureStateReturn } from "./DisclosureState"; import { - getAutoSizeDuration, - getElementHeight, - getElementWidth, + TransitionState, + useAnimationPresenceSize, + UseAnimationPresenceSizeReturnType, + useTransitionPresence, + UseTransitionPresenceReturnType, } from "./helpers"; export type DisclosureContentOptions = BoxOptions & - Pick< - DisclosureStateReturn, - | "baseId" - | "expanded" - | "contentSize" - | "duration" - | "direction" - | "easing" - | "onCollapseEnd" - | "onCollapseStart" - | "onExpandEnd" - | "onExpandStart" - > & {}; + Pick & { + /** + * Whether it uses animation or not. + */ + animationPresent?: boolean; + + /** + * Whether it uses animation or not. + */ + transitionPresent?: boolean; + + /** + * Whether the content is hidden or not. + */ + isHidden?: boolean; + + /** + * Ref for the animation/transition. + */ + presenceRef?: ((value: any) => void) | null; + present?: UseAnimationPresenceSizeReturnType["isPresent"]; + transitionState?: TransitionState; + onEnd?: UseTransitionPresenceReturnType["onEnd"]; + contentWidth?: UseAnimationPresenceSizeReturnType["width"]; + contentHeight?: UseAnimationPresenceSizeReturnType["height"]; + }; export type DisclosureContentHTMLProps = BoxHTMLProps; @@ -44,18 +58,61 @@ export const disclosureComposableContent = createComposableHook< compose: useBox, keys: DISCLOSURE_CONTENT_KEYS, + useOptions(options, htmlProps) { + const { + visible, + animationPresent = false, + transitionPresent = false, + } = options; + const { isPresent: present, ref: animationRef } = useAnimationPresence({ + present: visible, + }); + const { + isPresent, + width: contentWidth, + height: contentHeight, + ref: transitionRef, + } = useAnimationPresenceSize({ + present, + visible, + }); + const { transitionState, transitioning, onEnd } = useTransitionPresence({ + transition: transitionPresent, + visible, + }); + + // when opening we want it to immediately open to retrieve dimensions + // when closing we delay `present` to retrieve dimensions before closing + const isVisible = visible || isPresent; + const isHidden = + (animationPresent && !isVisible) || + (transitionPresent && !visible && !transitioning) || + (!animationPresent && !transitionPresent && !isVisible); + + return { + ...options, + isHidden, + presenceRef: useForkRef(animationRef, transitionRef), + transitionState, + onEnd, + contentWidth, + contentHeight, + present, + }; + }, + useProps(options, htmlProps) { const { - contentSize, - expanded, - direction, - duration, - easing, - onCollapseEnd, - onCollapseStart, - onExpandEnd, - onExpandStart, + visible, baseId, + presenceRef, + transitionPresent, + animationPresent, + onEnd, + contentWidth: width, + contentHeight: height, + isHidden, + transitionState, } = options; const { ref: htmlRef, @@ -63,167 +120,40 @@ export const disclosureComposableContent = createComposableHook< 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( - expanded ? {} : 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 (expanded) { - 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", - }); - }); - }); - } - }, [expanded]); + const onTransitionEndRef = useLiveRef(htmlOnTransitionEnd); 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 (expanded) { - 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?.(); - } + onEnd?.(event); }, - [ - onTransitionEndRef, - currentSize, - isVertical, - styles.height, - styles.width, - expanded, - contentSize, - onExpandEnd, - mergeStyles, - getCurrentSizeStyle, - collapsedStyles, - onCollapseEnd, - ], + [onEnd, onTransitionEndRef], ); - const style = { ...styles, ...htmlStyle }; + const style = { + "--content-height": height ? `${height}px` : undefined, + "--content-width": width ? `${width}px` : undefined, + display: isHidden ? "none" : undefined, + ...htmlStyle, + }; return { - ref: useForkRef(ref, htmlRef), + ref: useForkRef(presenceRef, htmlRef), id: baseId, - "aria-hidden": !expanded, - style, + hidden: isHidden, + "data-enter": + (transitionPresent && transitionState === "enter") || + (animationPresent && visible) + ? "" + : undefined, + "data-leave": + (transitionPresent && transitionState === "leave") || + (animationPresent && !visible) + ? "" + : undefined, onTransitionEnd, + style, ...restHtmlProps, }; }, diff --git a/src/disclosure/DisclosureState.ts b/src/disclosure/DisclosureState.ts index 2a8a08191..e441c5de0 100644 --- a/src/disclosure/DisclosureState.ts +++ b/src/disclosure/DisclosureState.ts @@ -12,34 +12,7 @@ export type DisclosureState = unstable_IdState & { /** * Whether it's expanded or not. */ - expanded: boolean; - - /** - * 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; + visible: boolean; }; export type DisclosureActions = unstable_IdActions & { @@ -61,101 +34,54 @@ export type DisclosureActions = unstable_IdActions & { /** * Sets `expanded`. */ - setExpanded: React.Dispatch< - React.SetStateAction - >; - - /** - * 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; + setVisible: React.Dispatch>; }; export type DisclosureStateReturn = DisclosureState & DisclosureActions; export type DisclosureInitialState = unstable_IdInitialState & - Partial< - Pick< - DisclosureState, - "expanded" | "direction" | "contentSize" | "easing" | "duration" - > - > & - Pick< - DisclosureActions, - "onExpandStart" | "onExpandEnd" | "onCollapseStart" | "onCollapseEnd" - > & { + Partial> & { /** * Default uncontrolled state. */ - defaultExpanded?: boolean; + defaultVisible?: boolean; /** * Controllabele state. */ - expanded?: boolean; + visible?: boolean; /** * controllable state callback. */ - onExpandedChange?: (expanded: boolean) => void; + onVisibleChange?: (expanded: boolean) => void; }; export const useDisclosureState = ( props: DisclosureInitialState = {}, ): DisclosureStateReturn => { const { - defaultExpanded = false, - expanded: initialExpanded, - onExpandedChange, - direction = "vertical", - contentSize = 0, - duration, - easing = "cubic-bezier(0.4, 0, 0.2, 1)", - onCollapseEnd, - onCollapseStart, - onExpandEnd, - onExpandStart, + defaultVisible = false, + visible: initialVisible, + onVisibleChange, } = props; const id = unstable_useIdState(); - const [expanded, setExpanded] = useControllableState({ - defaultValue: defaultExpanded, - value: initialExpanded, - onChange: onExpandedChange, + const [visible, setVisible] = useControllableState({ + defaultValue: defaultVisible, + value: initialVisible, + onChange: onVisibleChange, }); - const show = React.useCallback(() => setExpanded(true), [setExpanded]); - const hide = React.useCallback(() => setExpanded(false), [setExpanded]); - const toggle = React.useCallback(() => setExpanded(e => !e), [setExpanded]); + const show = React.useCallback(() => setVisible(true), [setVisible]); + const hide = React.useCallback(() => setVisible(false), [setVisible]); + const toggle = React.useCallback(() => setVisible(e => !e), [setVisible]); return { ...id, - expanded, - direction, - contentSize, - duration, - easing, + visible, + setVisible, show, hide, toggle, - setExpanded, - onCollapseEnd, - onCollapseStart, - onExpandEnd, - onExpandStart, }; }; diff --git a/src/disclosure/__keys.ts b/src/disclosure/__keys.ts index 1e9939c03..55a38ae51 100644 --- a/src/disclosure/__keys.ts +++ b/src/disclosure/__keys.ts @@ -2,34 +2,29 @@ export const DISCLOSURE_STATE_KEYS = [ "baseId", "unstable_idCountRef", - "expanded", - "direction", - "contentSize", - "duration", - "easing", + "visible", "setBaseId", "show", "hide", "toggle", - "setExpanded", - "onExpandStart", - "onExpandEnd", - "onCollapseStart", - "onCollapseEnd", + "setVisible", ] as const; export const USE_DISCLOSURE_STATE_KEYS = [ "baseId", - "expanded", - "direction", - "contentSize", - "easing", - "duration", - "onExpandStart", - "onExpandEnd", - "onCollapseStart", - "onCollapseEnd", - "defaultExpanded", - "onExpandedChange", + "visible", + "defaultVisible", + "onVisibleChange", +] as const; +export const DISCLOSURE_KEYS = DISCLOSURE_STATE_KEYS; +export const DISCLOSURE_CONTENT_KEYS = [ + ...DISCLOSURE_KEYS, + "animationPresent", + "transitionPresent", + "isHidden", + "presenceRef", + "present", + "transitionState", + "onEnd", + "contentWidth", + "contentHeight", ] as const; -export const DISCLOSURE_BUTTON_KEYS = DISCLOSURE_STATE_KEYS; -export const DISCLOSURE_CONTENT_KEYS = DISCLOSURE_BUTTON_KEYS; diff --git a/src/disclosure/helpers.ts b/src/disclosure/helpers.ts new file mode 100644 index 000000000..94cbc56f1 --- /dev/null +++ b/src/disclosure/helpers.ts @@ -0,0 +1,131 @@ +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 useAnimationPresenceSizeProps = { + present: boolean; + visible: boolean; +}; + +export const useAnimationPresenceSize = ( + props: useAnimationPresenceSizeProps, +) => { + const { present, visible } = props; + const ref = React.useRef(null); + const [isPresent, setIsPresent] = React.useState(present); + const heightRef = React.useRef(0); + const height = heightRef.current; + const widthRef = React.useRef(0); + const width = widthRef.current; + + React.useLayoutEffect(() => { + const node = ref.current; + + if (node) { + const originalTransition = node.style.transition; + const originalAnimation = node.style.animation; + // block any animations/transitions so the element renders at its full dimensions + node.style.transition = "none"; + node.style.animation = "none"; + + // get width and height from full dimensions + const rect = node.getBoundingClientRect(); + heightRef.current = rect.height; + widthRef.current = rect.width; + + // kick off any animations/transitions that were originally set up + node.style.transition = originalTransition; + node.style.animation = originalAnimation; + setIsPresent(present); + } + /** + * depends on `context.open` because it will change to `false` + * when a close is triggered but `present` will be `false` on + * animation end (so when close finishes). This allows us to + * retrieve the dimensions *before* closing. + */ + }, [visible, present]); + + return { isPresent, height, width, ref }; +}; + +export type UseAnimationPresenceSizeReturnType = ReturnType< + typeof useAnimationPresenceSize +>; + +export type TransitionState = "enter" | "leave" | null; + +export type useTransitionPresenceProps = { + transition: boolean; + visible: boolean; +}; + +export const useTransitionPresence = (props: useTransitionPresenceProps) => { + const { transition, visible } = props; + const [transitionState, setTransitionState] = + React.useState(null); + const [transitioning, setTransitioning] = React.useState(false); + const lastVisible = useLastValue(visible); + + const visibleHasChanged = + lastVisible.current != null && lastVisible.current !== visible; + + if (transition && !transitioning && visibleHasChanged) { + // Sets transitioning to true when when visible is updated + setTransitioning(true); + } + + const raf = React.useRef(0); + + React.useEffect(() => { + if (!transition) return; + + // 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) { + if (!transitioning) return; + + setTransitionState("enter"); + } else if (transitioning) { + setTransitionState("leave"); + } else { + setTransitionState(null); + } + }); + }); + + return () => window.cancelAnimationFrame(raf.current); + }, [visible, transitioning, transition]); + + const onEnd = React.useCallback( + (event: React.SyntheticEvent) => { + if (!isSelfTarget(event)) return; + if (!transition) return; + if (!transitioning) return; + + // Ignores number animated + setTransitioning(false); + }, + [transition, transitioning], + ); + + return { transitionState, transitioning, onEnd }; +}; + +export type UseTransitionPresenceReturnType = ReturnType< + typeof useTransitionPresence +>; diff --git a/src/disclosure/helpers.tsx b/src/disclosure/helpers.tsx deleted file mode 100644 index 420350c56..000000000 --- a/src/disclosure/helpers.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 d16604eb2..2ed1c7143 100644 --- a/src/disclosure/index.ts +++ b/src/disclosure/index.ts @@ -1,4 +1,4 @@ export * from "./__keys"; -export * from "./DisclosureButton"; +export * from "./Disclosure"; export * from "./DisclosureContent"; export * from "./DisclosureState"; diff --git a/src/disclosure/stories/DisclosureBasic.component.tsx b/src/disclosure/stories/DisclosureBasic.component.tsx index c776edc74..d9695e66b 100644 --- a/src/disclosure/stories/DisclosureBasic.component.tsx +++ b/src/disclosure/stories/DisclosureBasic.component.tsx @@ -1,31 +1,23 @@ import * as React from "react"; import { - DisclosureButton, + Disclosure, DisclosureContent, DisclosureInitialState, useDisclosureState, } from "../../index"; -export type DisclosureProps = DisclosureInitialState & {}; +export type DisclosureBasicProps = DisclosureInitialState & {}; -export const Disclosure: React.FC = props => { - const [hasExpandStarted, setHasExpandStarted] = React.useState(false); - - const state = useDisclosureState({ - ...props, - onExpandStart: () => setHasExpandStarted(true), - onCollapseEnd: () => setHasExpandStarted(false), - }); +export const DisclosureBasic: React.FC = props => { + const state = useDisclosureState(props); return (
- Show More + Show More Item 1 @@ -38,4 +30,5 @@ export const Disclosure: React.FC = props => {
); }; -export default Disclosure; + +export default DisclosureBasic; diff --git a/src/disclosure/stories/DisclosureBasic.css b/src/disclosure/stories/DisclosureBasic.css new file mode 100644 index 000000000..8166f139a --- /dev/null +++ b/src/disclosure/stories/DisclosureBasic.css @@ -0,0 +1,20 @@ +.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 index 80abf69e3..2c6bda881 100644 --- a/src/disclosure/stories/DisclosureBasic.stories.tsx +++ b/src/disclosure/stories/DisclosureBasic.stories.tsx @@ -2,33 +2,35 @@ import * as React from "react"; import { Meta, Story } from "@storybook/react"; import { createPreviewTabs } from "../../../.storybook/utils"; -import { DisclosureState } from "../DisclosureState"; +import { DisclosureState } from "../index"; +import css from "./templates/DisclosureBasicCss"; import js from "./templates/DisclosureBasicJsx"; import ts from "./templates/DisclosureBasicTsx"; -import { Disclosure, DisclosureProps } from "./DisclosureBasic.component"; +import { + DisclosureBasic, + DisclosureBasicProps, +} from "./DisclosureBasic.component"; + +import "./DisclosureBasic.css"; export default { - component: Disclosure, + component: DisclosureBasic, title: "Disclosure/Basic", parameters: { layout: "centered", options: { showPanel: true }, - preview: createPreviewTabs({ js, ts }), + preview: createPreviewTabs({ js, ts, css }), }, } as Meta; -export const Default: Story = args => ( - -); - -export const WithTransition: Story = args => ( - +export const Default: Story = args => ( + ); export const Controlled = () => { - const [value, setValue] = React.useState(false); + const [value, setValue] = React.useState(false); console.log("%cvalue", "color: #997326", value); - return ; + return ; }; diff --git a/src/disclosure/stories/DisclosureHorizontal.component.tsx b/src/disclosure/stories/DisclosureHorizontal.component.tsx index a1879a7b2..75f0c2dd1 100644 --- a/src/disclosure/stories/DisclosureHorizontal.component.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.component.tsx @@ -1,42 +1,35 @@ import * as React from "react"; import { - DisclosureButton, + Disclosure, DisclosureContent, DisclosureInitialState, useDisclosureState, } from "../../index"; -export type DisclosureProps = DisclosureInitialState & {}; +export type DisclosureHorizontalProps = DisclosureInitialState & {}; -export const Disclosure: React.FC = props => { - const [hasExpandStarted, setHasExpandStarted] = React.useState(false); +export const DisclosureHorizontal: React.FC = + props => { + const state = useDisclosureState(props); - const state = useDisclosureState({ - ...props, - onExpandStart: () => setHasExpandStarted(true), - onCollapseEnd: () => setHasExpandStarted(false), - }); + return ( +
+ Show More + +
Item 1
+
Item 2
+
Item 3
+
Item 4
+
Item 5
+
Item 6
+
+
+ ); + }; - return ( -
- Show More - -
Item 1
-
Item 2
-
Item 3
-
Item 4
-
Item 5
-
Item 6
-
-
- ); -}; - -export default Disclosure; +export default DisclosureHorizontal; diff --git a/src/disclosure/stories/DisclosureHorizontal.css b/src/disclosure/stories/DisclosureHorizontal.css new file mode 100644 index 000000000..6adb6c056 --- /dev/null +++ b/src/disclosure/stories/DisclosureHorizontal.css @@ -0,0 +1,39 @@ +.root { + display: flex; + width: 100%; +} +.content { + display: flex; + flex-direction: row; + overflow: hidden; +} + +.content[data-enter] { + animation: slideRight 300ms ease-out; +} + +.item { + flex-shrink: 0; +} + +.content[data-leave] { + animation: slideLeft 300ms ease-in; +} + +@keyframes slideRight { + from { + width: 0; + } + to { + width: var(--content-width); + } +} + +@keyframes slideLeft { + from { + width: var(--content-width); + } + to { + width: 0; + } +} diff --git a/src/disclosure/stories/DisclosureHorizontal.stories.tsx b/src/disclosure/stories/DisclosureHorizontal.stories.tsx index b859cfa81..7c4534584 100644 --- a/src/disclosure/stories/DisclosureHorizontal.stories.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.stories.tsx @@ -4,37 +4,33 @@ 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 { Disclosure, DisclosureProps } from "./DisclosureHorizontal.component"; +import { + DisclosureHorizontal, + DisclosureHorizontalProps, +} from "./DisclosureHorizontal.component"; + +import "./DisclosureHorizontal.css"; export default { - component: Disclosure, + component: DisclosureHorizontal, title: "Disclosure/Horizontal", parameters: { layout: "centered", options: { showPanel: true }, - preview: createPreviewTabs({ js, ts }), + preview: createPreviewTabs({ js, ts, css }), }, } as Meta; -export const Default: Story = args => ( - -); - -export const WithTransition: Story = args => ( - +export const Default: Story = args => ( + ); export const Controlled = () => { - const [value, setValue] = React.useState(false); + const [value, setValue] = React.useState(false); console.log("%cvalue", "color: #997326", value); - return ( - - ); + return ; }; diff --git a/src/drawer/Drawer.ts b/src/drawer/Drawer.ts index b0a9dd187..01f088415 100644 --- a/src/drawer/Drawer.ts +++ b/src/drawer/Drawer.ts @@ -1,8 +1,58 @@ import { createComponent, createHook } from "reakit-system"; -import { DialogHTMLProps, DialogOptions, useDialog } from "reakit"; + +import { DialogHTMLProps, DialogOptions, useDialog } from "../dialog"; 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, @@ -29,33 +79,3 @@ const PLACEMENTS = { width: "100vw", }, }; - -export type Placement = keyof typeof PLACEMENTS; - -export type DrawerOptions = DialogOptions & { placement?: Placement }; - -export type DrawerHTMLProps = DialogHTMLProps; - -export type DrawerProps = DrawerOptions & DrawerHTMLProps; - -export const useDrawer = createHook({ - name: "Drawer", - compose: useDialog, - keys: DRAWER_KEYS, - - useProps({ placement = "left" }, { style: htmlStyles, ...htmlProps }) { - return { - style: { - ...PLACEMENTS[placement], - position: "fixed", - ...htmlStyles, - }, - ...htmlProps, - }; - }, -}); - -export const Drawer = createComponent({ - as: "div", - useHook: useDrawer, -}); diff --git a/src/drawer/DrawerBackdrop.ts b/src/drawer/DrawerBackdrop.ts new file mode 100644 index 000000000..d406f2fc7 --- /dev/null +++ b/src/drawer/DrawerBackdrop.ts @@ -0,0 +1,35 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + DialogBackdropHTMLProps, + DialogBackdropOptions, + useDialogBackdrop, +} from "../dialog"; + +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/DrawerCloseButton.ts b/src/drawer/DrawerCloseButton.ts deleted file mode 100644 index 5cbb95d96..000000000 --- a/src/drawer/DrawerCloseButton.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createComponent, createHook } from "reakit-system"; -import { - DialogDisclosureHTMLProps, - DialogDisclosureOptions, - useDialogDisclosure, -} from "reakit"; - -import { DRAWER_CLOSE_BUTTON_KEYS } from "./__keys"; - -export type DrawerCloseButtonOptions = DialogDisclosureOptions; - -export type DrawerCloseButtonHTMLProps = DialogDisclosureHTMLProps; - -export type DrawerCloseButtonProps = DrawerCloseButtonOptions & - DrawerCloseButtonHTMLProps; - -export const useDrawerCloseButton = createHook< - DrawerCloseButtonOptions, - DrawerCloseButtonHTMLProps ->({ - name: "DrawerCloseButton", - compose: useDialogDisclosure, - keys: DRAWER_CLOSE_BUTTON_KEYS, -}); - -export const DrawerCloseButton = createComponent({ - as: "button", - useHook: useDrawerCloseButton, -}); diff --git a/src/drawer/DrawerDisclosure.tsx b/src/drawer/DrawerDisclosure.tsx new file mode 100644 index 000000000..5cd2bbac4 --- /dev/null +++ b/src/drawer/DrawerDisclosure.tsx @@ -0,0 +1,38 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + DialogDisclosureHTMLProps, + DialogDisclosureOptions, + useDialogDisclosure, +} from "../dialog"; + +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 new file mode 100644 index 000000000..f27d29e5c --- /dev/null +++ b/src/drawer/DrawerState.ts @@ -0,0 +1,24 @@ +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 index 3dda0fd1b..cef160ba5 100644 --- a/src/drawer/__keys.ts +++ b/src/drawer/__keys.ts @@ -1,3 +1,24 @@ // Automatically generated -export const DRAWER_KEYS = ["placement"] as const; -export const DRAWER_CLOSE_BUTTON_KEYS = [] as const; +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 index 8a71fa2fe..2933bf42d 100644 --- a/src/drawer/index.ts +++ b/src/drawer/index.ts @@ -1,10 +1,5 @@ export * from "./__keys"; export * from "./Drawer"; -export * from "./DrawerCloseButton"; -export { - DialogBackdrop as DrawerBackdrop, - DialogDisclosure as DrawerDisclosure, - useDialogBackdrop as useDrawerBackdrop, - useDialogDisclosure as useDrawerDisclosure, - useDialogState as useDrawerState, -} from "reakit/Dialog"; +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 index 8f186a335..e2f66b7ee 100644 --- a/src/drawer/stories/DrawerBasic.component.tsx +++ b/src/drawer/stories/DrawerBasic.component.tsx @@ -2,23 +2,22 @@ import React from "react"; import { css } from "@emotion/css"; import { - Drawer as RenderlesskitDrawer, + Drawer, DrawerBackdrop, - DrawerCloseButton, DrawerDisclosure, DrawerInitialState, Placement, useDrawerState, } from "../../index"; -export const Drawer: React.FC = props => { - const dialog = useDrawerState({ animated: true, ...props }); +export const DrawerBasic: React.FC = props => { + const drawer = useDrawerState(props); const inputRef = React.useRef(null); const [placement, setPlacement] = React.useState("left"); return (
- {`Open Drawer`} + {`Open Drawer`} - - + = props => { opacity: 1; transform: translate(0, 0); } + &[data-leave] { + opacity: 0; + transform: ${cssTransforms[placement]}; + } `} + transitionPresent={true} unstable_initialFocusRef={inputRef} > - X + X

Welcome to Reakit!

-
+
); }; -export default Drawer; +export default DrawerBasic; const backdropStyles = css` opacity: 0; diff --git a/src/drawer/stories/DrawerBasic.stories.tsx b/src/drawer/stories/DrawerBasic.stories.tsx index e0fd22804..8d31126fe 100644 --- a/src/drawer/stories/DrawerBasic.stories.tsx +++ b/src/drawer/stories/DrawerBasic.stories.tsx @@ -5,10 +5,10 @@ import { createPreviewTabs } from "../../../.storybook/utils"; import js from "./templates/DrawerBasicJsx"; import ts from "./templates/DrawerBasicTsx"; -import { Drawer } from "./DrawerBasic.component"; +import { DrawerBasic } from "./DrawerBasic.component"; export default { - component: Drawer, + component: DrawerBasic, title: "Drawer/Basic", parameters: { layout: "centered", @@ -16,4 +16,4 @@ export default { }, } as Meta; -export const Default: Story = args => ; +export const Default: Story = args => ; diff --git a/src/index.ts b/src/index.ts index 284934973..5e70f503b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export * from "./breadcrumbs"; export * from "./calendar"; export * from "./checkbox"; export * from "./datepicker"; +export * from "./dialog"; export * from "./disclosure"; export * from "./drawer"; export * from "./link"; @@ -10,6 +11,7 @@ export * from "./meter"; export * from "./number-input"; export * from "./pagination"; export * from "./picker-base"; +export * from "./popover"; export * from "./progress"; export * from "./radio"; export * from "./segment"; @@ -18,4 +20,5 @@ export * from "./slider"; export * from "./system"; export * from "./timepicker"; export * from "./toast"; +export * from "./tooltip"; export * from "./utils"; diff --git a/src/link/Link.ts b/src/link/Link.ts index b654e3b16..0df15a960 100644 --- a/src/link/Link.ts +++ b/src/link/Link.ts @@ -38,7 +38,6 @@ export const useLink = createHook({ useWarning( true, "Can't determine whether the element is a native link because `ref` wasn't passed to the component", - "See https://reakit.io/docs/button", ); return; } diff --git a/src/popover/Popover.tsx b/src/popover/Popover.tsx new file mode 100644 index 000000000..3ae1c1796 --- /dev/null +++ b/src/popover/Popover.tsx @@ -0,0 +1,38 @@ +import { CSSProperties } from "react"; +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; + +import { POPOVER_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverOptions = RoleOptions & + Pick; + +export type PopoverHTMLProps = RoleHTMLProps; + +export type PopoverProps = PopoverOptions & PopoverHTMLProps; + +export const usePopover = createHook({ + name: "Popover", + compose: useRole, + keys: POPOVER_KEYS, + + useProps(options, htmlProps) { + const { popperStyles } = options; + const { style: htmlStyle, ...restHtmlProps } = htmlProps; + + return { + style: { + ...(popperStyles as CSSProperties), + ...htmlStyle, + }, + ...restHtmlProps, + }; + }, +}); + +export const Popover = createComponent({ + as: "div", + memo: true, + useHook: usePopover, +}); diff --git a/src/popover/PopoverAnchor.tsx b/src/popover/PopoverAnchor.tsx new file mode 100644 index 000000000..32287e6d6 --- /dev/null +++ b/src/popover/PopoverAnchor.tsx @@ -0,0 +1,42 @@ +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; +import { useForkRef } from "reakit-utils"; + +import { POPOVER_ANCHOR_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverAnchorOptions = RoleOptions & + Pick; + +export type PopoverAnchorHTMLProps = RoleHTMLProps; + +export type PopoverAnchorProps = PopoverAnchorOptions & PopoverAnchorHTMLProps; + +export const usePopoverAnchor = createHook< + PopoverAnchorOptions, + PopoverAnchorHTMLProps +>({ + name: "PopoverAnchor", + compose: useRole, + keys: POPOVER_ANCHOR_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + const { setAnchor } = options; + const { ref: htmlRef, ...restHtmlProps } = htmlProps; + + return { + ref: useForkRef(setAnchor, htmlRef), + ...restHtmlProps, + }; + }, +}); + +export const PopoverAnchor = createComponent({ + as: "div", + memo: true, + useHook: usePopoverAnchor, +}); diff --git a/src/popover/PopoverArrow.tsx b/src/popover/PopoverArrow.tsx new file mode 100644 index 000000000..9512b0e48 --- /dev/null +++ b/src/popover/PopoverArrow.tsx @@ -0,0 +1,46 @@ +import { CSSProperties } from "react"; +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; + +import { POPOVER_ARROW_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverArrowOptions = RoleOptions & + Pick; + +export type PopoverArrowHTMLProps = RoleHTMLProps; + +export type PopoverArrowProps = PopoverArrowOptions & PopoverArrowHTMLProps; + +export const usePopoverArrow = createHook< + PopoverArrowOptions, + PopoverArrowHTMLProps +>({ + name: "PopoverArrow", + compose: useRole, + keys: POPOVER_ARROW_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + const { arrowStyles } = options; + const { style: htmlStyle, ...restHtmlProps } = htmlProps; + + return { + style: { + ...(arrowStyles as CSSProperties), + pointerEvents: "none", + ...htmlStyle, + }, + ...restHtmlProps, + }; + }, +}); + +export const PopoverArrow = createComponent({ + as: "span", + memo: true, + useHook: usePopoverArrow, +}); diff --git a/src/popover/PopoverArrowContent.tsx b/src/popover/PopoverArrowContent.tsx new file mode 100644 index 000000000..31efe244a --- /dev/null +++ b/src/popover/PopoverArrowContent.tsx @@ -0,0 +1,43 @@ +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; +import { useForkRef } from "reakit-utils"; + +import { POPOVER_ARROW_CONTENT_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverArrowContentOptions = RoleOptions & + Pick; + +export type PopoverArrowContentHTMLProps = RoleHTMLProps; + +export type PopoverArrowContentProps = PopoverArrowContentOptions & + PopoverArrowContentHTMLProps; + +export const usePopoverArrowContent = createHook< + PopoverArrowContentOptions, + PopoverArrowContentHTMLProps +>({ + name: "PopoverArrowContent", + compose: useRole, + keys: POPOVER_ARROW_CONTENT_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + const { setArrow } = options; + const { ref: htmlRef, ...restHtmlProps } = htmlProps; + + return { + ref: useForkRef(setArrow, htmlRef), + ...restHtmlProps, + }; + }, +}); + +export const PopoverArrowContent = createComponent({ + as: "div", + memo: true, + useHook: usePopoverArrowContent, +}); diff --git a/src/popover/PopoverBackdrop.ts b/src/popover/PopoverBackdrop.ts new file mode 100644 index 000000000..d72956777 --- /dev/null +++ b/src/popover/PopoverBackdrop.ts @@ -0,0 +1,35 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + DialogBackdropHTMLProps, + DialogBackdropOptions, + useDialogBackdrop, +} from "../dialog"; + +import { POPOVER_BACKDROP_KEYS } from "./__keys"; + +export type PopoverBackdropOptions = DialogBackdropOptions; + +export type PopoverBackdropHTMLProps = DialogBackdropHTMLProps; + +export type PopoverBackdropProps = PopoverBackdropOptions & + PopoverBackdropHTMLProps; + +export const usePopoverBackdrop = createHook< + PopoverBackdropOptions, + PopoverBackdropHTMLProps +>({ + name: "PopoverBackdrop", + compose: useDialogBackdrop, + keys: POPOVER_BACKDROP_KEYS, + + useOptions({ modal = false, ...options }) { + return { modal, ...options }; + }, +}); + +export const PopoverBackdrop = createComponent({ + as: "div", + memo: true, + useHook: usePopoverBackdrop, +}); diff --git a/src/popover/PopoverContent.tsx b/src/popover/PopoverContent.tsx new file mode 100644 index 000000000..319f11a7f --- /dev/null +++ b/src/popover/PopoverContent.tsx @@ -0,0 +1,44 @@ +import { createComponent, createHook } from "reakit-system"; +import { useForkRef } from "reakit-utils"; + +import { DialogHTMLProps, DialogOptions, useDialog } from "../dialog"; + +import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverContentOptions = DialogOptions & + Pick; + +export type PopoverContentHTMLProps = DialogHTMLProps; + +export type PopoverContentProps = PopoverContentOptions & + PopoverContentHTMLProps; + +export const usePopoverContent = createHook< + PopoverContentOptions, + PopoverContentHTMLProps +>({ + name: "PopoverContent", + compose: useDialog, + keys: POPOVER_DISCLOSURE_KEYS, + + useOptions({ modal = false, ...options }) { + return { modal, ...options }; + }, + + useProps(options, htmlProps) { + const { setPopper } = options; + const { ref: htmlRef, ...restHtmlProps } = htmlProps; + + return { + ref: useForkRef(setPopper, htmlRef), + ...restHtmlProps, + }; + }, +}); + +export const PopoverContent = createComponent({ + as: "div", + memo: true, + useHook: usePopoverContent, +}); diff --git a/src/popover/PopoverDisclosure.tsx b/src/popover/PopoverDisclosure.tsx new file mode 100644 index 000000000..a78d21aee --- /dev/null +++ b/src/popover/PopoverDisclosure.tsx @@ -0,0 +1,48 @@ +import { createComponent, createHook } from "reakit-system"; +import { useForkRef } from "reakit-utils"; + +import { + DialogDisclosureHTMLProps, + DialogDisclosureOptions, + useDialogDisclosure, +} from "../dialog"; + +import { POPOVER_DISCLOSURE_KEYS } from "./__keys"; +import { PopoverStateReturn } from "./PopoverState"; + +export type PopoverDisclosureOptions = DialogDisclosureOptions & + Pick; + +export type PopoverDisclosureHTMLProps = DialogDisclosureHTMLProps; + +export type PopoverDisclosureProps = PopoverDisclosureOptions & + PopoverDisclosureHTMLProps; + +export const usePopoverDisclosure = createHook< + PopoverDisclosureOptions, + PopoverDisclosureHTMLProps +>({ + name: "PopoverDisclosure", + compose: useDialogDisclosure, + keys: POPOVER_DISCLOSURE_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + const { setAnchor } = options; + const { ref: htmlRef, ...restHtmlProps } = htmlProps; + + return { + ref: useForkRef(setAnchor, htmlRef), + ...restHtmlProps, + }; + }, +}); + +export const PopoverDisclosure = createComponent({ + as: "button", + memo: true, + useHook: usePopoverDisclosure, +}); diff --git a/src/popover/PopoverState.ts b/src/popover/PopoverState.ts new file mode 100644 index 000000000..83eedcc64 --- /dev/null +++ b/src/popover/PopoverState.ts @@ -0,0 +1,160 @@ +import * as React from "react"; +import { getPlacementData, PlacementData } from "@radix-ui/popper"; +import { useRect } from "@radix-ui/react-use-rect"; +import { useSize } from "@radix-ui/react-use-size"; + +import { + DialogActions, + DialogInitialState, + DialogState, + useDialogState, +} from "../dialog"; + +export type PopoverState = DialogState & + PlacementData & { + side: "top" | "bottom" | "left" | "right"; + align: "start" | "center" | "end"; + sideOffset: number; + alignOffset: number; + arrowOffset: number; + collisionTolerance: number; + anchor: HTMLDivElement | null; + popper: HTMLDivElement | null; + arrow: HTMLDivElement | null; + }; + +export type PopoverActions = DialogActions & { + setSide: React.Dispatch>; + setAlign: React.Dispatch>; + setSideOffset: React.Dispatch>; + setAlignOffset: React.Dispatch>; + setArrowOffset: React.Dispatch>; + setCollisionTolerance: React.Dispatch>; + setAnchor: React.Dispatch>; + setPopper: React.Dispatch>; + setArrow: React.Dispatch>; +}; + +export type PopoverStateReturn = PopoverState & PopoverActions; + +export type PopoverInitialState = DialogInitialState & + Partial< + Pick< + PopoverState, + | "side" + | "align" + | "sideOffset" + | "alignOffset" + | "arrowOffset" + | "collisionTolerance" + > + > & { + enableCollisionsDetection?: boolean; + }; + +export const usePopoverState = ( + props: PopoverInitialState = {}, +): PopoverStateReturn => { + const { + enableCollisionsDetection, + side: initialSide = "bottom", + align: initialAlign = "center", + sideOffset: initialSideOffset = 5, + alignOffset: initialAlignOffset = 0, + arrowOffset: initialArrowOffset = 20, + collisionTolerance: initialCollisionTolerance = 0, + modal = false, + ...restProps + } = props; + const dialog = useDialogState({ modal, ...restProps }); + + const [side, setSide] = React.useState(initialSide); + const [align, setAlign] = React.useState(initialAlign); + const [sideOffset, setSideOffset] = React.useState(initialSideOffset); + const [alignOffset, setAlignOffset] = React.useState(initialAlignOffset); + const [arrowOffset, setArrowOffset] = React.useState(initialArrowOffset); + const [collisionTolerance, setCollisionTolerance] = React.useState( + initialCollisionTolerance, + ); + + const [anchor, setAnchor] = React.useState(null); + const anchorRect = useRect(anchor); + + const [popper, setPopper] = React.useState(null); + const popperSize = useSize(popper); + + const [arrow, setArrow] = React.useState(null); + const arrowSize = useSize(arrow); + + const windowSize = useWindowSize(); + const collisionBoundariesRect = windowSize + ? DOMRect.fromRect({ ...windowSize, x: 0, y: 0 }) + : undefined; + + const placementData = getPlacementData({ + popperSize, + anchorRect, + arrowSize, + arrowOffset, + side, + sideOffset, + align, + alignOffset, + shouldAvoidCollisions: enableCollisionsDetection, + collisionBoundariesRect, + collisionTolerance, + }); + + return { + ...placementData, + ...dialog, + side, + align, + sideOffset, + alignOffset, + arrowOffset, + collisionTolerance, + anchor, + popper, + arrow, + setSide, + setAlign, + setSideOffset, + setAlignOffset, + setArrowOffset, + setCollisionTolerance, + setAnchor, + setPopper, + setArrow, + }; +}; + +const WINDOW_RESIZE_DEBOUNCE_WAIT_IN_MS = 100; + +function useWindowSize() { + const [windowSize, setWindowSize] = React.useState< + { width: number; height: number } | undefined + >(undefined); + + React.useEffect(() => { + let debounceTimerId: number; + + function updateWindowSize() { + setWindowSize({ width: window.innerWidth, height: window.innerHeight }); + } + + function handleResize() { + window.clearTimeout(debounceTimerId); + debounceTimerId = window.setTimeout( + updateWindowSize, + WINDOW_RESIZE_DEBOUNCE_WAIT_IN_MS, + ); + } + + updateWindowSize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return windowSize; +} diff --git a/src/popover/PopoverTrigger.tsx b/src/popover/PopoverTrigger.tsx new file mode 100644 index 000000000..ed5e9e13f --- /dev/null +++ b/src/popover/PopoverTrigger.tsx @@ -0,0 +1,39 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + DialogDisclosureHTMLProps, + DialogDisclosureOptions, + useDialogDisclosure, +} from "../dialog"; + +import { POPOVER_TRIGGER_KEYS } from "./__keys"; + +export type PopoverTriggerOptions = DialogDisclosureOptions & {}; + +export type PopoverTriggerHTMLProps = DialogDisclosureHTMLProps; + +export type PopoverTriggerProps = PopoverTriggerOptions & + PopoverTriggerHTMLProps; + +export const usePopoverTrigger = createHook< + PopoverTriggerOptions, + PopoverTriggerHTMLProps +>({ + name: "PopoverTrigger", + compose: useDialogDisclosure, + keys: POPOVER_TRIGGER_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const PopoverTrigger = createComponent({ + as: "button", + memo: true, + useHook: usePopoverTrigger, +}); diff --git a/src/popover/__keys.ts b/src/popover/__keys.ts new file mode 100644 index 000000000..20ce2c07a --- /dev/null +++ b/src/popover/__keys.ts @@ -0,0 +1,58 @@ +// Automatically generated +export const POPOVER_STATE_KEYS = [ + "baseId", + "unstable_idCountRef", + "visible", + "modal", + "disclosureRef", + "popperStyles", + "arrowStyles", + "placedSide", + "placedAlign", + "side", + "align", + "sideOffset", + "alignOffset", + "arrowOffset", + "collisionTolerance", + "anchor", + "popper", + "arrow", + "setBaseId", + "show", + "hide", + "toggle", + "setVisible", + "setModal", + "setSide", + "setAlign", + "setSideOffset", + "setAlignOffset", + "setArrowOffset", + "setCollisionTolerance", + "setAnchor", + "setPopper", + "setArrow", +] as const; +export const USE_POPOVER_STATE_KEYS = [ + "baseId", + "visible", + "defaultVisible", + "onVisibleChange", + "modal", + "side", + "align", + "sideOffset", + "alignOffset", + "arrowOffset", + "collisionTolerance", + "enableCollisionsDetection", +] as const; +export const POPOVER_KEYS = POPOVER_STATE_KEYS; +export const POPOVER_ANCHOR_KEYS = POPOVER_KEYS; +export const POPOVER_ARROW_KEYS = POPOVER_ANCHOR_KEYS; +export const POPOVER_ARROW_CONTENT_KEYS = POPOVER_ARROW_KEYS; +export const POPOVER_BACKDROP_KEYS = POPOVER_ARROW_CONTENT_KEYS; +export const POPOVER_CONTENT_KEYS = POPOVER_BACKDROP_KEYS; +export const POPOVER_DISCLOSURE_KEYS = POPOVER_CONTENT_KEYS; +export const POPOVER_TRIGGER_KEYS = POPOVER_DISCLOSURE_KEYS; diff --git a/src/popover/index.ts b/src/popover/index.ts new file mode 100644 index 000000000..2b5c471fd --- /dev/null +++ b/src/popover/index.ts @@ -0,0 +1,9 @@ +export * from "./Popover"; +export * from "./PopoverAnchor"; +export * from "./PopoverArrow"; +export * from "./PopoverArrowContent"; +export * from "./PopoverBackdrop"; +export * from "./PopoverContent"; +export * from "./PopoverDisclosure"; +export * from "./PopoverState"; +export * from "./PopoverTrigger"; diff --git a/src/popover/stories/PopoverBasic.component.tsx b/src/popover/stories/PopoverBasic.component.tsx new file mode 100644 index 000000000..54bda6d43 --- /dev/null +++ b/src/popover/stories/PopoverBasic.component.tsx @@ -0,0 +1,68 @@ +import * as React from "react"; + +import { + Popover, + PopoverAnchor, + PopoverArrow, + PopoverArrowContent, + PopoverContent, + PopoverInitialState, + PopoverTrigger, + usePopoverState, +} from "../../index"; + +export type PopoverBasicProps = PopoverInitialState & {}; + +export const PopoverBasic: React.FC = props => { + const state = usePopoverState(props); + + return ( +
+ + Item + Open + + + + +
+ +
+ + + + + + + +
+
+ +
+ ); +}; diff --git a/src/popover/stories/PopoverBasic.css b/src/popover/stories/PopoverBasic.css new file mode 100644 index 000000000..c1c2052da --- /dev/null +++ b/src/popover/stories/PopoverBasic.css @@ -0,0 +1,47 @@ +.content { + background-color: gray; + padding: 20px; + border-radius: 5px; +} + +.arrow { + display: inline-block; + vertical-align: top; + pointer-events: auto; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadesIn 500ms ease-in-out; +} + +.content[data-leave] { + animation: fadesOut 500ms ease-in-out; +} + +@keyframes fadesIn { + from { + opacity: 0; + transform: translate(0, -10px); + } + + to { + opacity: 1; + transform: translate(0, 0px); + } +} + +@keyframes fadesOut { + from { + opacity: 1; + transform: translate(0, 0px); + } + + to { + opacity: 0; + transform: translate(0, -10px); + } +} diff --git a/src/popover/stories/PopoverBasic.stories.tsx b/src/popover/stories/PopoverBasic.stories.tsx new file mode 100644 index 000000000..b268037cc --- /dev/null +++ b/src/popover/stories/PopoverBasic.stories.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/PopoverBasicCss"; +import js from "./templates/PopoverBasicJsx"; +import ts from "./templates/PopoverBasicTsx"; +import { PopoverBasic } from "./PopoverBasic.component"; + +import "./PopoverBasic.css"; + +export default { + component: PopoverBasic, + title: "Popover/Basic", + parameters: { + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ( + +); diff --git a/src/popover/stories/PopoverCollision.component.tsx b/src/popover/stories/PopoverCollision.component.tsx new file mode 100644 index 000000000..3a9ca698b --- /dev/null +++ b/src/popover/stories/PopoverCollision.component.tsx @@ -0,0 +1,176 @@ +import * as React from "react"; +import { ALIGN_OPTIONS, SIDE_OPTIONS } from "@radix-ui/popper"; + +import { + Popover, + PopoverArrow, + PopoverArrowContent, + PopoverContent, + PopoverDisclosure, + PopoverState, + usePopoverState, +} from "../../index"; + +export const PopoverCollision = () => { + return ( +
+ +
+ ); +}; + +const Demo = ({ disableCollisions = false }) => { + const state = usePopoverState({ + enableCollisionsDetection: !disableCollisions, + }); + const { + side, + setSide, + align, + setAlign, + collisionTolerance, + setCollisionTolerance, + sideOffset, + setSideOffset, + alignOffset, + setAlignOffset, + arrowOffset, + setArrowOffset, + } = state; + + return ( + <> + + Anchor + + + + +
+
+ side + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + + align + {/* eslint-disable-next-line jsx-a11y/no-onchange */} + + arrowOffset + setArrowOffset(Number(event.target.value))} + /> +
+
+ offset + setSideOffset(Number(event.target.value))} + style={{ marginBottom: 10 }} + /> + offset + setAlignOffset(Number(event.target.value))} + style={{ marginBottom: 10 }} + /> + tolerance + + setCollisionTolerance(Number(event.target.value)) + } + /> +
+
+
+ + + + +
+ + ); +}; diff --git a/src/popover/stories/PopoverCollision.stories.tsx b/src/popover/stories/PopoverCollision.stories.tsx new file mode 100644 index 000000000..06c6defb0 --- /dev/null +++ b/src/popover/stories/PopoverCollision.stories.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import js from "./templates/PopoverCollisionJsx"; +import ts from "./templates/PopoverCollisionTsx"; +import { PopoverCollision } from "./PopoverCollision.component"; + +export default { + component: PopoverCollision, + title: "Popover/Collision", + parameters: { + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = args => ; diff --git a/src/tooltip/README.md b/src/tooltip/README.md new file mode 100644 index 000000000..5608e19ae --- /dev/null +++ b/src/tooltip/README.md @@ -0,0 +1,334 @@ +--- +path: /docs/tooltip/ +redirect_from: + - /components/tooltip/ + - /components/tooltip/tooltiparrow/ +--- + +# Tooltip + +`Tooltip` follows the +[WAI-ARIA Tooltip Pattern](https://www.w3.org/TR/wai-aria-practices/#tooltip). +It's a popup that displays information related to an element when the element +receives keyboard focus or the mouse hovers over it. + + + +## Installation + +```sh +npm install reakit +``` + +Learn more in [Get started](/docs/get-started/). + +## Usage + +```jsx +import { Button } from "reakit/Button"; +import { Tooltip, TooltipReference, useTooltipState } from "reakit/Tooltip"; + +function Example() { + const tooltip = useTooltipState(); + return ( + <> + + Reference + + Tooltip + + ); +} +``` + +### Placement + +Since `Tooltip` is composed by [Popover](/docs/popover/), you can control how it +is positioned by setting the `placement` option on `useTooltipState`. + +```jsx +import { Button } from "reakit/Button"; +import { Tooltip, TooltipReference, useTooltipState } from "reakit/Tooltip"; + +function Example() { + const tooltip = useTooltipState({ placement: "bottom-end" }); + return ( + <> + + Reference + + Tooltip + + ); +} +``` + +### Multiple tooltips + +Each group of `Tooltip` and `TooltipReference` should have its own corresponding +`useTooltipState`. + +```jsx +import { Button } from "reakit/Button"; +import { Tooltip, TooltipReference, useTooltipState } from "reakit/Tooltip"; + +function Example() { + const tooltip1 = useTooltipState(); + const tooltip2 = useTooltipState(); + return ( + <> + + Reference 1 + + Tooltip 1 + + Reference 2 + + Tooltip 2 + + ); +} +``` + +### Animating + +`Tooltip` uses [DisclosureContent](/docs/disclosure/) underneath, so you can use +the same approaches as described in the [Animating](/docs/disclosure/#animating) +section there. + +The only difference is that Reakit automatically adds inline styles to the +`Tooltip` element so that it's correctly positioned according to +`TooltipReference`. In this example, we're animating an inner wrapper element, +so we don't need to overwrite `Tooltip` positioning styles. + +```jsx +import { css } from "emotion"; +import { Button } from "reakit/Button"; +import { + useTooltipState, + Tooltip, + TooltipArrow, + TooltipReference, +} from "reakit/Tooltip"; + +const styles = css` + background-color: rgba(33, 33, 33, 0.9); + padding: 8px; + border-radius: 4px; + transition: opacity 250ms ease-in-out, transform 250ms ease-in-out; + opacity: 0; + transform-origin: top center; + transform: translate3d(0, -20px, 0); + [data-enter] & { + opacity: 1; + transform: translate3d(0, 0, 0); + } +`; + +function Example() { + const tooltip = useTooltipState({ animated: 250 }); + return ( + <> + + Reference + + +
+ + Tooltip +
+
+ + ); +} +``` + +### Abstracting + +You can build your own `Tooltip` component with a different API on top of +Reakit. + +```jsx +import React from "react"; +import { + useTooltipState, + Tooltip as ReakitTooltip, + TooltipReference, +} from "reakit/Tooltip"; + +function Tooltip({ children, title, ...props }) { + const tooltip = useTooltipState(); + return ( + <> + + {referenceProps => React.cloneElement(children, referenceProps)} + + + {title} + + + ); +} + +function Example() { + return ( + + + + ); +} +``` + +## Accessibility + +- `Tooltip` has role `tooltip`. +- `TooltipReference` has `aria-describedby` referring to `Tooltip`. +- Escape hides the current visible tooltip. + +Learn more in [Accessibility](/docs/accessibility/). + +## Composition + +- `Tooltip` uses [DisclosureContent](/docs/disclosure/). +- `TooltipArrow` uses [PopoverArrow](/docs/popover/). +- `TooltipReference` uses [Role](/docs/role/). + +Learn more in [Composition](/docs/composition/#props-hooks). + +## Props + + + +### `useTooltipState` + +- **`baseId`** string + + ID that will serve as a base for all the items IDs. + +- **`visible`** boolean + + Whether it's visible or not. + +- **`animated`** number | boolean + + If `true`, `animating` will be set to `true` when `visible` is updated. It'll + wait for `stopAnimation` to be called or a CSS transition ends. If `animated` + is set to a `number`, `stopAnimation` will be called only after the same + number of milliseconds have passed. + +- **`placement`** + "auto-start" + | "auto" | "auto-end" | "top-start... + + Actual `placement`. + +- **`unstable_fixed`** ⚠️ boolean | + undefined + + Whether or not the popover should have `position` set to `fixed`. + +- **`unstable_flip`** ⚠️ boolean | + undefined + + Flip the popover's placement when it starts to overlap its reference element. + +- **`unstable_offset`** ⚠️ [string | + number, string | number] | undefined + + Offset between the reference and the popover: [main axis, alt axis]. Should + not be combined with `gutter`. + +- **`gutter`** number | undefined + + Offset between the reference and the popover on the main axis. Should not be + combined with `unstable_offset`. + +- **`unstable_preventOverflow`** ⚠️ + boolean | undefined + + Prevents popover from being positioned outside the boundary. + +### `Tooltip` + +- **`unstable_portal`** ⚠️ boolean | + undefined + + Whether or not the tooltip should be rendered within `Portal`. + +
5 state props + +> These props are returned by the state hook. You can spread them into this +> component (`{...state}`) or pass them separately. You can also provide these +> props from your own state logic. + +- **`baseId`** string + + ID that will serve as a base for all the items IDs. + +- **`visible`** boolean + + Whether it's visible or not. + +- **`animated`** number | boolean + + If `true`, `animating` will be set to `true` when `visible` is updated. It'll + wait for `stopAnimation` to be called or a CSS transition ends. If `animated` + is set to a `number`, `stopAnimation` will be called only after the same + number of milliseconds have passed. + +- **`animating`** boolean + + Whether it's animating or not. + +- **`stopAnimation`** () => void + + Stops animation. It's called automatically if there's a CSS transition. + +
+ +### `TooltipArrow` + +- **`size`** string | number | undefined + + Arrow's size + +
1 state props + +> These props are returned by the state hook. You can spread them into this +> component (`{...state}`) or pass them separately. You can also provide these +> props from your own state logic. + +- **`placement`** + "auto-start" + | "auto" | "auto-end" | "top-start... + + Actual `placement`. + +
+ +### `TooltipReference` + +
4 state props + +> These props are returned by the state hook. You can spread them into this +> component (`{...state}`) or pass them separately. You can also provide these +> props from your own state logic. + +- **`baseId`** string + + ID that will serve as a base for all the items IDs. + +- **`unstable_referenceRef`** ⚠️ + RefObject<HTMLElement | null> + + The reference element. + +- **`show`** () => void + + Changes the `visible` state to `true` + +- **`hide`** () => void + + Changes the `visible` state to `false` + +
diff --git a/src/tooltip/Tooltip.tsx b/src/tooltip/Tooltip.tsx new file mode 100644 index 000000000..c05df7591 --- /dev/null +++ b/src/tooltip/Tooltip.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { Portal } from "reakit"; + +import { + PopoverHTMLProps, + PopoverOptions, + usePopover, +} from "../popover/Popover"; + +import { TOOLTIP_KEYS } from "./__keys"; + +export type TooltipOptions = PopoverOptions & { + /** + * Whether or not the tooltip should be rendered within `Portal`. + */ + unstable_portal?: boolean; +}; + +export type TooltipHTMLProps = PopoverHTMLProps; + +export type TooltipProps = TooltipOptions & TooltipHTMLProps; + +export const useTooltip = createHook({ + name: "Tooltip", + compose: usePopover, + keys: TOOLTIP_KEYS, + + useOptions({ unstable_portal = true, ...options }) { + return { unstable_portal, ...options }; + }, + + useProps(options, htmlProps) { + const { + style: htmlStyle, + wrapElement: htmlWrapElement, + ...restHtmlProps + } = htmlProps; + + const wrapElement = React.useCallback( + (element: React.ReactNode) => { + if (options.unstable_portal) { + element = {element}; + } + + if (htmlWrapElement) { + return htmlWrapElement(element); + } + + return element; + }, + [options.unstable_portal, htmlWrapElement], + ); + + return { + style: { + pointerEvents: "none", + ...htmlStyle, + }, + wrapElement, + ...restHtmlProps, + }; + }, +}); + +export const Tooltip = createComponent({ + as: "div", + memo: true, + useHook: useTooltip, +}); diff --git a/src/tooltip/TooltipAnchor.tsx b/src/tooltip/TooltipAnchor.tsx new file mode 100644 index 000000000..89bcec3a6 --- /dev/null +++ b/src/tooltip/TooltipAnchor.tsx @@ -0,0 +1,40 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + PopoverAnchorHTMLProps, + PopoverAnchorOptions, + usePopoverAnchor, +} from "../popover"; + +import { TOOLTIP_ANCHOR_KEYS } from "./__keys"; +import { TooltipStateReturn } from "./TooltipState"; + +export type TooltipAnchorOptions = PopoverAnchorOptions & + Pick; + +export type TooltipAnchorHTMLProps = PopoverAnchorHTMLProps; + +export type TooltipAnchorProps = TooltipAnchorOptions & TooltipAnchorHTMLProps; + +export const useTooltipAnchor = createHook< + TooltipAnchorOptions, + TooltipAnchorHTMLProps +>({ + name: "TooltipAnchor", + compose: usePopoverAnchor, + keys: TOOLTIP_ANCHOR_KEYS, + + useOptions(options, htmlProps) { + return options; + }, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const TooltipAnchor = createComponent({ + as: "div", + memo: true, + useHook: useTooltipAnchor, +}); diff --git a/src/tooltip/TooltipArrow.ts b/src/tooltip/TooltipArrow.ts new file mode 100644 index 000000000..87eab0a21 --- /dev/null +++ b/src/tooltip/TooltipArrow.ts @@ -0,0 +1,39 @@ +import { createComponent } from "reakit-system/createComponent"; +import { createHook } from "reakit-system/createHook"; + +import { + PopoverArrowHTMLProps, + PopoverArrowOptions, + usePopoverArrow, +} from "../popover/PopoverArrow"; + +import { TOOLTIP_ARROW_KEYS } from "./__keys"; + +export type TooltipArrowOptions = PopoverArrowOptions; + +export type TooltipArrowHTMLProps = PopoverArrowHTMLProps; + +export type TooltipArrowProps = TooltipArrowOptions & TooltipArrowHTMLProps; + +export const useTooltipArrow = createHook< + TooltipArrowOptions, + TooltipArrowHTMLProps +>({ + name: "TooltipArrow", + compose: usePopoverArrow, + keys: TOOLTIP_ARROW_KEYS, + + useOptions(options) { + return options; + }, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const TooltipArrow = createComponent({ + as: "div", + memo: true, + useHook: useTooltipArrow, +}); diff --git a/src/tooltip/TooltipArrowContent.ts b/src/tooltip/TooltipArrowContent.ts new file mode 100644 index 000000000..6187cda60 --- /dev/null +++ b/src/tooltip/TooltipArrowContent.ts @@ -0,0 +1,39 @@ +import { createComponent, createHook } from "reakit-system"; + +import { + PopoverArrowContentHTMLProps, + PopoverArrowContentOptions, + usePopoverArrowContent, +} from "../popover/PopoverArrowContent"; + +import { TOOLTIP_ARROW_CONTENT_KEYS } from "./__keys"; + +export type TooltipArrowContentOptions = PopoverArrowContentOptions; + +export type TooltipArrowContentHTMLProps = PopoverArrowContentHTMLProps; + +export type TooltipArrowContentProps = TooltipArrowContentOptions & + TooltipArrowContentHTMLProps; + +export const useTooltipArrowContent = createHook< + TooltipArrowContentOptions, + TooltipArrowContentHTMLProps +>({ + name: "TooltipArrowContent", + compose: usePopoverArrowContent, + keys: TOOLTIP_ARROW_CONTENT_KEYS, + + useOptions(options) { + return options; + }, + + useProps(options, htmlProps) { + return htmlProps; + }, +}); + +export const TooltipArrowContent = createComponent({ + as: "div", + memo: true, + useHook: useTooltipArrowContent, +}); diff --git a/src/tooltip/TooltipContent.tsx b/src/tooltip/TooltipContent.tsx new file mode 100644 index 000000000..acfd8a494 --- /dev/null +++ b/src/tooltip/TooltipContent.tsx @@ -0,0 +1,65 @@ +import * as React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { getDocument, useForkRef } from "reakit-utils"; + +import { + DisclosureContentHTMLProps, + DisclosureContentOptions, + useDisclosureContent, +} from "../disclosure/DisclosureContent"; + +import globalState from "./__globalState"; +import { TOOLTIP_KEYS } from "./__keys"; +import { TooltipStateReturn } from "./TooltipState"; + +export type TooltipContentOptions = DisclosureContentOptions & + Pick, "setPopper" | "popper"> & {}; + +export type TooltipContentHTMLProps = DisclosureContentHTMLProps; + +export type TooltipContentProps = TooltipContentOptions & + TooltipContentHTMLProps; + +function globallyHideTooltipContentOnEscape(event: KeyboardEvent) { + if (event.defaultPrevented) return; + if (event.key === "Escape") { + globalState.show(null); + } +} + +export const useTooltipContent = createHook< + TooltipContentOptions, + TooltipContentHTMLProps +>({ + name: "TooltipContent", + compose: useDisclosureContent, + keys: TOOLTIP_KEYS, + + useOptions(options) { + return options; + }, + + useProps(options, { ref: htmlRef, style: htmlStyle, ...htmlProps }) { + React.useEffect(() => { + const document = getDocument(options.popper); + + document.addEventListener("keydown", globallyHideTooltipContentOnEscape); + }, [options.popper]); + + return { + ref: useForkRef(options.setPopper, htmlRef), + role: "tooltip", + style: { + pointerEvents: "none", + ...htmlStyle, + }, + ...htmlProps, + }; + }, +}); + +export const TooltipContent = createComponent({ + as: "div", + memo: true, + useHook: useTooltipContent, +}); diff --git a/src/tooltip/TooltipReference.ts b/src/tooltip/TooltipReference.ts new file mode 100644 index 000000000..fc5ff4dcf --- /dev/null +++ b/src/tooltip/TooltipReference.ts @@ -0,0 +1,95 @@ +import * as React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; +import { useForkRef, useLiveRef } from "reakit-utils"; + +import { TOOLTIP_REFERENCE_KEYS } from "./__keys"; +import { TooltipStateReturn } from "./TooltipState"; + +export type TooltipReferenceOptions = RoleOptions & + Pick, "setAnchor" | "baseId"> & + Pick; + +export type TooltipReferenceHTMLProps = RoleHTMLProps; + +export type TooltipReferenceProps = TooltipReferenceOptions & + TooltipReferenceHTMLProps; + +export const useTooltipReference = createHook< + TooltipReferenceOptions, + TooltipReferenceHTMLProps +>({ + name: "TooltipReference", + compose: useRole, + keys: TOOLTIP_REFERENCE_KEYS, + + useProps( + options, + { + ref: htmlRef, + onFocus: htmlOnFocus, + onBlur: htmlOnBlur, + onMouseEnter: htmlOnMouseEnter, + onMouseLeave: htmlOnMouseLeave, + ...htmlProps + }, + ) { + const { show, hide, baseId, setAnchor } = options; + const onFocusRef = useLiveRef(htmlOnFocus); + const onBlurRef = useLiveRef(htmlOnBlur); + const onMouseEnterRef = useLiveRef(htmlOnMouseEnter); + const onMouseLeaveRef = useLiveRef(htmlOnMouseLeave); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusRef.current?.(event); + if (event.defaultPrevented) return; + show?.(); + }, + [onFocusRef, show], + ); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + onBlurRef.current?.(event); + if (event.defaultPrevented) return; + hide?.(); + }, + [hide, onBlurRef], + ); + + const onMouseEnter = React.useCallback( + (event: React.MouseEvent) => { + onMouseEnterRef.current?.(event); + if (event.defaultPrevented) return; + show?.(); + }, + [onMouseEnterRef, show], + ); + + const onMouseLeave = React.useCallback( + (event: React.MouseEvent) => { + onMouseLeaveRef.current?.(event); + if (event.defaultPrevented) return; + hide?.(); + }, + [hide, onMouseLeaveRef], + ); + + return { + ref: useForkRef(setAnchor, htmlRef), + tabIndex: 0, + onFocus, + onBlur, + onMouseEnter, + onMouseLeave, + "aria-describedby": baseId, + ...htmlProps, + }; + }, +}); + +export const TooltipReference = createComponent({ + as: "div", + useHook: useTooltipReference, +}); diff --git a/src/tooltip/TooltipState.ts b/src/tooltip/TooltipState.ts new file mode 100644 index 000000000..9710b60fa --- /dev/null +++ b/src/tooltip/TooltipState.ts @@ -0,0 +1,119 @@ +import * as React from "react"; + +import { + PopoverActions, + PopoverInitialState, + PopoverState, + PopoverStateReturn, + usePopoverState, +} from "../popover/PopoverState"; + +import globalState from "./__globalState"; + +export type TooltipState = Omit & { + /** + * @private + */ + unstable_timeout: number; +}; + +export type TooltipActions = Omit & { + /** + * @private + */ + unstable_setTimeout: React.Dispatch< + React.SetStateAction + >; +}; + +export type TooltipInitialState = Omit & + Pick, "unstable_timeout">; + +export type TooltipStateReturn = Omit< + PopoverStateReturn, + "modal" | "setModal" +> & + TooltipState & + TooltipActions; + +export function useTooltipState( + props: TooltipInitialState = {}, +): TooltipStateReturn { + const { unstable_timeout: initialTimeout = 0, ...restProps } = props; + const [timeout, setTimeout] = React.useState(initialTimeout); + const showTimeout = React.useRef(null); + const hideTimeout = React.useRef(null); + + const { modal, setModal, ...popover } = usePopoverState({ + ...restProps, + }); + + const clearTimeouts = React.useCallback(() => { + if (showTimeout.current !== null) { + window.clearTimeout(showTimeout.current); + } + if (hideTimeout.current !== null) { + window.clearTimeout(hideTimeout.current); + } + }, []); + + const hide = React.useCallback(() => { + clearTimeouts(); + popover.hide(); + // Let's give some time so people can move from a reference to another + // and still show tooltips immediately + hideTimeout.current = window.setTimeout(() => { + globalState.hide(popover.baseId); + }, timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clearTimeouts, popover.hide, timeout, popover.baseId]); + + const show = React.useCallback(() => { + clearTimeouts(); + if (!timeout || globalState.currentTooltipId) { + // If there's no timeout or a tooltip visible already, we can show this + // immediately + globalState.show(popover.baseId); + popover.show(); + } else { + // There may be a reference with focus whose tooltip is still not visible + // In this case, we want to update it before it gets shown. + globalState.show(null); + // Otherwise, wait a little bit to show the tooltip + showTimeout.current = window.setTimeout(() => { + globalState.show(popover.baseId); + popover.show(); + }, timeout); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [clearTimeouts, timeout, popover.show, popover.baseId]); + + React.useEffect(() => { + return globalState.subscribe(id => { + if (id !== popover.baseId) { + clearTimeouts(); + if (popover.visible) { + // Make sure there will be only one tooltip visible + popover.hide(); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [popover.baseId, clearTimeouts, popover.visible, popover.hide]); + + React.useEffect( + () => () => { + clearTimeouts(); + globalState.hide(popover.baseId); + }, + [clearTimeouts, popover.baseId], + ); + + return { + ...popover, + hide, + show, + unstable_timeout: timeout, + unstable_setTimeout: setTimeout, + }; +} diff --git a/src/tooltip/TooltipTrigger.ts b/src/tooltip/TooltipTrigger.ts new file mode 100644 index 000000000..f22a83060 --- /dev/null +++ b/src/tooltip/TooltipTrigger.ts @@ -0,0 +1,94 @@ +import * as React from "react"; +import { createComponent, createHook } from "reakit-system"; +import { RoleHTMLProps, RoleOptions, useRole } from "reakit"; +import { useLiveRef } from "reakit-utils"; + +import { TOOLTIP_TRIGGER_KEYS } from "./__keys"; +import { TooltipStateReturn } from "./TooltipState"; + +export type TooltipTriggerOptions = RoleOptions & + Pick, "baseId"> & + Pick; + +export type TooltipTriggerHTMLProps = RoleHTMLProps; + +export type TooltipTriggerProps = TooltipTriggerOptions & + TooltipTriggerHTMLProps; + +export const useTooltipTrigger = createHook< + TooltipTriggerOptions, + TooltipTriggerHTMLProps +>({ + name: "TooltipTrigger", + compose: useRole, + keys: TOOLTIP_TRIGGER_KEYS, + + useProps( + options, + { + ref: htmlRef, + onFocus: htmlOnFocus, + onBlur: htmlOnBlur, + onMouseEnter: htmlOnMouseEnter, + onMouseLeave: htmlOnMouseLeave, + ...htmlProps + }, + ) { + const { show, hide, baseId } = options; + const onFocusRef = useLiveRef(htmlOnFocus); + const onBlurRef = useLiveRef(htmlOnBlur); + const onMouseEnterRef = useLiveRef(htmlOnMouseEnter); + const onMouseLeaveRef = useLiveRef(htmlOnMouseLeave); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusRef.current?.(event); + if (event.defaultPrevented) return; + options.show?.(); + }, + [onFocusRef, options], + ); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + onBlurRef.current?.(event); + if (event.defaultPrevented) return; + hide?.(); + }, + [hide, onBlurRef], + ); + + const onMouseEnter = React.useCallback( + (event: React.MouseEvent) => { + onMouseEnterRef.current?.(event); + if (event.defaultPrevented) return; + show?.(); + }, + [onMouseEnterRef, show], + ); + + const onMouseLeave = React.useCallback( + (event: React.MouseEvent) => { + onMouseLeaveRef.current?.(event); + if (event.defaultPrevented) return; + hide?.(); + }, + [hide, onMouseLeaveRef], + ); + + return { + tabIndex: 0, + onFocus, + onBlur, + onMouseEnter, + onMouseLeave, + "aria-describedby": baseId, + ...htmlProps, + }; + }, +}); + +export const TooltipTrigger = createComponent({ + as: "div", + useHook: useTooltipTrigger, +}); diff --git a/src/tooltip/__globalState.ts b/src/tooltip/__globalState.ts new file mode 100644 index 000000000..7bea68951 --- /dev/null +++ b/src/tooltip/__globalState.ts @@ -0,0 +1,24 @@ +type Listener = (id: string | null) => void; + +const state = { + currentTooltipId: null as string | null, + listeners: new Set(), + subscribe(listener: Listener) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }, + show(id: string | null) { + this.currentTooltipId = id; + this.listeners.forEach(listener => listener(id)); + }, + hide(id: string) { + if (this.currentTooltipId === id) { + this.currentTooltipId = null; + this.listeners.forEach(listener => listener(null)); + } + }, +}; + +export default state; diff --git a/src/tooltip/__keys.ts b/src/tooltip/__keys.ts new file mode 100644 index 000000000..0596782a2 --- /dev/null +++ b/src/tooltip/__keys.ts @@ -0,0 +1,57 @@ +// Automatically generated +export const USE_TOOLTIP_STATE_KEYS = [ + "baseId", + "visible", + "side", + "align", + "sideOffset", + "alignOffset", + "arrowOffset", + "collisionTolerance", + "defaultVisible", + "onVisibleChange", + "enableCollisionsDetection", + "unstable_timeout", +] as const; +export const TOOLTIP_STATE_KEYS = [ + "baseId", + "visible", + "side", + "align", + "sideOffset", + "alignOffset", + "arrowOffset", + "collisionTolerance", + "unstable_idCountRef", + "disclosureRef", + "popperStyles", + "arrowStyles", + "placedSide", + "placedAlign", + "anchor", + "popper", + "arrow", + "setBaseId", + "show", + "hide", + "toggle", + "setVisible", + "setSide", + "setAlign", + "setSideOffset", + "setAlignOffset", + "setArrowOffset", + "setCollisionTolerance", + "setAnchor", + "setPopper", + "setArrow", + "unstable_timeout", + "unstable_setTimeout", +] as const; +export const TOOLTIP_KEYS = [...TOOLTIP_STATE_KEYS, "unstable_portal"] as const; +export const TOOLTIP_ANCHOR_KEYS = TOOLTIP_STATE_KEYS; +export const TOOLTIP_ARROW_KEYS = TOOLTIP_ANCHOR_KEYS; +export const TOOLTIP_ARROW_CONTENT_KEYS = TOOLTIP_ARROW_KEYS; +export const TOOLTIP_CONTENT_KEYS = TOOLTIP_ARROW_CONTENT_KEYS; +export const TOOLTIP_REFERENCE_KEYS = TOOLTIP_CONTENT_KEYS; +export const TOOLTIP_TRIGGER_KEYS = TOOLTIP_REFERENCE_KEYS; diff --git a/src/tooltip/index.ts b/src/tooltip/index.ts new file mode 100644 index 000000000..690c7f71c --- /dev/null +++ b/src/tooltip/index.ts @@ -0,0 +1,8 @@ +export * from "./Tooltip"; +export * from "./TooltipAnchor"; +export * from "./TooltipArrow"; +export * from "./TooltipArrowContent"; +export * from "./TooltipContent"; +export * from "./TooltipReference"; +export * from "./TooltipState"; +export * from "./TooltipTrigger"; diff --git a/src/tooltip/stories/TooltipBasic.component.tsx b/src/tooltip/stories/TooltipBasic.component.tsx new file mode 100644 index 000000000..51dfc03e4 --- /dev/null +++ b/src/tooltip/stories/TooltipBasic.component.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; + +import { + Tooltip, + TooltipContent, + TooltipInitialState, + TooltipReference, + useTooltipState, +} from "../../index"; + +export type TooltipBasicProps = TooltipInitialState & {}; + +export const TooltipBasic: React.FC = props => { + const state = useTooltipState(props); + + return ( +
+ + Open + + + + +
Tooltip
+
+
+ +
+ ); +}; diff --git a/src/tooltip/stories/TooltipBasic.css b/src/tooltip/stories/TooltipBasic.css new file mode 100644 index 000000000..ba67e8c95 --- /dev/null +++ b/src/tooltip/stories/TooltipBasic.css @@ -0,0 +1,15 @@ +.content { + background-color: rgba(33, 33, 33, 0.9); + color: white; + padding: 8px; + border-radius: 4px; + transition: opacity 250ms ease-in-out, transform 250ms ease-in-out; + opacity: 0; + transform-origin: top center; + transform: translate3d(0, -20px, 0); +} + +.content[data-enter] { + opacity: 1; + transform: translate3d(0, 0, 0); +} diff --git a/src/tooltip/stories/TooltipBasic.stories.tsx b/src/tooltip/stories/TooltipBasic.stories.tsx new file mode 100644 index 000000000..8b30dd510 --- /dev/null +++ b/src/tooltip/stories/TooltipBasic.stories.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipBasicCss"; +import js from "./templates/TooltipBasicJsx"; +import ts from "./templates/TooltipBasicTsx"; +import { TooltipBasic } from "./TooltipBasic.component"; + +import "./TooltipBasic.css"; + +export default { + component: TooltipBasic, + title: "Tooltip/Basic", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ; + +export const NoCollisionDetection: Story = args => ( + +); diff --git a/src/tooltip/stories/TooltipCustomAnchor.component.tsx b/src/tooltip/stories/TooltipCustomAnchor.component.tsx new file mode 100644 index 000000000..70c16e9bd --- /dev/null +++ b/src/tooltip/stories/TooltipCustomAnchor.component.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; + +import { + Tooltip, + TooltipAnchor, + TooltipArrow, + TooltipArrowContent, + TooltipContent, + TooltipInitialState, + TooltipTrigger, + useTooltipState, +} from "../../index"; + +export type TooltipCustomAnchorProps = TooltipInitialState & {}; + +export const TooltipCustomAnchor: React.FC = + props => { + const state = useTooltipState(props); + + return ( +
+ + Tooltip Trigger + Open + + + + +
Tooltip Content
+ + + + + + + +
+
+
+ ); + }; diff --git a/src/tooltip/stories/TooltipCustomAnchor.css b/src/tooltip/stories/TooltipCustomAnchor.css new file mode 100644 index 000000000..e2cf15370 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomAnchor.css @@ -0,0 +1,47 @@ +.content { + background-color: #222; + color: white; + padding: 5px; + border-radius: 5px; +} + +.arrow { + display: inline-block; + vertical-align: top; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadesIn 200ms ease-in-out; +} + +.content[data-leave] { + animation: fadesOut 200ms ease-in-out; +} + +@keyframes fadesIn { + from { + opacity: 0; + transform: translate(0, 10px); + } + + to { + opacity: 1; + transform: translate(0, 0px); + } +} + +@keyframes fadesOut { + from { + opacity: 1; + transform: translate(0, 0px); + } + + to { + opacity: 0; + transform: translate(0, +10px); + } +} diff --git a/src/tooltip/stories/TooltipCustomAnchor.stories.tsx b/src/tooltip/stories/TooltipCustomAnchor.stories.tsx new file mode 100644 index 000000000..b02883abd --- /dev/null +++ b/src/tooltip/stories/TooltipCustomAnchor.stories.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipCustomAnchorCss"; +import js from "./templates/TooltipCustomAnchorJsx"; +import ts from "./templates/TooltipCustomAnchorTsx"; +import { TooltipCustomAnchor } from "./TooltipCustomAnchor.component"; + +import "./TooltipCustomAnchor.css"; + +export default { + component: TooltipCustomAnchor, + title: "Tooltip/CustomAnchor", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ( + +); diff --git a/src/tooltip/stories/TooltipCustomContent.component.tsx b/src/tooltip/stories/TooltipCustomContent.component.tsx new file mode 100644 index 000000000..89d17d829 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomContent.component.tsx @@ -0,0 +1,144 @@ +import * as React from "react"; + +import { + Tooltip, + TooltipArrow, + TooltipArrowContent, + TooltipContent, + TooltipInitialState, + TooltipReference, + useTooltipState, +} from "../../index"; + +export type TooltipCustomContentProps = {}; + +export const TooltipCustomContent: React.FC = + props => { + return ( +
+ +

Some heading

+
+ + +

Some paragraph

+
+ + +
    +
  • One
  • +
  • Two
  • +
  • Three
  • +
+
+ + +
+ Lorem ipsum dolor sit amet consectetur, adipisicing elit. Harum, + quae qui. Magnam delectus ex totam repellat amet distinctio unde, + porro architecto voluptatibus nemo et nisi, voluptatem eligendi + earum autem fugit. +
+
+ + +
+ +
Jason Todd
+
+
+ + + {/* @ts-ignore */} + + + + + View in Modulz + + + +
+ +
+ +
+ +
+ +
+
+ + +

+ Start video call + + press{" "} + + c + + +

+
+
+ ); + }; + +export type TooltipBasicProps = TooltipInitialState & { + label?: string; +}; + +export const TooltipBasic: React.FC = props => { + const { label, children, ...restProps } = props; + const state = useTooltipState({ + sideOffset: 5, + arrowOffset: 10, + ...restProps, + }); + + return ( + <> + + {label} + + + + + {children} + + + + + + + + + + + ); +}; diff --git a/src/tooltip/stories/TooltipCustomContent.css b/src/tooltip/stories/TooltipCustomContent.css new file mode 100644 index 000000000..85286be70 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomContent.css @@ -0,0 +1,45 @@ +.trigger { + margin: 5px; + border: 1px solid black; + background: transparent; +} + +.content { + background-color: #222; + color: white; + padding: 5px 10px; + border-radius: 5px; + max-width: 300px; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadeIn 200ms ease-in-out; +} + +.content[data-leave] { + animation: fadeOut 200ms ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/tooltip/stories/TooltipCustomContent.stories.tsx b/src/tooltip/stories/TooltipCustomContent.stories.tsx new file mode 100644 index 000000000..e5e6b324c --- /dev/null +++ b/src/tooltip/stories/TooltipCustomContent.stories.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipCustomContentCss"; +import js from "./templates/TooltipCustomContentJsx"; +import ts from "./templates/TooltipCustomContentTsx"; +import { TooltipCustomContent } from "./TooltipCustomContent.component"; + +import "./TooltipCustomContent.css"; + +export default { + component: TooltipCustomContent, + title: "Tooltip/CustomContent", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ; diff --git a/src/tooltip/stories/TooltipCustomDuration.component.tsx b/src/tooltip/stories/TooltipCustomDuration.component.tsx new file mode 100644 index 000000000..627d2ddee --- /dev/null +++ b/src/tooltip/stories/TooltipCustomDuration.component.tsx @@ -0,0 +1,76 @@ +import * as React from "react"; + +import { + Tooltip, + TooltipArrow, + TooltipArrowContent, + TooltipContent, + TooltipInitialState, + TooltipReference, + useTooltipState, +} from "../../index"; + +export type TooltipCustomDurationProps = {}; + +export const TooltipCustomDuration: React.FC = + props => { + return ( + <> +

Duration

+

Default (Instant)

+
+ + + +
+ +

Custom (750ms)

+
+ + + +
+ + ); + }; + +export type TooltipBasicProps = TooltipInitialState & { + label?: string; +}; + +export const TooltipBasic: React.FC = props => { + const { label, children, ...restProps } = props; + const state = useTooltipState({ + sideOffset: 5, + arrowOffset: 10, + ...restProps, + }); + + return ( + <> + + TooltipTrigger + + + + + TooltipContent + + + + + + + + + + + ); +}; diff --git a/src/tooltip/stories/TooltipCustomDuration.css b/src/tooltip/stories/TooltipCustomDuration.css new file mode 100644 index 000000000..85286be70 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomDuration.css @@ -0,0 +1,45 @@ +.trigger { + margin: 5px; + border: 1px solid black; + background: transparent; +} + +.content { + background-color: #222; + color: white; + padding: 5px 10px; + border-radius: 5px; + max-width: 300px; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadeIn 200ms ease-in-out; +} + +.content[data-leave] { + animation: fadeOut 200ms ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/tooltip/stories/TooltipCustomDuration.stories.tsx b/src/tooltip/stories/TooltipCustomDuration.stories.tsx new file mode 100644 index 000000000..2754d8666 --- /dev/null +++ b/src/tooltip/stories/TooltipCustomDuration.stories.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipCustomDurationCss"; +import js from "./templates/TooltipCustomDurationJsx"; +import ts from "./templates/TooltipCustomDurationTsx"; +import { TooltipCustomDuration } from "./TooltipCustomDuration.component"; + +import "./TooltipCustomDuration.css"; + +export default { + component: TooltipCustomDuration, + title: "Tooltip/CustomDuration", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ; diff --git a/src/tooltip/stories/TooltipPositions.component.tsx b/src/tooltip/stories/TooltipPositions.component.tsx new file mode 100644 index 000000000..7df609c55 --- /dev/null +++ b/src/tooltip/stories/TooltipPositions.component.tsx @@ -0,0 +1,159 @@ +import * as React from "react"; + +import { + Tooltip, + TooltipArrow, + TooltipArrowContent, + TooltipContent, + TooltipInitialState, + TooltipReference, + useTooltipState, +} from "../../index"; + +export type TooltipPositionsProps = TooltipInitialState & {}; + +export const TooltipPositions: React.FC = props => { + return ( +
+
+ + + + + + + + + + + + + + + +
+
+ ); +}; + +export type TooltipBasicProps = TooltipInitialState & { + style?: React.CSSProperties; + label?: string; +}; + +export const TooltipBasic: React.FC = props => { + const { label, style, ...restProps } = props; + const state = useTooltipState({ + sideOffset: 5, + arrowOffset: 10, + ...restProps, + }); + + return ( + <> + + {label} + + + + +
Tooltip
+ + + + + + + +
+
+ + ); +}; diff --git a/src/tooltip/stories/TooltipPositions.css b/src/tooltip/stories/TooltipPositions.css new file mode 100644 index 000000000..5eba96f6a --- /dev/null +++ b/src/tooltip/stories/TooltipPositions.css @@ -0,0 +1,44 @@ +.trigger { + margin: 5px; + border: 1px solid black; + background: transparent; +} + +.content { + background-color: #222; + color: white; + padding: 5px 10px; + border-radius: 5px; +} + +.content { + transform-origin: top center; +} + +.content[data-enter] { + animation: fadeIn 200ms ease-in-out; +} + +.content[data-leave] { + animation: fadeOut 200ms ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} diff --git a/src/tooltip/stories/TooltipPositions.stories.tsx b/src/tooltip/stories/TooltipPositions.stories.tsx new file mode 100644 index 000000000..9fa4b7d5a --- /dev/null +++ b/src/tooltip/stories/TooltipPositions.stories.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; + +import css from "./templates/TooltipPositionsCss"; +import js from "./templates/TooltipPositionsJsx"; +import ts from "./templates/TooltipPositionsTsx"; +import { TooltipPositions } from "./TooltipPositions.component"; + +import "./TooltipPositions.css"; + +export default { + component: TooltipPositions, + title: "Tooltip/Positions", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts, css }), + }, +} as Meta; + +export const Default: Story = args => ; diff --git a/src/utils/index.ts b/src/utils/index.ts index 1f22853ad..4354f2971 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -86,4 +86,5 @@ export function splitStateProps(props: any, keys: readonly any[]) { } export * from "./date"; +export * from "./useAnimationPresence"; export * from "./useControllableState"; diff --git a/src/utils/useAnimationPresence/helpers.tsx b/src/utils/useAnimationPresence/helpers.tsx new file mode 100644 index 000000000..fafbe0456 --- /dev/null +++ b/src/utils/useAnimationPresence/helpers.tsx @@ -0,0 +1,31 @@ +import * as React from "react"; + +// Inspired from Radix UI Presence - https://github.com/radix-ui/primitives/tree/main/packages/react/presence + +type Machine = { [k: string]: { [k: string]: S } }; +type MachineState = keyof T; +type MachineEvent = keyof UnionToIntersection; + +// 🤯 https://fettblog.eu/typescript-union-to-intersection/ +type UnionToIntersection = (T extends any ? (x: T) => any : never) extends ( + x: infer R, +) => any + ? R + : never; + +export function useStateMachine( + initialState: MachineState, + machine: M & Machine>, +) { + return React.useReducer( + (state: MachineState, event: MachineEvent): MachineState => { + const nextState = (machine[state] as any)[event]; + return nextState ?? state; + }, + initialState, + ); +} + +export function getAnimationName(styles?: CSSStyleDeclaration) { + return styles?.animationName || "none"; +} diff --git a/src/utils/useAnimationPresence/index.ts b/src/utils/useAnimationPresence/index.ts new file mode 100644 index 000000000..6798ad331 --- /dev/null +++ b/src/utils/useAnimationPresence/index.ts @@ -0,0 +1,2 @@ +export * from "./helpers"; +export * from "./useAnimationPresence"; diff --git a/src/utils/useAnimationPresence/useAnimationPresence.tsx b/src/utils/useAnimationPresence/useAnimationPresence.tsx new file mode 100644 index 000000000..c2040bdf8 --- /dev/null +++ b/src/utils/useAnimationPresence/useAnimationPresence.tsx @@ -0,0 +1,124 @@ +// Inspired from Radix UI AnimationPresence - https://github.com/radix-ui/primitives/tree/main/packages/react/presence +import * as React from "react"; +import { useSafeLayoutEffect } from "@chakra-ui/hooks"; + +import { getAnimationName, useStateMachine } from "./helpers"; + +export type UseAnimationPresenceProps = { + present?: boolean; +}; + +export const useAnimationPresence = (props: UseAnimationPresenceProps = {}) => { + const { present } = props; + + const [node, setNode] = React.useState(); + const stylesRef = React.useRef({} as any); + const prevPresentRef = React.useRef(present); + const prevAnimationNameRef = React.useRef("none"); + const initialState = present ? "mounted" : "unmounted"; + const [state, send] = useStateMachine(initialState, { + mounted: { + UNMOUNT: "unmounted", + ANIMATION_OUT: "unmountSuspended", + }, + unmountSuspended: { + MOUNT: "mounted", + ANIMATION_END: "unmounted", + }, + unmounted: { + MOUNT: "mounted", + }, + }); + + React.useEffect(() => { + const currentAnimationName = getAnimationName(stylesRef.current); + prevAnimationNameRef.current = + state === "mounted" ? currentAnimationName : "none"; + }, [state]); + + useSafeLayoutEffect(() => { + const styles = stylesRef.current; + const wasPresent = prevPresentRef.current; + const hasPresentChanged = wasPresent !== present; + + if (hasPresentChanged) { + const prevAnimationName = prevAnimationNameRef.current; + const currentAnimationName = getAnimationName(styles); + + if (present) { + send("MOUNT"); + } else if ( + currentAnimationName === "none" || + styles?.display === "none" + ) { + // If there is no exit animation or the element is hidden, animations won't run + // so we unmount instantly + send("UNMOUNT"); + } else { + /** + * When `present` changes to `false`, we check changes to animation-name to + * determine whether an animation has started. We chose this approach (reading + * computed styles) because there is no `animationrun` event and `animationstart` + * fires after `animation-delay` has expired which would be too late. + */ + const isAnimating = prevAnimationName !== currentAnimationName; + + if (wasPresent && isAnimating) { + send("ANIMATION_OUT"); + } else { + send("UNMOUNT"); + } + } + + prevPresentRef.current = present; + } + }, [present, send]); + + useSafeLayoutEffect(() => { + if (node) { + /** + * Triggering an ANIMATION_OUT during an ANIMATION_IN will fire an `animationcancel` + * event for ANIMATION_IN after we have entered `unmountSuspended` state. So, we + * make sure we only trigger ANIMATION_END for the currently active animation. + */ + const handleAnimationEnd = (event: AnimationEvent) => { + const currentAnimationName = getAnimationName(stylesRef.current); + const isCurrentAnimation = currentAnimationName.includes( + event.animationName, + ); + + if (event.target === node && isCurrentAnimation) { + send("ANIMATION_END"); + } + }; + const handleAnimationStart = (event: AnimationEvent) => { + if (event.target === node) { + // if animation occurred, store its name as the previous animation. + prevAnimationNameRef.current = getAnimationName(stylesRef.current); + } + }; + + node.addEventListener("animationstart", handleAnimationStart); + node.addEventListener("animationcancel", handleAnimationEnd); + node.addEventListener("animationend", handleAnimationEnd); + + return () => { + node.removeEventListener("animationstart", handleAnimationStart); + node.removeEventListener("animationcancel", handleAnimationEnd); + node.removeEventListener("animationend", handleAnimationEnd); + }; + } + }, [node, send]); + + return { + isPresent: ["mounted", "unmountSuspended"].includes(state), + ref: React.useCallback((node: HTMLElement) => { + if (node) stylesRef.current = getComputedStyle(node); + setNode(node); + }, []), + }; +}; + +export type useAnimationPresenceReturnType = ReturnType< + typeof useAnimationPresence +>; diff --git a/yarn.lock b/yarn.lock index 59aa4349c..2af2351e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2781,7 +2781,7 @@ resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.8.0.tgz#bbbff68978fefdbe68ccb533bc8cbe1d1afb5413" integrity sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow== -"@emotion/is-prop-valid@0.8.8", "@emotion/is-prop-valid@^0.8.6": +"@emotion/is-prop-valid@0.8.8", "@emotion/is-prop-valid@^0.8.2", "@emotion/is-prop-valid@^0.8.6": version "0.8.8" resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== @@ -3421,6 +3421,36 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.9.2.tgz#adea7b6953cbb34651766b0548468e743c6a2353" integrity sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q== +"@radix-ui/popper@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/popper/-/popper-0.1.0.tgz#c387a38f31b7799e1ea0d2bb1ca0c91c2931b063" + integrity sha512-uzYeElL3w7SeNMuQpXiFlBhTT+JyaNMCwDfjKkrzugEcYrf5n52PHqncNdQPUtR42hJh8V9FsqyEDbDxkeNjJQ== + dependencies: + "@babel/runtime" "^7.13.10" + csstype "^3.0.4" + +"@radix-ui/react-use-rect@^0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-0.1.1.tgz#6c15384beee59c086e75b89a7e66f3d2e583a856" + integrity sha512-kHNNXAsP3/PeszEmM/nxBBS9Jbo93sO+xuMTcRfwzXsmxT5gDXQzAiKbZQ0EecCPtJIzqvr7dlaQi/aP1PKYqQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/rect" "0.1.1" + +"@radix-ui/react-use-size@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-0.1.0.tgz#dc49295d646f5d3f570943dbb88bd94fc7db7daf" + integrity sha512-TcZAsR+BYI46w/RbaSFCRACl+Jh6mDqhu6GS2r0iuJpIVrj8atff7qtTjmMmfGtEDNEjhl7DxN3pr1nTS/oruQ== + dependencies: + "@babel/runtime" "^7.13.10" + +"@radix-ui/rect@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-0.1.1.tgz#95b5ba51f469bea6b1b841e2d427e17e37d38419" + integrity sha512-g3hnE/UcOg7REdewduRPAK88EPuLZtaq7sA9ouu8S+YEtnyFRI16jgv6GZYe3VMoQLL1T171ebmEPtDjyxWLzw== + dependencies: + "@babel/runtime" "^7.13.10" + "@reach/observe-rect@^1.1.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2" @@ -3550,6 +3580,26 @@ "@react-spring/shared" "~9.3.0" "@react-spring/types" "~9.3.0" +"@react-spring/konva@~9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@react-spring/konva/-/konva-9.3.0.tgz#97b23b2f235a9805d39279a0a1027c7d9646d6fb" + integrity sha512-lyUWxzEateE6Qxpc81oxJb5yiNDdj36Q9R9euJAgjl2dvUDaX85rVGqaB25+72yA1iQg5I4Kymj3UZVvPthRlA== + dependencies: + "@react-spring/animated" "~9.3.0" + "@react-spring/core" "~9.3.0" + "@react-spring/shared" "~9.3.0" + "@react-spring/types" "~9.3.0" + +"@react-spring/native@~9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@react-spring/native/-/native-9.3.0.tgz#6fee1ccaa8d70a19c239b27e95bcc050776f1725" + integrity sha512-lvKV5qxqnE5AMtTHv8xwAocGED4+VRxpljwBl1lbtileq3WnvOn7CpMLZNGc5TXjLWAE3zfoNJui69/jE/3uSw== + dependencies: + "@react-spring/animated" "~9.3.0" + "@react-spring/core" "~9.3.0" + "@react-spring/shared" "~9.3.0" + "@react-spring/types" "~9.3.0" + "@react-spring/rafz@~9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-9.3.0.tgz#e791c0ae854f7c1a512ae87f34fff36934d82d29" @@ -3563,12 +3613,22 @@ "@react-spring/rafz" "~9.3.0" "@react-spring/types" "~9.3.0" +"@react-spring/three@~9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@react-spring/three/-/three-9.3.0.tgz#e3fc49de1411eb1a7aa937fec8db33252f11d294" + integrity sha512-RKMXXdcNK0nbwLbmle/0KT/idGGpOxvI5lT1KtN8R3cgJWQBKYWVtzg+B/RgmQVNxO/QNlsKGWTjURockTRSVQ== + dependencies: + "@react-spring/animated" "~9.3.0" + "@react-spring/core" "~9.3.0" + "@react-spring/shared" "~9.3.0" + "@react-spring/types" "~9.3.0" + "@react-spring/types@~9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-9.3.0.tgz#54ec58ca40414984209c8baa75fddd394f9e2949" integrity sha512-q4cDr2RSPblXMD3Rxvk6qcC7nmhhfV2izEBP06hb8ZCXznA6qJirG3RMpi29kBtEQiw1lWR59hAXKhauaPtbOA== -"@react-spring/web@9.3.0": +"@react-spring/web@9.3.0", "@react-spring/web@~9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-9.3.0.tgz#48d1ebdd1d484065e0a943dbbb343af259496427" integrity sha512-OTAGKRdyz6fLRR1tABFyw9KMpytyATIndQrj0O6RG47GfjiInpf4+WZKxo763vpS7z1OlnkI81WLUm/sqOqAnA== @@ -3578,6 +3638,16 @@ "@react-spring/shared" "~9.3.0" "@react-spring/types" "~9.3.0" +"@react-spring/zdog@~9.3.0": + version "9.3.0" + resolved "https://registry.yarnpkg.com/@react-spring/zdog/-/zdog-9.3.0.tgz#d84b69375017d864514ebcf59511731c5cc0280f" + integrity sha512-JOQwtg/MQ6sWwmKNY4w/R1TVXohIUkrbSgDfgUEK45ERTDwZGZzIo9QbqHv4dwEBK4Wa2Hfrcdf8cnEaNNzdAQ== + dependencies: + "@react-spring/animated" "~9.3.0" + "@react-spring/core" "~9.3.0" + "@react-spring/shared" "~9.3.0" + "@react-spring/types" "~9.3.0" + "@react-stately/utils@^3.2.2": version "3.2.2" resolved "https://registry.yarnpkg.com/@react-stately/utils/-/utils-3.2.2.tgz#468eafa60740c6b0b847a368215dfaa55e87f505" @@ -5283,11 +5353,6 @@ resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1" integrity sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA== -"@types/raf@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@types/raf/-/raf-3.4.0.tgz#2b72cbd55405e071f1c4d29992638e022b20acc2" - integrity sha512-taW5/WYqo36N7V39oYyHP9Ipfd5pNFvGTIQsNGj86xV88YQ7GnI30/yMfKDF7Zgin0m3e+ikX88FvImnK4RjGw== - "@types/reach__router@^1.3.7": version "1.3.8" resolved "https://registry.yarnpkg.com/@types/reach__router/-/reach__router-1.3.8.tgz#7b8607abf13704f918a9543257bcb7ec63028bfa" @@ -8746,6 +8811,11 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== +csstype@^3.0.4: + version "3.0.10" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.10.tgz#2ad3a7bed70f35b965707c092e5f30b327c290e5" + integrity sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA== + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -9025,6 +9095,11 @@ detect-newline@3.1.0, detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +detect-node-es@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" + integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== + detect-port-alt@1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/detect-port-alt/-/detect-port-alt-1.1.6.tgz#24707deabe932d4a3cf621302027c2b266568275" @@ -10559,6 +10634,19 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +framer-motion@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-5.3.1.tgz#5588f904f16b20a617f112ee2b10573e7392c488" + integrity sha512-wBqW2eAoIxGcp6zhIPlzHq9b6FqWG3Xir6ovgg/HZgbKajGLfcfYRsQU6qri0U53Qov5QZwb7CkfMjl0UIxL4A== + dependencies: + framesync "6.0.1" + hey-listen "^1.0.8" + popmotion "11.0.0" + style-value-types "5.0.0" + tslib "^2.1.0" + optionalDependencies: + "@emotion/is-prop-valid" "^0.8.2" + framesync@5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/framesync/-/framesync-5.3.0.tgz#0ecfc955e8f5a6ddc8fdb0cc024070947e1a0d9b" @@ -10566,6 +10654,13 @@ framesync@5.3.0: dependencies: tslib "^2.1.0" +framesync@6.0.1, framesync@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20" + integrity sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA== + dependencies: + tslib "^2.1.0" + fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -10777,6 +10872,11 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1: has "^1.0.3" has-symbols "^1.0.1" +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -11502,6 +11602,11 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== +hey-listen@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" + integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== + highlight.js@^10.1.1, highlight.js@~10.7.0: version "10.7.3" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" @@ -15702,11 +15807,6 @@ pegjs@^0.10.0: resolved "https://registry.yarnpkg.com/pegjs/-/pegjs-0.10.0.tgz#cf8bafae6eddff4b5a7efb185269eaaf4610ddbd" integrity sha1-z4uvrm7d/0tafvsYUmnqr0YQ3b0= -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= - picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -15819,6 +15919,16 @@ polished@^4.0.5: dependencies: "@babel/runtime" "^7.14.0" +popmotion@11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.0.tgz#910e2e7077d9aeba520db8744d40bb5354992212" + integrity sha512-kJDyaG00TtcANP5JZ51od+DCqopxBm2a/Txh3Usu23L9qntjY5wumvcVf578N8qXEHR1a+jx9XCv8zOntdYalQ== + dependencies: + framesync "^6.0.1" + hey-listen "^1.0.8" + style-value-types "5.0.0" + tslib "^2.1.0" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" @@ -16380,13 +16490,6 @@ quick-lru@^5.1.1: resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== -raf@^3.4.1: - version "3.4.1" - resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" - integrity sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA== - dependencies: - performance-now "^2.1.0" - ramda@^0.21.0: version "0.21.0" resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.21.0.tgz#a001abedb3ff61077d4ff1d577d44de77e8d0a35" @@ -16625,6 +16728,25 @@ react-refresh@^0.8.3: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== +react-remove-scroll-bar@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.2.0.tgz#d4d545a7df024f75d67e151499a6ab5ac97c8cdd" + integrity sha512-UU9ZBP1wdMR8qoUs7owiVcpaPwsQxUDC2lypP6mmixaGlARZa7ZIBx1jcuObLdhMOvCsnZcvetOho0wzPa9PYg== + dependencies: + react-style-singleton "^2.1.0" + tslib "^1.0.0" + +react-remove-scroll@^2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.4.3.tgz#83d19b02503b04bd8141ed6e0b9e6691a2e935a6" + integrity sha512-lGWYXfV6jykJwbFpsuPdexKKzp96f3RbvGapDSIdcyGvHb7/eqyn46C7/6h+rUzYar1j5mdU+XECITHXCKBk9Q== + dependencies: + react-remove-scroll-bar "^2.1.0" + react-style-singleton "^2.1.0" + tslib "^1.0.0" + use-callback-ref "^1.2.3" + use-sidecar "^1.0.1" + react-select@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/react-select/-/react-select-3.2.0.tgz#de9284700196f5f9b5277c5d850a9ce85f5c72fe" @@ -16657,6 +16779,27 @@ react-sizeme@^3.0.1: shallowequal "^1.1.0" throttle-debounce "^3.0.1" +react-spring@^9.3.1: + version "9.3.1" + resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-9.3.1.tgz#5f9587d42a524c8bfeb870458d32755ceee9801a" + integrity sha512-YbWcG+LiiyMi4RnBrMudVj2Rqnt2yEozeISkW5Hm2SOss+uDS4CpVjLuwdFAwczw99FSPrRfyAlGjEx3HvOWiw== + dependencies: + "@react-spring/core" "~9.3.0" + "@react-spring/konva" "~9.3.0" + "@react-spring/native" "~9.3.0" + "@react-spring/three" "~9.3.0" + "@react-spring/web" "~9.3.0" + "@react-spring/zdog" "~9.3.0" + +react-style-singleton@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.1.1.tgz#ce7f90b67618be2b6b94902a30aaea152ce52e66" + integrity sha512-jNRp07Jza6CBqdRKNgGhT3u9umWvils1xsuMOjZlghBDH2MU0PL2WZor4PGYjXpnRCa9DQSlHMs/xnABWOwYbA== + dependencies: + get-nonce "^1.0.0" + invariant "^2.2.4" + tslib "^1.0.0" + react-syntax-highlighter@^13.5.3: version "13.5.3" resolved "https://registry.yarnpkg.com/react-syntax-highlighter/-/react-syntax-highlighter-13.5.3.tgz#9712850f883a3e19eb858cf93fad7bb357eea9c6" @@ -18415,6 +18558,14 @@ style-to-object@0.3.0, style-to-object@^0.3.0: dependencies: inline-style-parser "0.1.1" +style-value-types@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-5.0.0.tgz#76c35f0e579843d523187989da866729411fc8ad" + integrity sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA== + dependencies: + hey-listen "^1.0.8" + tslib "^2.1.0" + stylis@^4.0.10, stylis@^4.0.3: version "4.0.10" resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.10.tgz#446512d1097197ab3f02fb3c258358c3f7a14240" @@ -18983,7 +19134,7 @@ tsconfig-paths@^3.11.0: minimist "^1.2.0" strip-bom "^3.0.0" -tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.0.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== @@ -19433,6 +19584,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-callback-ref@^1.2.3: + version "1.2.5" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.2.5.tgz#6115ed242cfbaed5915499c0a9842ca2912f38a5" + integrity sha512-gN3vgMISAgacF7sqsLPByqoePooY3n2emTH59Ur5d/M8eg4WTWu1xp8i8DHjohftIyEx0S08RiYxbffr4j8Peg== + use-composed-ref@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.1.0.tgz#9220e4e94a97b7b02d7d27eaeab0b37034438bbc" @@ -19452,6 +19608,14 @@ use-latest@^1.0.0: dependencies: use-isomorphic-layout-effect "^1.0.0" +use-sidecar@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.0.5.tgz#ffff2a17c1df42e348624b699ba6e5c220527f2b" + integrity sha512-k9jnrjYNwN6xYLj1iaGhonDghfvmeTmYjAiGvOr7clwKfPjMXJf4/HOr7oT5tJwYafgp2tG2l3eZEOfoELiMcA== + dependencies: + detect-node-es "^1.1.0" + tslib "^1.9.3" + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"