Skip to content

Commit 1bd9a4c

Browse files
committed
feat: custom-accordion-expand-button
1 parent e0363a9 commit 1bd9a4c

File tree

5 files changed

+290
-41
lines changed

5 files changed

+290
-41
lines changed

src/lib/accordion/accordion-item.tsx

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,99 @@
1-
import React, { ReactNode } from "react";
1+
import React, { ReactNode, useMemo } from "react";
22
import { useElementSize } from "../../hooks/useElementSize";
33
import Plus from "../../assets/svgs/accordion/plus.svg";
44
import Minus from "../../assets/svgs/accordion/minus.svg";
55

66
import { Button } from "react-aria-components";
77
import { cn } from "../../utils";
88

9-
interface AccordionItemProps {
9+
export interface AccordionItemProps {
1010
setExpanded: React.Dispatch<React.SetStateAction<number>>;
1111
index: number;
12+
/**
13+
* The title displayed in the accordion header.
14+
*
15+
* This is usually text, but can be any ReactNode.
16+
*/
1217
title: ReactNode;
18+
/**
19+
* The body/content of the accordion section.
20+
* This is only visible when the item is expanded.
21+
*/
1322
body: ReactNode;
14-
expanded?: boolean;
23+
/**
24+
* Custom render function for the expand/collapse button.
25+
*
26+
* This function receives:
27+
* - `expanded`: boolean => whether the item is currently expanded
28+
* - `toggle`: function => expands/collapses this item when called
29+
*
30+
* Example:
31+
* ```
32+
* expandButton={({ expanded, toggle }) => (
33+
* <button onClick={toggle}>
34+
* {expanded ? "-" : "+"}
35+
* </button>
36+
* )}
37+
* ```
38+
*
39+
* If not provided, a default + or − icon will be shown.
40+
*
41+
* This button does NOT need to manage its own state — calling `toggle()`
42+
* properly expands/collapses the item.
43+
*/
44+
expandButton?: (options: {
45+
expanded: boolean;
46+
toggle: () => void;
47+
}) => ReactNode;
48+
expanded: boolean;
1549
}
1650

1751
const AccordionItem: React.FC<AccordionItemProps> = ({
1852
title,
1953
body,
54+
expandButton,
2055
index,
2156
expanded,
2257
setExpanded,
2358
}) => {
2459
const [ref, { height }] = useElementSize();
60+
const ExpandButton = useMemo(
61+
() =>
62+
expandButton ? (
63+
expandButton({
64+
expanded,
65+
toggle: () => setExpanded(expanded ? -1 : index),
66+
})
67+
) : expanded ? (
68+
<Minus
69+
className={cn("fill-klerosUIComponentsPrimaryText size-4 shrink-0")}
70+
/>
71+
) : (
72+
<Plus
73+
className={cn("fill-klerosUIComponentsPrimaryText size-4 shrink-0")}
74+
/>
75+
),
76+
[expanded, expandButton, index, setExpanded],
77+
);
2578
return (
2679
<div className="my-2">
2780
<Button
2881
id="expand-button"
82+
aria-expanded={expanded}
2983
className={cn(
3084
"bg-klerosUIComponentsWhiteBackground border-klerosUIComponentsStroke border",
3185
"hover-medium-blue hover-short-transition hover:cursor-pointer",
3286
"rounded-[3px] px-4 py-[11.5px] md:px-8",
33-
"flex w-full items-center justify-between",
87+
"flex w-full items-center justify-between gap-4",
3488
)}
3589
onPress={() => setExpanded(expanded ? -1 : index)}
3690
>
3791
{title}
38-
{expanded ? (
39-
<Minus
40-
className={cn("fill-klerosUIComponentsPrimaryText size-4 shrink-0")}
41-
/>
42-
) : (
43-
<Plus
44-
className={cn("fill-klerosUIComponentsPrimaryText size-4 shrink-0")}
45-
/>
46-
)}
92+
{ExpandButton}
4793
</Button>
4894
<div
4995
style={{ height: expanded ? `${height.toString()}px` : 0 }}
50-
className={cn(
51-
expanded ? `overflow-visible` : "overflow-hidden",
52-
"transition-[height] duration-(--klerosUIComponentsTransitionSpeed) ease-initial",
53-
)}
96+
className="overflow-hidden transition-[height] duration-(--klerosUIComponentsTransitionSpeed) ease-in-out"
5497
>
5598
<div className="p-4 md:p-8" id="body-wrapper" ref={ref}>
5699
{body}

src/lib/accordion/custom.tsx

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,69 @@
1-
import React, { ReactNode, useState } from "react";
2-
import AccordionItem from "./accordion-item";
1+
import React, { useState } from "react";
2+
import AccordionItem, { AccordionItemProps } from "./accordion-item";
33
import { cn, isUndefined } from "../../utils";
44

5-
interface AccordionItem {
6-
title: ReactNode;
7-
body: ReactNode;
8-
}
5+
export interface CustomAccordionProps {
6+
/**
7+
* Array of accordion items.
8+
*
9+
* Each item can optionally define its own `expandButton`.
10+
* If omitted, the parent-level `expandButton` (if provided) is used.
11+
*/
912

10-
interface AccordionProps {
11-
items: AccordionItem[];
13+
items: Pick<AccordionItemProps, "title" | "body" | "expandButton">[];
1214
className?: string;
15+
16+
/**
17+
* Index of the item to expand by default.
18+
*
19+
* - Set to a number (0-based index) to expand an item on mount
20+
* - Leave undefined to start with all items collapsed
21+
*/
22+
1323
defaultExpanded?: number;
24+
/**
25+
* A global expand/collapse button renderer applied to every item
26+
* **unless that item provides its own expandButton**.
27+
*
28+
* Signature:
29+
* ```
30+
* expandButton?: ({ expanded, toggle }) => ReactNode;
31+
* ```
32+
*
33+
* Example:
34+
* ```
35+
* expandButton={({ expanded, toggle }) => (
36+
* <Button onPress={toggle}>
37+
* {expanded ? <ChevronUp /> : <ChevronDown />}
38+
* <Button>
39+
* )}
40+
* ```
41+
*/
42+
expandButton?: AccordionItemProps["expandButton"];
1443
}
1544

16-
const CustomAccordion: React.FC<AccordionProps> = ({
45+
/**
46+
* @description This component manages a list of collapsible accordion items,
47+
* where only one item can be expanded at a time.
48+
* @param props - CustomAccordionProps
49+
* @returns JSX.Element
50+
*/
51+
function CustomAccordion({
1752
items,
1853
className,
1954
defaultExpanded,
55+
expandButton,
2056
...props
21-
}) => {
57+
}: Readonly<CustomAccordionProps>) {
2258
const [expanded, setExpanded] = useState(
2359
!isUndefined(defaultExpanded) ? defaultExpanded : -1,
2460
);
2561
return (
2662
<div
27-
className={cn("box-border flex w-[1000px] flex-col", className)}
63+
className={cn(
64+
"box-border flex w-full max-w-[1000px] flex-col",
65+
className,
66+
)}
2867
{...props}
2968
>
3069
{items.map((item, index) => (
@@ -33,12 +72,13 @@ const CustomAccordion: React.FC<AccordionProps> = ({
3372
index={index}
3473
title={item.title}
3574
body={item.body}
75+
expandButton={item.expandButton ?? expandButton}
3676
setExpanded={setExpanded}
3777
expanded={expanded === index}
3878
/>
3979
))}
4080
</div>
4181
);
42-
};
82+
}
4383

4484
export default CustomAccordion;

src/lib/accordion/index.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,38 +2,56 @@ import React, { ReactNode, useState } from "react";
22
import AccordionItem from "./accordion-item";
33
import { cn, isUndefined } from "../../utils";
44

5-
interface AccordionItem {
5+
export interface AccordionItemProps {
66
title: string;
77
body: ReactNode;
88
Icon?: React.FC<React.SVGAttributes<SVGElement>>;
99
icon?: ReactNode;
1010
}
1111

12-
interface AccordionProps {
13-
items: AccordionItem[];
12+
export interface AccordionProps {
13+
/**
14+
* Array of accordion items.
15+
*/
16+
items: AccordionItemProps[];
17+
/**
18+
* Index of the item to expand by default.
19+
*
20+
* - Set to a number (0-based index) to expand an item on mount
21+
* - Leave undefined to start with all items collapsed
22+
*/
1423
defaultExpanded?: number;
1524
className?: string;
1625
}
1726

18-
const DefaultTitle: React.FC<{ item: AccordionItem }> = ({ item }) => (
27+
const DefaultTitle: React.FC<{ item: AccordionItemProps }> = ({ item }) => (
1928
<>
2029
{item.icon ?? (item.Icon && <item.Icon />)}
2130
<p className="w-fit text-center text-base font-semibold">{item.title}</p>
2231
</>
2332
);
2433

25-
const Accordion: React.FC<AccordionProps> = ({
34+
/**
35+
* @description This component manages a list of collapsible accordion items,
36+
* where only one item can be expanded at a time.
37+
* @param props - AccordionProps
38+
* @returns JSX.Element
39+
*/
40+
function Accordion({
2641
items,
2742
defaultExpanded,
2843
className,
2944
...props
30-
}) => {
45+
}: Readonly<AccordionProps>) {
3146
const [expanded, setExpanded] = useState(
3247
!isUndefined(defaultExpanded) ? defaultExpanded : -1,
3348
);
3449
return (
3550
<div
36-
className={cn("box-border flex w-[1000px] flex-col", className)}
51+
className={cn(
52+
"box-border flex w-full max-w-[1000px] flex-col",
53+
className,
54+
)}
3755
{...props}
3856
>
3957
{items.map((item, index) => (
@@ -48,6 +66,6 @@ const Accordion: React.FC<AccordionProps> = ({
4866
))}
4967
</div>
5068
);
51-
};
69+
}
5270

5371
export default Accordion;

src/stories/accordion.stories.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,19 @@ import type { Meta, StoryObj } from "@storybook/react";
33

44
import { IPreviewArgs } from "./utils";
55

6-
import Accordion from "../lib/accordion/index";
6+
import AccordionComponent from "../lib/accordion/index";
77

88
const meta = {
9-
component: Accordion,
9+
component: AccordionComponent,
1010
title: "Accordion",
1111
tags: ["autodocs"],
12-
} satisfies Meta<typeof Accordion>;
12+
} satisfies Meta<typeof AccordionComponent>;
1313

1414
export default meta;
1515

1616
type Story = StoryObj<typeof meta> & IPreviewArgs;
1717

18-
export const DarkTheme: Story = {
18+
export const Accordion: Story = {
1919
args: {
2020
className: "max-w-[80dvw]",
2121

0 commit comments

Comments
 (0)