UNPKG

react-collapsed

Version:

A React custom-hook for creating flexible and accessible expand/collapse components.

440 lines (425 loc) 13.4 kB
/** * react-collapsed v4.2.0 * * Copyright (c) 2019-2024, Rogin Farrer * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. * * @license MIT */ // src/index.ts import { useState as useState4, useRef as useRef3, useEffect as useEffect6, useLayoutEffect as useReactLayoutEffect } from "react"; // src/utils/index.ts import { useEffect as useEffect5 } from "react"; // src/utils/CollapseError.ts import warning from "tiny-warning"; var CollapseError = class extends Error { constructor(message) { super(`react-collapsed: ${message}`); } }; var collapseWarning = (...args) => { return warning(args[0], `[react-collapsed] -- ${args[1]}`); }; // src/utils/useEvent.ts import { useRef, useEffect, useCallback } from "react"; function useEvent(callback) { const ref = useRef(callback); useEffect(() => { ref.current = callback; }); return useCallback((...args) => ref.current?.(...args), []); } // src/utils/useControlledState.ts import { useState, useRef as useRef2, useCallback as useCallback2, useEffect as useEffect2 } from "react"; function useControlledState(value, defaultValue, callback) { const [state, setState] = useState(defaultValue); const initiallyControlled = useRef2(typeof value !== "undefined"); const effectiveValue = initiallyControlled.current ? value : state; const cb = useEvent(callback); const onChange = useCallback2( (update) => { const setter = update; const newValue = typeof update === "function" ? setter(effectiveValue) : update; if (!initiallyControlled.current) { setState(newValue); } cb?.(newValue); }, [cb, effectiveValue] ); useEffect2(() => { collapseWarning( !(initiallyControlled.current && value == null), "`isExpanded` state is changing from controlled to uncontrolled. useCollapse should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the `isExpanded` prop." ); collapseWarning( !(!initiallyControlled.current && value != null), "`isExpanded` state is changing from uncontrolled to controlled. useCollapse should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled collapse for the lifetime of the component. Check the `isExpanded` prop." ); }, [value]); return [effectiveValue, onChange]; } // src/utils/usePrefersReducedMotion.ts import { useState as useState2, useEffect as useEffect3 } from "react"; var QUERY = "(prefers-reduced-motion: reduce)"; function usePrefersReducedMotion() { const [prefersReducedMotion, setPrefersReducedMotion] = useState2(false); useEffect3(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") { return; } const mediaQueryList = window.matchMedia(QUERY); setPrefersReducedMotion(mediaQueryList.matches); const listener = (event) => { setPrefersReducedMotion(event.matches); }; if (mediaQueryList.addEventListener) { mediaQueryList.addEventListener("change", listener); return () => { mediaQueryList.removeEventListener("change", listener); }; } else if (mediaQueryList.addListener) { mediaQueryList.addListener(listener); return () => { mediaQueryList.removeListener(listener); }; } return void 0; }, []); return prefersReducedMotion; } // src/utils/useId.ts import * as React from "react"; var __useId = React["useId".toString()] || (() => void 0); function useReactId() { const id2 = __useId(); return id2 ?? ""; } var useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect; var serverHandoffComplete = false; var id = 0; var genId = () => ++id; function useUniqueId(idFromProps) { const initialId = idFromProps || (serverHandoffComplete ? genId() : null); const [id2, setId] = React.useState(initialId); useIsomorphicLayoutEffect(() => { if (id2 === null) { setId(genId()); } }, []); React.useEffect(() => { if (serverHandoffComplete === false) { serverHandoffComplete = true; } }, []); return id2 != null ? String(id2) : void 0; } function useId(idOverride) { const reactId = useReactId(); const uniqueId = useUniqueId(idOverride); if (typeof idOverride === "string") { return idOverride; } if (typeof reactId === "string") { return reactId; } return uniqueId; } // src/utils/setAnimationTimeout.ts function setAnimationTimeout(callback, timeout) { const startTime = performance.now(); const frame = {}; function call() { frame.id = requestAnimationFrame((now) => { if (now - startTime > timeout) { callback(); } else { call(); } }); } call(); return frame; } function clearAnimationTimeout(frame) { if (frame.id) cancelAnimationFrame(frame.id); } // src/utils/index.ts function getElementHeight(el) { if (!el?.current) { collapseWarning( true, `Was not able to find a ref to the collapse element via \`getCollapseProps\`. Ensure that the element exposes its \`ref\` prop. If it exposes the ref prop under a different name (like \`innerRef\`), use the \`refKey\` property to change it. Example: const collapseProps = getCollapseProps({refKey: 'innerRef'})` ); return 0; } return el.current.scrollHeight; } function getAutoHeightDuration(height) { if (!height || typeof height === "string") { return 0; } const constant = height / 36; return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10); } function assignRef(ref, value) { if (ref == null) return; if (typeof ref === "function") { ref(value); } else { try { ref.current = value; } catch (error) { throw new CollapseError(`Cannot assign value "${value}" to ref "${ref}"`); } } } function mergeRefs(...refs) { if (refs.every((ref) => ref == null)) { return null; } return (node) => { refs.forEach((ref) => { assignRef(ref, node); }); }; } function usePaddingWarning(element) { let warn = (el) => { }; if (true !== "production") { warn = (el) => { if (!el?.current) { return; } const { paddingTop, paddingBottom } = window.getComputedStyle(el.current); const hasPadding = paddingTop && paddingTop !== "0px" || paddingBottom && paddingBottom !== "0px"; collapseWarning( !hasPadding, `Padding applied to the collapse element will cause the animation to break and not perform as expected. To fix, apply equivalent padding to the direct descendent of the collapse element. Example: Before: <div {...getCollapseProps({style: {padding: 10}})}>{children}</div> After: <div {...getCollapseProps()}> <div style={{padding: 10}}> {children} </div> </div>` ); }; } useEffect5(() => { warn(element); }, [element]); } // src/index.ts var useLayoutEffect2 = typeof window === "undefined" ? useEffect6 : useReactLayoutEffect; function useCollapse({ duration, easing = "cubic-bezier(0.4, 0, 0.2, 1)", onTransitionStateChange: propOnTransitionStateChange = () => { }, isExpanded: configIsExpanded, defaultExpanded = false, hasDisabledAnimation, id: id2, ...initialConfig } = {}) { const onTransitionStateChange = useEvent(propOnTransitionStateChange); const uniqueId = useId(id2 ? `${id2}` : void 0); const [isExpanded, setExpanded] = useControlledState( configIsExpanded, defaultExpanded ); const prevExpanded = useRef3(isExpanded); const [isAnimating, setIsAnimating] = useState4(false); const prefersReducedMotion = usePrefersReducedMotion(); const disableAnimation = hasDisabledAnimation ?? prefersReducedMotion; const frameId = useRef3(); const endFrameId = useRef3(); const collapseElRef = useRef3(null); const [toggleEl, setToggleEl] = useState4(null); usePaddingWarning(collapseElRef); const collapsedHeight = `${initialConfig.collapsedHeight || 0}px`; function setStyles(newStyles) { if (!collapseElRef.current) return; const target = collapseElRef.current; for (const property in newStyles) { const value = newStyles[property]; if (value) { target.style[property] = value; } else { target.style.removeProperty(property); } } } useLayoutEffect2(() => { const collapse = collapseElRef.current; if (!collapse) return; if (isExpanded === prevExpanded.current) return; prevExpanded.current = isExpanded; function getDuration(height) { if (disableAnimation) { return 0; } return duration ?? getAutoHeightDuration(height); } const getTransitionStyles = (height) => `height ${getDuration(height)}ms ${easing}`; const setTransitionEndTimeout = (duration2) => { function endTransition() { if (isExpanded) { setStyles({ height: "", overflow: "", transition: "", display: "" }); onTransitionStateChange("expandEnd"); } else { setStyles({ transition: "" }); onTransitionStateChange("collapseEnd"); } setIsAnimating(false); } if (endFrameId.current) { clearAnimationTimeout(endFrameId.current); } endFrameId.current = setAnimationTimeout(endTransition, duration2); }; setIsAnimating(true); if (isExpanded) { frameId.current = requestAnimationFrame(() => { onTransitionStateChange("expandStart"); setStyles({ display: "block", overflow: "hidden", height: collapsedHeight }); frameId.current = requestAnimationFrame(() => { onTransitionStateChange("expanding"); const height = getElementHeight(collapseElRef); setTransitionEndTimeout(getDuration(height)); if (collapseElRef.current) { collapseElRef.current.style.transition = getTransitionStyles(height); collapseElRef.current.style.height = `${height}px`; } }); }); } else { frameId.current = requestAnimationFrame(() => { onTransitionStateChange("collapseStart"); const height = getElementHeight(collapseElRef); setTransitionEndTimeout(getDuration(height)); setStyles({ transition: getTransitionStyles(height), height: `${height}px` }); frameId.current = requestAnimationFrame(() => { onTransitionStateChange("collapsing"); setStyles({ height: collapsedHeight, overflow: "hidden" }); }); }); } return () => { if (frameId.current) cancelAnimationFrame(frameId.current); if (endFrameId.current) clearAnimationTimeout(endFrameId.current); }; }, [ isExpanded, collapsedHeight, disableAnimation, duration, easing, onTransitionStateChange ]); return { isExpanded, setExpanded, getToggleProps(args) { const { disabled, onClick, refKey, ...rest } = { refKey: "ref", onClick() { }, disabled: false, ...args }; const isButton = toggleEl ? toggleEl.tagName === "BUTTON" : void 0; const theirRef = args?.[refKey || "ref"]; const props = { id: `react-collapsed-toggle-${uniqueId}`, "aria-controls": `react-collapsed-panel-${uniqueId}`, "aria-expanded": isExpanded, onClick(evt) { if (disabled) return; onClick?.(evt); setExpanded((n) => !n); }, [refKey || "ref"]: mergeRefs(theirRef, setToggleEl) }; const buttonProps = { type: "button", disabled: disabled ? true : void 0 }; const fakeButtonProps = { "aria-disabled": disabled ? true : void 0, role: "button", tabIndex: disabled ? -1 : 0 }; if (isButton === false) { return { ...props, ...fakeButtonProps, ...rest }; } else if (isButton === true) { return { ...props, ...buttonProps, ...rest }; } else { return { ...props, ...buttonProps, ...fakeButtonProps, ...rest }; } }, getCollapseProps(args) { const { style, refKey } = { refKey: "ref", style: {}, ...args }; const theirRef = args?.[refKey || "ref"]; return { id: `react-collapsed-panel-${uniqueId}`, "aria-hidden": !isExpanded, "aria-labelledby": `react-collapsed-toggle-${uniqueId}`, role: "region", ...args, [refKey || "ref"]: mergeRefs(collapseElRef, theirRef), style: { boxSizing: "border-box", ...!isAnimating && !isExpanded ? { // collapsed and not animating display: collapsedHeight === "0px" ? "none" : "block", height: collapsedHeight, overflow: "hidden" } : {}, // additional styles passed, e.g. getCollapseProps({style: {}}) ...style } }; } }; } export { useCollapse };