Skip to content

Commit

Permalink
Merge pull request #35 from babylonlabs-io/accordion
Browse files Browse the repository at this point in the history
feat: Accordion
  • Loading branch information
0xDazzer authored Nov 24, 2024
2 parents b0ca4ad + b8827cd commit 866441b
Show file tree
Hide file tree
Showing 9 changed files with 258 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/grumpy-trains-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@babylonlabs-io/bbn-core-ui": minor
---

add Accordion
27 changes: 27 additions & 0 deletions src/components/Accordion/Accordion.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
35 changes: 35 additions & 0 deletions src/components/Accordion/Accordion.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Accordion> = {
component: Accordion,
tags: ["autodocs"],
};

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
className: "b-text-primary",
},
render: (props) => (
<Accordion {...props}>
<AccordionSummary
className="b-p-2"
renderIcon={(expanded) => (expanded ? <AiOutlineMinus size={24} /> : <AiOutlinePlus size={24} />)}
>
<Heading variant="h6">How does Bitcoin Staking work?</Heading>
</AccordionSummary>

<AccordionDetails className="b-p-2" unmountOnExit>
<Text>I don't know</Text>
</AccordionDetails>
</Accordion>
),
};
59 changes: 59 additions & 0 deletions src/components/Accordion/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -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<AccordionContext>({
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<AccordionProps>) {
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 (
<Context.Provider value={context}>
<div className={twJoin("bbn-accordion", disabled && "bbn-accordion-disabled", className)}>{children}</div>
</Context.Provider>
);
}
62 changes: 62 additions & 0 deletions src/components/Accordion/components/AccordionDetails.tsx
Original file line number Diff line number Diff line change
@@ -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<AccordionDetailsProps>) {
const { expanded } = useContext(Context);
const [visible, setVisibility] = useState(expanded);
const mounted = visible || !unmountOnExit;
const { height, ref: contentRef } = useHeightObserver<HTMLDivElement>(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 (
<div
className={twJoin("bbn-accordion-details", wrapperClassName)}
style={{
height: expanded ? height : "0px",
visibility: visible ? "visible" : "hidden",
transitionDuration: `${animationDuration}ms`,
}}
>
{mounted ? (
<div ref={contentRef} className={twJoin("bbn-accordion-content", className)}>
{children}
</div>
) : null}
</div>
);
}
28 changes: 28 additions & 0 deletions src/components/Accordion/components/AccordionSummary.tsx
Original file line number Diff line number Diff line change
@@ -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<AccordionSummaryProps>) {
const { expanded, toggle } = useContext(Context);

const icon = renderIcon(expanded);
return (
<div className={twJoin("bbn-accordion-summary", className)} onClick={toggle}>
{children}
{icon && <IconButton className={twJoin("bbn-accordion-icon", iconClassName)}>{icon}</IconButton>}
</div>
);
}
36 changes: 36 additions & 0 deletions src/components/Accordion/hooks/useHeightObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { toPixels } from "@/utils/css";
import { useEffect, useRef, useState } from "react";

export function useHeightObserver<E extends HTMLElement>(enabled = true) {
const [height, setHeight] = useState("0px");
const [observer, setObserver] = useState<ResizeObserver>();

const contentRef = useRef<E>(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 };
}
5 changes: 5 additions & 0 deletions src/components/Accordion/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import "./Accordion.css";

export { Accordion, type AccordionProps } from "./Accordion";
export * from "./components/AccordionSummary";
export * from "./components/AccordionDetails";
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "./index.css";

export * from "./components/Accordion";
export * from "./components/Text";
export * from "./components/Heading";
export * from "./components/Button";
Expand Down

0 comments on commit 866441b

Please sign in to comment.