From e224e80ee1184081c739296c7afda9bb3c7c9977 Mon Sep 17 00:00:00 2001 From: Navin Moorthy Date: Tue, 23 Nov 2021 19:01:39 +0530 Subject: [PATCH] =?UTF-8?q?fix(disclosure):=20=F0=9F=90=9B=20add=20collaps?= =?UTF-8?q?e=20for=20showmore=20&=20refactor=20presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + src/dialog/helpers/useDisclosureRef.ts | 78 ++--- src/dialog/helpers/useFocusOnHide.ts | 5 +- src/dialog/helpers/useFocusOnShow.ts | 10 +- src/dialog/stories/DialogBasic.component.tsx | 7 +- src/disclosure/DisclosureCollapseContent.tsx | 283 ++++++++++++++++++ src/disclosure/DisclosureContent.tsx | 143 +++------ src/disclosure/__keys.ts | 24 +- src/disclosure/helpers.ts | 137 ++++----- src/disclosure/index.ts | 1 + .../stories/DisclosureBasic.component.tsx | 8 +- ...DisclosureCollapseHorizontal.component.tsx | 38 +++ .../DisclosureCollapseHorizontal.stories.tsx | 35 +++ .../DisclosureCollapseVertical.component.tsx | 37 +++ .../DisclosureCollapseVertical.stories.tsx | 35 +++ .../DisclosureHorizontal.component.tsx | 6 +- .../stories/DisclosureHorizontal.css | 31 +- src/drawer/stories/DrawerBasic.component.tsx | 7 +- .../stories/TooltipBasic.component.tsx | 2 +- src/utils/index.ts | 1 - src/utils/useAnimationPresence/helpers.tsx | 31 -- src/utils/useAnimationPresence/index.ts | 2 - .../useAnimationPresence.tsx | 124 -------- yarn.lock | 17 ++ 24 files changed, 631 insertions(+), 433 deletions(-) create mode 100644 src/disclosure/DisclosureCollapseContent.tsx create mode 100644 src/disclosure/stories/DisclosureCollapseHorizontal.component.tsx create mode 100644 src/disclosure/stories/DisclosureCollapseHorizontal.stories.tsx create mode 100644 src/disclosure/stories/DisclosureCollapseVertical.component.tsx create mode 100644 src/disclosure/stories/DisclosureCollapseVertical.stories.tsx delete mode 100644 src/utils/useAnimationPresence/helpers.tsx delete mode 100644 src/utils/useAnimationPresence/index.ts delete mode 100644 src/utils/useAnimationPresence/useAnimationPresence.tsx diff --git a/package.json b/package.json index 546fb5443..15e163dac 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@react-aria/utils": "^3.9.0", "date-fns": "^2.26.0", "framer-motion": "^5.3.1", + "raf": "^3.4.1", "react-remove-scroll": "^2.4.3", "react-spring": "^9.3.1", "reakit-system": "^0.15.2", @@ -137,6 +138,7 @@ "@types/jest-in-case": "1.0.5", "@types/mockdate": "3.0.0", "@types/node": "16.11.9", + "@types/raf": "3.4.0", "@types/react": "17.0.36", "@types/react-dom": "17.0.11", "@types/react-transition-group": "4.4.4", diff --git a/src/dialog/helpers/useDisclosureRef.ts b/src/dialog/helpers/useDisclosureRef.ts index 26f2a225f..468c41a4a 100644 --- a/src/dialog/helpers/useDisclosureRef.ts +++ b/src/dialog/helpers/useDisclosureRef.ts @@ -10,50 +10,50 @@ export function useDisclosureRef( 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; - } + if (options.visible || options.animating) return undefined; + + // We get the last focused element before the dialog opens, so we can move + // focus back to it when the dialog closes. + const onFocus = (event: FocusEvent) => { + const target = event.target as HTMLElement; + + if ("focus" in target) { + ref.current = target; + + if (options.disclosureRef) { + options.disclosureRef.current = target; } - }; + } + }; - const document = getDocument(dialogRef.current); - document.addEventListener("focusin", onFocus); + const document = getDocument(dialogRef.current); + document.addEventListener("focusin", onFocus); - return () => document.removeEventListener("focusin", onFocus); - } - }, [options.visible, options.present, options.disclosureRef, dialogRef]); + return () => document.removeEventListener("focusin", onFocus); + }, [options.visible, options.animating, 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]); + if (!options.visible || options.animating) return undefined; + + // Safari and Firefox on MacOS don't focus on buttons on mouse down. + // Instead, they focus on the closest focusable parent (ultimately, the + // body element). This works around that by preventing that behavior and + // forcing focus on the disclosure button. Otherwise, we wouldn't be able + // to close the dialog by clicking again on the disclosure. + const onMouseDown = (event: MouseEvent) => { + const element = event.currentTarget as HTMLElement; + + if (!isButton(element)) return; + + event.preventDefault(); + element.focus(); + }; + + const disclosure = options.disclosureRef?.current || ref.current; + disclosure?.addEventListener("mousedown", onMouseDown); + + return () => disclosure?.removeEventListener("mousedown", onMouseDown); + }, [options.visible, options.animating, options.disclosureRef]); return options.disclosureRef || ref; } diff --git a/src/dialog/helpers/useFocusOnHide.ts b/src/dialog/helpers/useFocusOnHide.ts index c6942c458..744649715 100644 --- a/src/dialog/helpers/useFocusOnHide.ts +++ b/src/dialog/helpers/useFocusOnHide.ts @@ -34,7 +34,8 @@ export function useFocusOnHide( useUpdateEffect(() => { if (!shouldFocus) return; - if (!options.present) return; + if (options.animating) return; + console.log("%canimating", "color: #ffa280", options.animating); // Hide was triggered by a click/focus on a tabbable element outside // the dialog or on another dialog. We won't change focus then. @@ -69,5 +70,5 @@ export function useFocusOnHide( "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]); + }, [shouldFocus, options.animating, dialogRef, disclosureRef]); } diff --git a/src/dialog/helpers/useFocusOnShow.ts b/src/dialog/helpers/useFocusOnShow.ts index 16295ade9..7a226b4cd 100644 --- a/src/dialog/helpers/useFocusOnShow.ts +++ b/src/dialog/helpers/useFocusOnShow.ts @@ -28,7 +28,7 @@ export function useFocusOnShow( if (!shouldFocus) return; if (!dialog) return; - if (!options.present) return; + if (options.animating) return; // If there're nested open dialogs, let them handle focus if (nestedDialogs.some(child => child.current && !child.current.hidden)) { @@ -52,5 +52,11 @@ export function useFocusOnShow( ); } } - }, [dialogRef, shouldFocus, options.present, nestedDialogs, initialFocusRef]); + }, [ + dialogRef, + shouldFocus, + options.animating, + nestedDialogs, + initialFocusRef, + ]); } diff --git a/src/dialog/stories/DialogBasic.component.tsx b/src/dialog/stories/DialogBasic.component.tsx index 7a4466d41..330936b61 100644 --- a/src/dialog/stories/DialogBasic.component.tsx +++ b/src/dialog/stories/DialogBasic.component.tsx @@ -19,12 +19,7 @@ export const DialogBasic: React.FC = props => { <> Open dialog - + Welcome to Reakit!
diff --git a/src/disclosure/DisclosureCollapseContent.tsx b/src/disclosure/DisclosureCollapseContent.tsx new file mode 100644 index 000000000..be5da8a22 --- /dev/null +++ b/src/disclosure/DisclosureCollapseContent.tsx @@ -0,0 +1,283 @@ +// Core Logic for transition is based on https://github.com/roginfarrer/react-collapsed +import * as React from "react"; +import { flushSync } from "react-dom"; +import { createComponent } from "reakit-system"; +import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; +import { useForkRef, useLiveRef, useUpdateEffect } from "reakit-utils"; +import raf from "raf"; + +import { createComposableHook } from "../system"; + +import { DISCLOSURE_COLLAPSE_CONTENT_KEYS } from "./__keys"; +import { DisclosureStateReturn } from "./DisclosureState"; +import { + getAutoSizeDuration, + getElementHeight, + getElementWidth, +} from "./helpers"; + +export type DisclosureCollapseContentOptions = BoxOptions & + Pick & { + /** + * Direction of the transition. + * + * @default vertical + */ + direction: "vertical" | "horizontal"; + + /** + * Size of the content. + * + * @default 0 + */ + contentSize: number; + + /** + * Duration of the transition. + * By default the duration is calculated based on the size of change. + */ + duration?: number; + + /** + * Transition Easing. + * + * @default cubic-bezier(0.4, 0, 0.2, 1) + */ + easing: string; + + /** + * Callback called before the expand transition starts. + */ + onExpandStart?: () => void; + + /** + * Callback called after the expand transition ends. + */ + onExpandEnd?: () => void; + + /** + * Callback called before the collapse transition starts. + */ + onCollapseStart?: () => void; + + /** + * Callback called after the collapse transition ends.. + */ + onCollapseEnd?: () => void; + }; + +export type DisclosureCollapseContentHTMLProps = BoxHTMLProps; + +export type DisclosureCollapseContentProps = DisclosureCollapseContentOptions & + DisclosureCollapseContentHTMLProps; + +export const disclosureCollapseComposableContent = createComposableHook< + DisclosureCollapseContentOptions, + DisclosureCollapseContentHTMLProps +>({ + name: "DisclosureCollapseContent", + compose: useBox, + keys: DISCLOSURE_COLLAPSE_CONTENT_KEYS, + + useOptions(options, htmlProps) { + const { + direction = "vertical", + contentSize = 0, + easing = "cubic-bezier(0.4, 0, 0.2, 1)", + ...restOptions + } = options; + return { direction, contentSize, easing, ...restOptions }; + }, + + useProps(options, htmlProps) { + const { + contentSize, + visible, + direction, + duration, + easing, + onCollapseEnd, + onCollapseStart, + onExpandEnd, + onExpandStart, + } = options; + const { + ref: htmlRef, + style: htmlStyle, + onTransitionEnd: htmlOnTransitionEnd, + ...restHtmlProps + } = htmlProps; + const ref = React.useRef(null); + const onTransitionEndRef = useLiveRef(htmlOnTransitionEnd); + const isVertical = direction === "vertical"; + const currentSize = isVertical ? "height" : "width"; + const getCurrentSizeStyle = React.useCallback( + (size: number) => ({ + [currentSize]: `${size}px`, + }), + [currentSize], + ); + const collapsedStyles = React.useMemo(() => { + return { + ...getCurrentSizeStyle(contentSize), + overflow: "hidden", + }; + }, [contentSize, getCurrentSizeStyle]); + + const [styles, setStylesRaw] = React.useState( + visible ? {} : collapsedStyles, + ); + const setStyles = (newStyles: {} | ((oldStyles: {}) => {})): void => { + // We rely on reading information from layout + // at arbitrary times, so ensure all style changes + // happen before we might attempt to read them. + flushSync(() => { + setStylesRaw(newStyles); + }); + }; + const mergeStyles = React.useCallback((newStyles: {}): void => { + setStyles(oldStyles => ({ ...oldStyles, ...newStyles })); + }, []); + + function getTransitionStyles(size: number | string): { + transition?: string; + } { + const _duration = duration || getAutoSizeDuration(size); + + return { + transition: `${currentSize} ${_duration}ms ${easing}`, + }; + } + + useUpdateEffect(() => { + if (visible) { + raf(() => { + onExpandStart?.(); + + mergeStyles({ + willChange: `${currentSize}`, + overflow: "hidden", + }); + + raf(() => { + const size = isVertical + ? getElementHeight(ref) + : getElementWidth(ref); + + mergeStyles({ + ...getTransitionStyles(size), + ...(isVertical ? { height: size } : { width: size }), + }); + }); + }); + } else { + raf(() => { + onCollapseStart?.(); + + const size = isVertical + ? getElementHeight(ref) + : getElementWidth(ref); + + mergeStyles({ + willChange: `${currentSize}`, + ...(isVertical ? { height: size } : { width: size }), + ...getTransitionStyles(size), + }); + raf(() => { + mergeStyles({ + ...getCurrentSizeStyle(contentSize), + overflow: "hidden", + }); + }); + }); + } + }, [visible]); + + const onTransitionEnd = React.useCallback( + (event: React.TransitionEvent) => { + onTransitionEndRef.current?.(event); + + if (event.defaultPrevented) return; + + // Sometimes onTransitionEnd is triggered by another transition, + // such as a nested collapse panel transitioning. But we only + // want to handle this if this component's element is transitioning + if ( + event.target !== ref.current || + event.propertyName !== currentSize + ) { + return; + } + + // The height comparisons below are a final check before + // completing the transition + // Sometimes this callback is run even though we've already begun + // transitioning the other direction + // The conditions give us the opportunity to bail out, + // which will prevent the collapsed content from flashing on the screen + const stylesSize = isVertical ? styles.height : styles.width; + + if (visible) { + const size = isVertical + ? getElementHeight(ref) + : getElementWidth(ref); + + // If the height at the end of the transition + // matches the height we're animating to, + if (size === stylesSize) { + setStyles({}); + } else { + // If the heights don't match, this could be due the height + // of the content changing mid-transition + mergeStyles({ + ...getCurrentSizeStyle(contentSize), + }); + } + + onExpandEnd?.(); + + // If the height we should be animating to matches the collapsed height, + // it's safe to apply the collapsed overrides + } else if (stylesSize === `${contentSize}px`) { + setStyles(collapsedStyles); + + onCollapseEnd?.(); + } + }, + [ + onTransitionEndRef, + currentSize, + isVertical, + styles.height, + styles.width, + visible, + contentSize, + onExpandEnd, + mergeStyles, + getCurrentSizeStyle, + collapsedStyles, + onCollapseEnd, + ], + ); + + const style = { ...styles, ...htmlStyle }; + + return { + ref: useForkRef(ref, htmlRef), + id: options.baseId, + "aria-hidden": !visible, + style, + onTransitionEnd, + ...restHtmlProps, + }; + }, +}); + +export const useDisclosureCollapseContent = + disclosureCollapseComposableContent(); + +export const DisclosureCollapseContent = createComponent({ + as: "div", + memo: true, + useHook: useDisclosureCollapseContent, +}); diff --git a/src/disclosure/DisclosureContent.tsx b/src/disclosure/DisclosureContent.tsx index f66aa2000..9a948ae04 100644 --- a/src/disclosure/DisclosureContent.tsx +++ b/src/disclosure/DisclosureContent.tsx @@ -2,49 +2,26 @@ import * as React from "react"; import { createComponent } from "reakit-system"; import { BoxHTMLProps, BoxOptions, useBox } from "reakit"; -import { useForkRef, useLiveRef } from "reakit-utils"; +import { useLiveRef } from "reakit-utils"; import { createComposableHook } from "../system"; -import { useAnimationPresence } from "../utils"; import { DISCLOSURE_CONTENT_KEYS } from "./__keys"; import { DisclosureStateReturn } from "./DisclosureState"; import { TransitionState, - useAnimationPresenceSize, - UseAnimationPresenceSizeReturnType, - useTransitionPresence, - UseTransitionPresenceReturnType, + useAnimation, + useAnimationReturnType, } from "./helpers"; export type DisclosureContentOptions = BoxOptions & 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"]; - onMountStart?: (value: boolean) => void; - onUnMountStart?: (value: boolean) => void; + state: TransitionState; + animating: useAnimationReturnType["animating"]; + onEnd: useAnimationReturnType["onEnd"]; + isVisible: boolean; + isHidden: boolean; }; export type DisclosureContentHTMLProps = BoxHTMLProps; @@ -61,85 +38,39 @@ export const disclosureComposableContent = createComposableHook< keys: DISCLOSURE_CONTENT_KEYS, useOptions(options, htmlProps) { - const { - visible, - animationPresent = false, - transitionPresent = false, - onMountStart, - onUnMountStart, - } = 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, + const { visible, animationPresent = false, ...restOptions } = options; + const { state, animating, onEnd } = useAnimation({ visible, }); - React.useEffect(() => { - if (visible && !present) { - onMountStart?.(true); - } else { - onMountStart?.(false); - } - - if (!visible && present) { - onUnMountStart?.(true); - } else { - onUnMountStart?.(false); - } - }, [visible, present, onMountStart, onUnMountStart]); - - // 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); + const isVisible = visible && animating; + const isHidden = !visible && !animating; return { - ...options, + animationPresent, + visible, + ...restOptions, + isVisible, isHidden, - presenceRef: useForkRef(animationRef, transitionRef), - transitionState, + state, + animating, onEnd, - contentWidth, - contentHeight, - present, }; }, useProps(options, htmlProps) { + const { baseId, onEnd, isHidden, isVisible, state, animationPresent } = + options; const { - visible, - baseId, - presenceRef, - transitionPresent, - animationPresent, - onEnd, - contentWidth: width, - contentHeight: height, - isHidden, - transitionState, - } = options; - const { - ref: htmlRef, style: htmlStyle, onTransitionEnd: htmlOnTransitionEnd, + onAnimationEnd: htmlOnAnimationEnd, ...restHtmlProps } = htmlProps; const onTransitionEndRef = useLiveRef(htmlOnTransitionEnd); + const onAnimationEndRef = useLiveRef(htmlOnAnimationEnd); + const onTransitionEnd = React.useCallback( (event: React.TransitionEvent) => { onTransitionEndRef.current?.(event); @@ -149,28 +80,32 @@ export const disclosureComposableContent = createComposableHook< [onEnd, onTransitionEndRef], ); + const onAnimationEnd = React.useCallback( + (event: React.AnimationEvent) => { + onAnimationEndRef.current?.(event); + onEnd?.(event); + }, + [onAnimationEndRef, onEnd], + ); + const style = { - "--content-height": height ? `${height}px` : undefined, - "--content-width": width ? `${width}px` : undefined, display: isHidden ? "none" : undefined, ...htmlStyle, }; return { - ref: useForkRef(presenceRef, htmlRef), id: baseId, hidden: isHidden, - "data-enter": - (transitionPresent && transitionState === "enter") || - (animationPresent && visible) - ? "" - : undefined, - "data-leave": - (transitionPresent && transitionState === "leave") || - (animationPresent && !visible) + "data-enter": animationPresent + ? isVisible ? "" - : undefined, + : undefined + : state === "enter" + ? "" + : undefined, + "data-leave": state === "leave" ? "" : undefined, onTransitionEnd, + onAnimationEnd, style, ...restHtmlProps, }; diff --git a/src/disclosure/__keys.ts b/src/disclosure/__keys.ts index 64c319f6c..5fcf02277 100644 --- a/src/disclosure/__keys.ts +++ b/src/disclosure/__keys.ts @@ -16,17 +16,23 @@ export const USE_DISCLOSURE_STATE_KEYS = [ "onVisibleChange", ] as const; export const DISCLOSURE_KEYS = DISCLOSURE_STATE_KEYS; +export const DISCLOSURE_COLLAPSE_CONTENT_KEYS = [ + ...DISCLOSURE_KEYS, + "direction", + "contentSize", + "duration", + "easing", + "onExpandStart", + "onExpandEnd", + "onCollapseStart", + "onCollapseEnd", +] as const; export const DISCLOSURE_CONTENT_KEYS = [ ...DISCLOSURE_KEYS, "animationPresent", - "transitionPresent", - "isHidden", - "presenceRef", - "present", - "transitionState", + "state", + "animating", "onEnd", - "contentWidth", - "contentHeight", - "onMountStart", - "onUnMountStart", + "isVisible", + "isHidden", ] as const; diff --git a/src/disclosure/helpers.ts b/src/disclosure/helpers.ts index 94cbc56f1..8bc3b2a69 100644 --- a/src/disclosure/helpers.ts +++ b/src/disclosure/helpers.ts @@ -12,84 +12,26 @@ function useLastValue(value: T) { return lastValue; } -export type useAnimationPresenceSizeProps = { - present: boolean; +export type useAnimationProps = { 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 const useAnimation = (props: useAnimationProps) => { + const { visible } = props; -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 [animating, setAnimating] = React.useState(false); const lastVisible = useLastValue(visible); - const visibleHasChanged = lastVisible.current != null && lastVisible.current !== visible; + const [state, setState] = React.useState(null); + const raf = React.useRef(0); - if (transition && !transitioning && visibleHasChanged) { - // Sets transitioning to true when when visible is updated - setTransitioning(true); + if (!animating && visibleHasChanged) { + // Sets animating to true when when visible is updated + setAnimating(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. @@ -97,35 +39,66 @@ export const useTransitionPresence = (props: useTransitionPresenceProps) => { raf.current = window.requestAnimationFrame(() => { raf.current = window.requestAnimationFrame(() => { if (visible) { - if (!transitioning) return; - - setTransitionState("enter"); - } else if (transitioning) { - setTransitionState("leave"); + setState("enter"); + } else if (animating) { + setState("leave"); } else { - setTransitionState(null); + setState(null); } }); }); return () => window.cancelAnimationFrame(raf.current); - }, [visible, transitioning, transition]); + }, [visible, animating]); + + const stopAnimation = React.useCallback(() => setAnimating(false), []); const onEnd = React.useCallback( (event: React.SyntheticEvent) => { if (!isSelfTarget(event)) return; - if (!transition) return; - if (!transitioning) return; + if (!animating) return; // Ignores number animated - setTransitioning(false); + stopAnimation(); }, - [transition, transitioning], + [animating, stopAnimation], ); - return { transitionState, transitioning, onEnd }; + return { state, animating, onEnd }; }; -export type UseTransitionPresenceReturnType = ReturnType< - typeof useTransitionPresence ->; +export type useAnimationReturnType = ReturnType; + +export type TransitionState = "enter" | "leave" | null; + +export function getElementHeight( + el: React.RefObject | { current?: { scrollHeight: number } }, +): string | number { + if (!el?.current) { + return "auto"; + } + + return el.current.scrollHeight; +} + +export function getElementWidth( + el: React.RefObject | { current?: { scrollWidth: number } }, +): string | number { + if (!el?.current) { + return "auto"; + } + + return el.current.scrollWidth; +} + +// https://github.com/mui-org/material-ui/blob/da362266f7c137bf671d7e8c44c84ad5cfc0e9e2/packages/material-ui/src/styles/transitions.js#L89-L98 +export function getAutoSizeDuration(size: number | string): number { + if (!size || typeof size === "string") { + return 0; + } + + const constant = size / 36; + + // https://www.wolframalpha.com/input/?i=(4+%2B+15+*+(x+%2F+36+)+**+0.25+%2B+(x+%2F+36)+%2F+5)+*+10 + return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10); +} diff --git a/src/disclosure/index.ts b/src/disclosure/index.ts index 2ed1c7143..591ed0ab2 100644 --- a/src/disclosure/index.ts +++ b/src/disclosure/index.ts @@ -1,4 +1,5 @@ export * from "./__keys"; export * from "./Disclosure"; +export * from "./DisclosureCollapseContent"; export * from "./DisclosureContent"; export * from "./DisclosureState"; diff --git a/src/disclosure/stories/DisclosureBasic.component.tsx b/src/disclosure/stories/DisclosureBasic.component.tsx index ab1302c60..84125ad55 100644 --- a/src/disclosure/stories/DisclosureBasic.component.tsx +++ b/src/disclosure/stories/DisclosureBasic.component.tsx @@ -15,13 +15,7 @@ export const DisclosureBasic: React.FC = props => { return (
Show More - console.log("onMountStart", start)} - onUnMountStart={start => console.log("onUnMountStart", start)} - {...state} - > + Item 1 Item 2 Item 3 diff --git a/src/disclosure/stories/DisclosureCollapseHorizontal.component.tsx b/src/disclosure/stories/DisclosureCollapseHorizontal.component.tsx new file mode 100644 index 000000000..f2998d4ab --- /dev/null +++ b/src/disclosure/stories/DisclosureCollapseHorizontal.component.tsx @@ -0,0 +1,38 @@ +import * as React from "react"; + +import { + Disclosure, + DisclosureCollapseContent, + DisclosureInitialState, + useDisclosureState, +} from "../../index"; + +export type DisclosureCollapseHorizontalProps = DisclosureInitialState & {}; + +export const DisclosureCollapseHorizontal: React.FC = + props => { + const state = useDisclosureState(props); + + return ( +
+ Show More + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + +
+ ); + }; + +export default DisclosureCollapseHorizontal; diff --git a/src/disclosure/stories/DisclosureCollapseHorizontal.stories.tsx b/src/disclosure/stories/DisclosureCollapseHorizontal.stories.tsx new file mode 100644 index 000000000..0d357d8c8 --- /dev/null +++ b/src/disclosure/stories/DisclosureCollapseHorizontal.stories.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; +import { DisclosureState } from "../index"; + +import js from "./templates/DisclosureCollapseHorizontalJsx"; +import ts from "./templates/DisclosureCollapseHorizontalTsx"; +import { + DisclosureCollapseHorizontal, + DisclosureCollapseHorizontalProps, +} from "./DisclosureCollapseHorizontal.component"; + +export default { + component: DisclosureCollapseHorizontal, + title: "Disclosure/CollapseHorizontal", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = args => ( + +); + +export const Controlled = () => { + const [value, setValue] = React.useState(false); + console.log("%cvalue", "color: #997326", value); + + return ( + + ); +}; diff --git a/src/disclosure/stories/DisclosureCollapseVertical.component.tsx b/src/disclosure/stories/DisclosureCollapseVertical.component.tsx new file mode 100644 index 000000000..df14b2b3a --- /dev/null +++ b/src/disclosure/stories/DisclosureCollapseVertical.component.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; + +import { + Disclosure, + DisclosureCollapseContent, + DisclosureInitialState, + useDisclosureState, +} from "../../index"; + +export type DisclosureCollapseVerticalProps = DisclosureInitialState & {}; + +export const DisclosureCollapseVertical: React.FC = + props => { + const state = useDisclosureState(props); + + return ( +
+ Show More + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + +
+ ); + }; + +export default DisclosureCollapseVertical; diff --git a/src/disclosure/stories/DisclosureCollapseVertical.stories.tsx b/src/disclosure/stories/DisclosureCollapseVertical.stories.tsx new file mode 100644 index 000000000..acd6c4d60 --- /dev/null +++ b/src/disclosure/stories/DisclosureCollapseVertical.stories.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { Meta, Story } from "@storybook/react"; + +import { createPreviewTabs } from "../../../.storybook/utils"; +import { DisclosureState } from "../index"; + +import js from "./templates/DisclosureCollapseVerticalJsx"; +import ts from "./templates/DisclosureCollapseVerticalTsx"; +import { + DisclosureCollapseVertical, + DisclosureCollapseVerticalProps, +} from "./DisclosureCollapseVertical.component"; + +export default { + component: DisclosureCollapseVertical, + title: "Disclosure/CollapseVertical", + parameters: { + layout: "centered", + options: { showPanel: true }, + preview: createPreviewTabs({ js, ts }), + }, +} as Meta; + +export const Default: Story = args => ( + +); + +export const Controlled = () => { + const [value, setValue] = React.useState(false); + console.log("%cvalue", "color: #997326", value); + + return ( + + ); +}; diff --git a/src/disclosure/stories/DisclosureHorizontal.component.tsx b/src/disclosure/stories/DisclosureHorizontal.component.tsx index 75f0c2dd1..d2ff9a787 100644 --- a/src/disclosure/stories/DisclosureHorizontal.component.tsx +++ b/src/disclosure/stories/DisclosureHorizontal.component.tsx @@ -16,11 +16,7 @@ export const DisclosureHorizontal: React.FC = return (
Show More - +
Item 1
Item 2
Item 3
diff --git a/src/disclosure/stories/DisclosureHorizontal.css b/src/disclosure/stories/DisclosureHorizontal.css index 6adb6c056..4279463ed 100644 --- a/src/disclosure/stories/DisclosureHorizontal.css +++ b/src/disclosure/stories/DisclosureHorizontal.css @@ -5,35 +5,42 @@ .content { display: flex; flex-direction: row; - overflow: hidden; + /* opacity: 0; */ } -.content[data-enter] { - animation: slideRight 300ms ease-out; +.content { + transform-origin: top center; } -.item { - flex-shrink: 0; +.content[data-enter] { + /* opacity: 1; */ + animation: fadedIn 500ms ease-in-out; } .content[data-leave] { - animation: slideLeft 300ms ease-in; + animation: fadedOut 500ms ease-in-out; } -@keyframes slideRight { +@keyframes fadedIn { from { - width: 0; + opacity: 0; + transform: translate(-10px, 0); } + to { - width: var(--content-width); + opacity: 1; + transform: translate(0, 0px); } } -@keyframes slideLeft { +@keyframes fadedOut { from { - width: var(--content-width); + opacity: 1; + transform: translate(0, 0px); } + to { - width: 0; + opacity: 0; + transform: translate(-10px, 0); } } diff --git a/src/drawer/stories/DrawerBasic.component.tsx b/src/drawer/stories/DrawerBasic.component.tsx index e2f66b7ee..4c9d8901b 100644 --- a/src/drawer/stories/DrawerBasic.component.tsx +++ b/src/drawer/stories/DrawerBasic.component.tsx @@ -27,11 +27,7 @@ export const DrawerBasic: React.FC = props => { - + = props => { transform: ${cssTransforms[placement]}; } `} - transitionPresent={true} unstable_initialFocusRef={inputRef} > X diff --git a/src/tooltip/stories/TooltipBasic.component.tsx b/src/tooltip/stories/TooltipBasic.component.tsx index 51dfc03e4..db1778d87 100644 --- a/src/tooltip/stories/TooltipBasic.component.tsx +++ b/src/tooltip/stories/TooltipBasic.component.tsx @@ -27,7 +27,7 @@ export const TooltipBasic: React.FC = props => { - +
Tooltip
diff --git a/src/utils/index.ts b/src/utils/index.ts index 4354f2971..1f22853ad 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -86,5 +86,4 @@ 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 deleted file mode 100644 index fafbe0456..000000000 --- a/src/utils/useAnimationPresence/helpers.tsx +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 6798ad331..000000000 --- a/src/utils/useAnimationPresence/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./helpers"; -export * from "./useAnimationPresence"; diff --git a/src/utils/useAnimationPresence/useAnimationPresence.tsx b/src/utils/useAnimationPresence/useAnimationPresence.tsx deleted file mode 100644 index c2040bdf8..000000000 --- a/src/utils/useAnimationPresence/useAnimationPresence.tsx +++ /dev/null @@ -1,124 +0,0 @@ -// 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 8771a9577..2a628a930 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5374,6 +5374,11 @@ 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" @@ -15888,6 +15893,11 @@ 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" @@ -16564,6 +16574,13 @@ 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"