import React, { useId } from "react"; import classNames from "classnames"; import { animated, useSpring } from "@react-spring/web"; import useComponentSize from "@rehooks/component-size"; import mergeRefs from "react-merge-refs"; import { bemHOF } from "../../utilities"; import { ORIENTATION } from "../../types"; import { BoxProps, Box } from "../Box"; import { Flex } from "../Flex"; import { Stack, StackProps } from "../Stack"; import { Heading } from "../Heading"; import { Icon, IconProps, ICON_TYPE } from "../Icon"; import type { AccordionOwnProps, AccordionItemOwnProps, AccordionDisclosureProps, AccordionGroupOwnProps, } from "./types"; import { AccordionItemContext, useAccordionItemContext, AccordionGroupContext, useAccordionGroupContext, } from "./context"; const cn = bemHOF("Accordion"); /** * Accordion.Group * ============================================================================= */ type AccordionGroupChildProps = { currentValue: string }; type AccordionGroupChildFunction = { children(props: AccordionGroupChildProps): React.ReactElement; }; type AccordionGroupChildrenUnionType = | AccordionGroupChildFunction | { children: React.ReactNode }; export type AccordionGroupProps = AccordionGroupOwnProps & AccordionGroupChildrenUnionType & Pick; export const AccordionGroup = React.forwardRef< HTMLDivElement, AccordionGroupProps >(function AccordionGroup(props, ref) { const { children, spacing = 0, defaultValue, disableAnimation, allowMultiple = true, allowToggle = true, className, ...rest } = props; const totalItems = React.Children.count(children); const [currentValue, setCurrentValue] = React.useState(defaultValue || ""); const context = React.useMemo( () => ({ defaultValue, disableAnimation, totalItems, currentValue, setCurrentValue, allowMultiple, allowToggle, }), [ defaultValue, disableAnimation, totalItems, currentValue, allowMultiple, allowToggle, ], ); return ( {typeof children === "function" ? children({ currentValue }) : children} ); }); /** * Accordion.Item * ============================================================================= */ export type AccordionItemProps = AccordionItemOwnProps & BoxProps; export const AccordionItem = React.forwardRef< HTMLDivElement, AccordionItemProps >(function AccordionItem(props, ref) { const { value, defaultIsOpen: defaultIsOpenProp, children, className, ...rest } = props; const { defaultValue, currentValue, setCurrentValue, allowMultiple, allowToggle, } = useAccordionGroupContext(); /** * To remain backwards compatible with the `defaultIsOpen` prop, we need to * perform an extra conditional check to determine if `defaultIsOpenProp` * has been defined. */ const hasDefaultValue = typeof defaultValue === "undefined" ? false : defaultValue === value; const defaultIsOpen = typeof defaultIsOpenProp === "undefined" ? hasDefaultValue : defaultIsOpenProp; const [isOpen, setIsOpen] = React.useState(defaultIsOpen); const onClose = React.useCallback(() => setIsOpen(false), []); const onOpen = React.useCallback(() => setIsOpen(true), []); const onToggle = React.useCallback(() => setIsOpen((prev) => !prev), []); React.useEffect(() => { setIsOpen(defaultIsOpen); }, [defaultIsOpen]); const id = `accordion-${useId()}`; const buttonId = `${id}-button`; const contentId = `${id}-content`; const context = React.useMemo( () => ({ defaultIsOpen, isOpen, onToggle, onClose, onOpen, buttonId, contentId, value, currentValue, setCurrentValue, allowMultiple, allowToggle, }), [ defaultIsOpen, isOpen, onToggle, onClose, onOpen, buttonId, contentId, value, currentValue, setCurrentValue, allowMultiple, allowToggle, ], ); React.useEffect(() => { if (!allowMultiple && currentValue !== value) { onClose(); } }, [allowMultiple, currentValue, value, onClose]); return ( {children} ); }); /** * Accordion.Button * ============================================================================= */ type AccordionButtonChildProps = Omit; declare type AccordionButtonOwnProps = { children: | React.ReactNode | ((props: AccordionButtonChildProps) => React.ReactNode); }; export interface AccordionButtonProps extends AccordionButtonOwnProps, Omit, "children"> {} export const AccordionButton = React.forwardRef< HTMLButtonElement, AccordionButtonProps >(function AccordionButton(props, forwardedRef) { const { children, className, onClick, disabled = false, ...rest } = props; const { buttonId, contentId, isOpen, onToggle, onClose, onOpen, value, setCurrentValue, allowToggle, } = useAccordionItemContext(); const buttonRef = React.useRef(null); const ref = mergeRefs([forwardedRef, buttonRef]); return ( ); }); /** * Accordion.Icon * ============================================================================= */ export type AccordionIconProps = { className?: string; /** * The initial direction that the chevron should point; when open, it will * point down, unless direction=down, then it will point up */ direction?: "up" | "down" | "left" | "right"; /** * Prevent the icon from rotating when the accordion is opened or closed; the * direction will always be what is specified in the `direction` prop */ disabled?: boolean; } & Pick; export const AccordionIcon = React.forwardRef< HTMLSpanElement, AccordionIconProps >(function AccordionIcon(props, ref) { const { className, direction = "down", color, disabled = false } = props; const icon = { up: ICON_TYPE.CHEVRON_UP, down: ICON_TYPE.CHEVRON_DOWN, left: ICON_TYPE.CHEVRON_LEFT, right: ICON_TYPE.CHEVRON_RIGHT, }; return ( ); }); /** * Accordion.Content * ============================================================================= */ export type AccordionContentProps = { children: React.ReactNode; maxHeight?: string; style?: React.CSSProperties; className?: string; scrollClassName?: string; }; export const AccordionContent = React.forwardRef< HTMLDivElement, AccordionContentProps >(function AccordionContent(props, ref) { const { children, maxHeight, style, className, scrollClassName, ...rest } = props; const { disableAnimation } = useAccordionGroupContext(); const { buttonId, contentId, defaultIsOpen, isOpen, } = useAccordionItemContext(); const [childrenRef, setChildrenRef] = React.useState< React.MutableRefObject >({ current: null }); const onRefChange = React.useCallback((node: any) => { if (node !== null) { setChildrenRef({ current: node }); } }, []); const [enableTransition, setEnableTransition] = React.useState( !defaultIsOpen, ); const { height } = useComponentSize(childrenRef); const springProps = useSpring({ height: isOpen ? height : 0, opacity: isOpen ? 1 : 0, config: { mass: 2, tension: 400, friction: 50 }, onRest: () => { if (defaultIsOpen && isOpen) { setEnableTransition(true); } }, immediate: disableAnimation || !enableTransition, }); return ( {children} ); }); /** * Accordion * ============================================================================= */ export type AccordionProps = AccordionOwnProps & BoxProps; export const Accordion = (props: AccordionProps) => { const { title, isActive, maxHeight, children, iconSlot, className, removePadding, ...rest } = props; return ( {iconSlot && ( {iconSlot} )} {title} {children} ); }; Accordion.Group = AccordionGroup; Accordion.Item = AccordionItem; Accordion.Button = AccordionButton; Accordion.Icon = AccordionIcon; Accordion.Content = AccordionContent;