feat(ui): accordion component (#9537)

This commit is contained in:
Mislav Lukach 2025-07-08 16:57:31 +02:00 committed by GitHub
parent 9ee2f976a1
commit 2066f90654
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 339 additions and 2 deletions

View File

@ -1 +1 @@
1.2.16
1.2.17

View File

@ -9,7 +9,7 @@ bun install
To run storybook:
```bash
bun run sb
bun run --bun sb
```
This project was created using `bun init` in bun v1.2.16. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@ -723,6 +723,8 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"@testing-library/jest-dom/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
"@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],

View File

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Accordion, type AccordionProps } from "./Accordion";
import { useArray } from "../../shared/hooks/use-array";
import { Typography } from "../typography/Typography";
const meta = {
title: "Components/Accordion",
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
const AccordionComponent = ({ type }: { type: AccordionProps["type"] }) => {
const [keys, { replace }] = useArray(["foo"]);
return (
<div className="w-96">
<Accordion type={type} expandedKeys={keys} setExpandedKeys={replace}>
<Accordion.Item
value="foo"
label="file.txt"
icon={"FileEarmarkPlusFill"}
>
<Typography.Text>total 30</Typography.Text>
</Accordion.Item>
<Accordion.Item
value="bar"
label="foo.ext"
icon={"FileEarmarkPlusFill"}
>
<Typography.Text>total 60</Typography.Text>
</Accordion.Item>
<Accordion.Item
value="ipsum"
label="very_very_long_file_name_v3.pdf"
icon={"FileEarmarkPlusFill"}
>
<Typography.Text>total 90</Typography.Text>
</Accordion.Item>
</Accordion>
</div>
);
};
export const Multi: Story = {
render: () => <AccordionComponent type="multi" />,
};
export const Single: Story = {
render: () => <AccordionComponent type="single" />,
};

View File

@ -0,0 +1,67 @@
import React, { useCallback, type PropsWithChildren } from "react";
import {
AccordionItem,
type AccordionItemPropsPublic,
} from "./components/AccordionItem";
import { cn } from "../../shared/utils/cn";
import type { HTMLProps } from "../../shared/types";
export type AccordionProps = HTMLProps<"div"> & {
expandedKeys: string[];
type?: "multi" | "single";
setExpandedKeys(keys: string[]): void;
};
type AccordionType = React.FC<PropsWithChildren<AccordionProps>> & {
Item: React.FC<PropsWithChildren<AccordionItemPropsPublic>>;
};
const Accordion: AccordionType = ({
className,
expandedKeys,
setExpandedKeys,
children,
type = "multi",
...props
}) => {
const onChange = useCallback(
(key: string, expanded: boolean) => {
if (type === "multi") {
setExpandedKeys(
expanded
? [...expandedKeys, key]
: [...expandedKeys].filter((k) => k !== key)
);
} else {
setExpandedKeys(expanded ? [key] : []);
}
},
[expandedKeys, type]
);
const reactChildren = React.Children.toArray(children);
const items =
React.Children.map(reactChildren, (child: any) => {
const value = child.props.value;
const expanded = expandedKeys.some((key) => key === value);
return React.cloneElement(child, {
expanded,
onExpandedChange: () => onChange(value, !expanded),
className: "flex-1",
});
}) ?? [];
return (
<div
className={cn("flex flex-col gap-y-2.5 items-start", className)}
{...props}
>
{items}
</div>
);
};
Accordion.Item = AccordionItem as React.FC<
PropsWithChildren<AccordionItemPropsPublic>
>;
export { Accordion };

View File

@ -0,0 +1,78 @@
import type { PropsWithChildren } from "react";
import type { HTMLProps } from "../../../shared/types";
import { cn } from "../../../shared/utils/cn";
import { Icon, type IconProps } from "../../icon/Icon";
import { Typography } from "../../typography/Typography";
export type AccordionHeaderProps = Omit<
HTMLProps<"button">,
"aria-disabled" | "disabled"
> & {
icon: IconProps["icon"];
expanded: boolean;
};
export const AccordionHeader = ({
className,
children,
icon,
expanded,
...props
}: PropsWithChildren<AccordionHeaderProps>) => {
const iconCss = [
"shrink-0 text-light-neutral-500",
// expanded state
"group-data-[expanded=true]:text-light-neutral-15",
// hover modifier
"group-hover:text-light-neutral-15",
"group-focus:text-light-neutral-15",
];
return (
<button
{...props}
onBlur={() => console.log("blur")}
data-expanded={expanded}
className={cn(
"px-5.5 py-3.5 min-w-32 w-full",
"flex flex-row items-center gap-x-6 justify-between",
"group cursor-pointer",
// " focus:outline-0",
"ring-1 ring-solid ring-light-neutral-500",
expanded ? "rounded-t-2xl" : "rounded-2xl",
"data-[expanded=true]:bg-light-neutral-900 data-[expanded=false]:bg-grey-800",
// hover modifier
"data-[expanded=true]:hover:bg-light-neutral-900",
// focus modifier
"data-[expanded=false]:focus:bg-light-neutral-900"
)}
>
<Icon icon={icon} className={cn(iconCss, "w-6 h-6")} />
<Typography.Text
fontSize="m"
fontWeight={500}
className={cn(
"flex-1 text-left truncate",
"text-light-neutral-500",
// expanded state
"group-data-[expanded=true]:text-light-neutral-15",
// hover modifier
"group-hover:text-light-neutral-15",
// focus modifier
"group-focus:text-light-neutral-15"
)}
>
{children}
</Typography.Text>
<Icon
icon={"ChevronUp"}
className={cn(
iconCss,
"h-4 w-4",
"transition-transform duration-300",
expanded && `rotate-180`
)}
/>
</button>
);
};

View File

@ -0,0 +1,41 @@
import type { PropsWithChildren } from "react";
import type { HTMLProps } from "../../../shared/types";
import { cn } from "../../../shared/utils/cn";
import { type IconProps } from "../../icon/Icon";
import { AccordionHeader } from "./AccordionHeader";
import { AccordionPanel } from "./AccordionPanel";
export type AccordionItemProps = HTMLProps<"div"> & {
icon: IconProps["icon"];
expanded: boolean;
value: string;
label: React.ReactNode;
onExpandedChange(value: boolean): void;
};
export type AccordionItemPropsPublic = Omit<
AccordionItemProps,
"expanded" | "onExpandedChange"
>;
export const AccordionItem = ({
className,
children,
expanded,
icon,
label,
onExpandedChange,
...props
}: PropsWithChildren<AccordionItemProps>) => {
return (
<div {...props} className={cn("w-full", className)}>
<AccordionHeader
icon={icon}
expanded={expanded}
onClick={() => onExpandedChange(!expanded)}
>
{label}
</AccordionHeader>
<AccordionPanel expanded={expanded}>{children}</AccordionPanel>
</div>
);
};

View File

@ -0,0 +1,30 @@
import type { PropsWithChildren } from "react";
import type { HTMLProps } from "../../../shared/types";
import { cn } from "../../../shared/utils/cn";
export type AccordionPanelProps = Omit<HTMLProps<"div">, "aria-expanded"> & {
expanded: boolean;
};
export const AccordionPanel = ({
className,
children,
expanded,
...props
}: PropsWithChildren<AccordionPanelProps>) => {
return (
<div
aria-expanded={expanded}
className={cn(
"px-6 py-4",
"ring-1 ring-solid ring-light-neutral-500 bg-grey-800",
"rounded-b-2xl",
"aria-[expanded=false]:hidden",
className
)}
{...props}
>
{children}
</div>
);
};

View File

@ -0,0 +1,64 @@
import { useCallback, useState } from "react";
type ArrayActions<T> = {
push: (value: T | T[]) => void;
replace: (value: T | T[]) => void;
addAtIndex: (index: number, value: T) => void;
removeAt: (index: number) => void;
remove: (value: T, compareBy?: keyof T) => void;
subset: (indexStart: number, indexEnd: number) => void;
clear: () => void;
};
export function useArray<T>(initialValue: T | T[]): [T[], ArrayActions<T>] {
const [array, setArray] = useState<T[]>(
Array.isArray(initialValue) ? initialValue : [initialValue]
);
const push = useCallback((value: T | T[]) => {
const values = Array.isArray(value) ? value : [value];
setArray((prev) => [...prev, ...values]);
}, []);
const replace = useCallback((value: T | T[]) => {
setArray(Array.isArray(value) ? value : [value]);
}, []);
const addAtIndex = useCallback((index: number, value: T) => {
setArray((prev) => [
...prev.slice(0, index + 1),
value,
...prev.slice(index + 1),
]);
}, []);
const removeAt = useCallback((index: number) => {
setArray((prev) => [...prev.slice(0, index), ...prev.slice(index + 1)]);
}, []);
const remove = useCallback((value: T, compareBy?: keyof T) => {
setArray((prev) => {
const index = prev.findIndex((item) =>
compareBy
? isEqual(item[compareBy], value[compareBy])
: isEqual(item, value)
);
return index >= 0
? [...prev.slice(0, index), ...prev.slice(index + 1)]
: prev;
});
}, []);
const subset = useCallback((indexStart: number, indexEnd: number) => {
setArray((prev) => prev.slice(indexStart, indexEnd + 1));
}, []);
const clear = useCallback(() => {
setArray([]);
}, []);
return [
array,
{ push, replace, addAtIndex, removeAt, remove, subset, clear },
];
}