diff --git a/.changeset/grumpy-trains-remember.md b/.changeset/grumpy-trains-remember.md new file mode 100644 index 0000000..4387644 --- /dev/null +++ b/.changeset/grumpy-trains-remember.md @@ -0,0 +1,5 @@ +--- +"@babylonlabs-io/bbn-core-ui": minor +--- + +add Accordion diff --git a/src/components/Accordion/Accordion.css b/src/components/Accordion/Accordion.css new file mode 100644 index 0000000..b10a2ba --- /dev/null +++ b/src/components/Accordion/Accordion.css @@ -0,0 +1,27 @@ +.bbn-accordion { + @apply b-transition-opacity; + + &-summary { + @apply b-cursor-pointer b-relative b-pr-5 b-transition-colors; + } + + &-details { + @apply b-overflow-hidden b-transition-all; + } + + &-content { + @apply b-flex b-w-full; + } + + &-icon { + @apply b-absolute b-right-0 b-top-1/2 -b-translate-y-1/2 b-transition-transform b-duration-200; + } + + &-disabled { + @apply b-opacity-40; + + & .sup-accordion-summary { + @apply b-cursor-default; + } + } +} diff --git a/src/components/Accordion/Accordion.stories.tsx b/src/components/Accordion/Accordion.stories.tsx new file mode 100644 index 0000000..40eaa73 --- /dev/null +++ b/src/components/Accordion/Accordion.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AiOutlinePlus, AiOutlineMinus } from "react-icons/ai"; + +import { Accordion, AccordionSummary, AccordionDetails } from "./"; +import { Heading } from "../Heading"; +import { Text } from "../Text"; + +const meta: Meta = { + component: Accordion, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + className: "b-text-primary", + }, + render: (props) => ( + + (expanded ? : )} + > + How does Bitcoin Staking work? + + + + I don't know + + + ), +}; diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx new file mode 100644 index 0000000..753437e --- /dev/null +++ b/src/components/Accordion/Accordion.tsx @@ -0,0 +1,59 @@ +import { type PropsWithChildren, useCallback, useMemo, createContext } from "react"; +import { twJoin } from "tailwind-merge"; + +import { useControlledState } from "@/hooks/useControlledState"; + +interface AccordionContext { + defaultExpanded?: boolean; + disabled: boolean; + expanded: boolean; + toggle?: () => void; +} + +export const Context = createContext({ + disabled: false, + expanded: false, +}); + +export interface AccordionProps { + expanded?: boolean; + defaultExpanded?: boolean; + onChange?: (expanded: boolean) => void; + className?: string; + disabled?: boolean; +} + +export function Accordion({ + expanded: isExpanded, + defaultExpanded, + onChange, + className, + disabled = false, + children, +}: PropsWithChildren) { + const [expanded = false, setExpanded] = useControlledState({ + value: isExpanded, + defaultValue: defaultExpanded, + onStateChange: onChange, + }); + + const handleToggle = useCallback(() => { + setExpanded(!expanded); + }, [setExpanded, expanded]); + + const context = useMemo( + () => ({ + defaultExpanded, + disabled, + expanded: expanded && !disabled, + toggle: !disabled ? handleToggle : undefined, + }), + [defaultExpanded, expanded, disabled, handleToggle], + ); + + return ( + +
{children}
+
+ ); +} diff --git a/src/components/Accordion/components/AccordionDetails.tsx b/src/components/Accordion/components/AccordionDetails.tsx new file mode 100644 index 0000000..f2ad86b --- /dev/null +++ b/src/components/Accordion/components/AccordionDetails.tsx @@ -0,0 +1,62 @@ +import { type PropsWithChildren, useContext, useEffect, useState } from "react"; +import { twJoin } from "tailwind-merge"; + +import { Context } from "../Accordion"; +import { useHeightObserver } from "../hooks/useHeightObserver"; + +interface AccordionDetailsProps { + className?: string; + wrapperClassName?: string; + unmountOnExit?: boolean; + animationDuration?: number; +} + +export function AccordionDetails({ + unmountOnExit = false, + animationDuration = 200, + className, + wrapperClassName, + children, +}: PropsWithChildren) { + const { expanded } = useContext(Context); + const [visible, setVisibility] = useState(expanded); + const mounted = visible || !unmountOnExit; + const { height, ref: contentRef } = useHeightObserver(mounted); + + useEffect( + function changeContentVisibility() { + if (expanded === visible) { + return; + } + + if (expanded) { + setVisibility(true); + return; + } + + const timer = setTimeout(setVisibility, animationDuration, false); + + return () => { + clearTimeout(timer); + }; + }, + [expanded, visible, animationDuration], + ); + + return ( +
+ {mounted ? ( +
+ {children} +
+ ) : null} +
+ ); +} diff --git a/src/components/Accordion/components/AccordionSummary.tsx b/src/components/Accordion/components/AccordionSummary.tsx new file mode 100644 index 0000000..2c8b4af --- /dev/null +++ b/src/components/Accordion/components/AccordionSummary.tsx @@ -0,0 +1,28 @@ +import { type PropsWithChildren, type ReactNode, useContext } from "react"; +import { twJoin } from "tailwind-merge"; + +import { IconButton } from "@/components/Button"; +import { Context } from "../Accordion"; + +interface AccordionSummaryProps { + renderIcon?: (expanded: boolean) => ReactNode; + className?: string; + iconClassName?: string; +} + +export function AccordionSummary({ + className, + iconClassName, + children, + renderIcon = () => null, +}: PropsWithChildren) { + const { expanded, toggle } = useContext(Context); + + const icon = renderIcon(expanded); + return ( +
+ {children} + {icon && {icon}} +
+ ); +} diff --git a/src/components/Accordion/hooks/useHeightObserver.ts b/src/components/Accordion/hooks/useHeightObserver.ts new file mode 100644 index 0000000..d6baef2 --- /dev/null +++ b/src/components/Accordion/hooks/useHeightObserver.ts @@ -0,0 +1,36 @@ +import { toPixels } from "@/utils/css"; +import { useEffect, useRef, useState } from "react"; + +export function useHeightObserver(enabled = true) { + const [height, setHeight] = useState("0px"); + const [observer, setObserver] = useState(); + + const contentRef = useRef(null); + + useEffect(() => { + const observer = new ResizeObserver((entries) => { + const [entry] = entries; + + setHeight(toPixels(entry.target.clientHeight) ?? "0px"); + }); + + setObserver(observer); + }, []); + + useEffect( + function updateAccordionHeightOnContentChange() { + const { current: content } = contentRef; + + if (!enabled || !content) return; + + observer?.observe(content); + + return () => { + observer?.unobserve(content); + }; + }, + [enabled, observer], + ); + + return { height, ref: contentRef }; +} diff --git a/src/components/Accordion/index.ts b/src/components/Accordion/index.ts new file mode 100644 index 0000000..5dae61e --- /dev/null +++ b/src/components/Accordion/index.ts @@ -0,0 +1,5 @@ +import "./Accordion.css"; + +export { Accordion, type AccordionProps } from "./Accordion"; +export * from "./components/AccordionSummary"; +export * from "./components/AccordionDetails"; diff --git a/src/index.tsx b/src/index.tsx index 6cd7022..7d84175 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,6 @@ import "./index.css"; +export * from "./components/Accordion"; export * from "./components/Text"; export * from "./components/Heading"; export * from "./components/Button";