Skip to content

Commit

Permalink
feat: add accordion component (#83)
Browse files Browse the repository at this point in the history
Signed-off-by: Lukas.J.Han <[email protected]>
  • Loading branch information
lukasjhan authored Aug 20, 2024
1 parent d657f38 commit e6f5861
Show file tree
Hide file tree
Showing 5 changed files with 237 additions and 0 deletions.
91 changes: 91 additions & 0 deletions packages/core/lib/components/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React, { useState, useRef } from 'react';
import { Label } from './Label';

const ChevronIcon: React.FC<{ isOpen: boolean }> = ({ isOpen }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
className={`transition-transform duration-300 ${isOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
>
<path
d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"
fill="currentColor"
/>
</svg>
);

interface AccordionItemProps {
title: string;
children: React.ReactNode;
isOpen: boolean;
onClick: () => void;
}

const AccordionItem: React.FC<AccordionItemProps> = ({
title,
children,
isOpen,
onClick,
}) => {
const contentRef = useRef<HTMLDivElement>(null);
const buttonId = `accordion-button-${title.replace(/\s+/g, '-').toLowerCase()}`;
const contentId = `accordion-content-${title.replace(/\s+/g, '-').toLowerCase()}`;

return (
<div className="w-full border-b border-gray-20">
<button
className="w-full text-left py-6 px-6 flex justify-between items-center focus:outline-none focus:ring-2 focus:ring-primary-50"
onClick={onClick}
aria-expanded={isOpen}
aria-controls={contentId}
>
<Label size="l" weight="bold" className="cursor-pointer">
{title}
</Label>
<span className="ml-6 flex-shrink-0">
<ChevronIcon isOpen={isOpen} />
</span>
<span className="sr-only">{isOpen ? '접기' : '펼치기'}</span>
</button>
<div
ref={contentRef}
role="region"
aria-labelledby={buttonId}
className="overflow-hidden transition-all duration-300 ease-in-out"
style={{
maxHeight: isOpen ? `${contentRef.current?.scrollHeight}px` : '0px',
}}
>
<div className="p-6">{children}</div>
</div>
</div>
);
};

interface AccordionProps {
items: Omit<AccordionItemProps, 'isOpen' | 'onClick'>[];
}

export const Accordion: React.FC<AccordionProps> = ({ items }) => {
const [openIndex, setOpenIndex] = useState<number | null>(null);

const handleItemClick = (index: number) => {
setOpenIndex((prevIndex) => (prevIndex === index ? null : index));
};

return (
<div className="border-t border-gray-20 overflow-hidden">
{items.map((item, index) => (
<AccordionItem
key={index}
{...item}
isOpen={openIndex === index}
onClick={() => handleItemClick(index)}
/>
))}
</div>
);
};
66 changes: 66 additions & 0 deletions packages/core/lib/components/Disclosure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React, { useState, useRef } from 'react';
import { Label } from './Label';

const ChevronIcon: React.FC<{ isOpen: boolean }> = ({ isOpen }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
className={`transition-transform duration-300 ${isOpen ? 'rotate-90' : ''}`}
aria-hidden="true"
>
<path
d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"
fill="currentColor"
/>
</svg>
);

interface DisclosureProps {
title: string;
children: React.ReactNode;
}

export const Disclosure: React.FC<DisclosureProps> = ({ title, children }) => {
const [isOpen, setIsOpen] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const buttonId = `disclosure-button-${title.replace(/\s+/g, '-').toLowerCase()}`;
const contentId = `disclosure-content-${title.replace(/\s+/g, '-').toLowerCase()}`;

const toggleDisclosure = () => {
setIsOpen(!isOpen);
};

return (
<div className="w-full">
<button
id={buttonId}
className="text-left py-4 flex items-center focus:outline-none focus:ring-2 focus:ring-primary-50"
onClick={toggleDisclosure}
aria-expanded={isOpen}
aria-controls={contentId}
>
<span className="mr-4 flex-shrink-0">
<ChevronIcon isOpen={isOpen} />
</span>
<Label size="m" className="cursor-pointer">
{title}
</Label>
<span className="sr-only">{isOpen ? '접기' : '펼치기'}</span>
</button>
<div
id={contentId}
ref={contentRef}
role="region"
aria-labelledby={buttonId}
className="overflow-hidden transition-all duration-300 ease-in-out pl-10"
style={{
maxHeight: isOpen ? `${contentRef.current?.scrollHeight}px` : '0px',
}}
>
<div className="py-4">{children}</div>
</div>
</div>
);
};
4 changes: 4 additions & 0 deletions packages/core/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { Checkbox } from './components/Checkbox';
import { RadioButtonGroup } from './components/RadioButton';
import { Tabs } from './components/Tab';
import { Modal } from './components/Modal';
import { Accordion } from './components/Accordion';
import { Disclosure } from './components/Disclosure';

export { Display, Heading, Title, Body, Detail, Label, Link, colors };
export {
Expand All @@ -39,4 +41,6 @@ export {
Breadcrumb,
Tabs,
Modal,
Accordion,
Disclosure,
};
41 changes: 41 additions & 0 deletions stories/core/Accordion.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { Accordion } from '../../packages/core/lib';

const meta = {
title: 'Components/Accordion',
component: Accordion,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
items: {
control: {
type: 'object',
},
},
},
} satisfies Meta<typeof Accordion>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
items: [
{
title: 'Accordion Item 1',
children: 'Accordion Item 1 Content',
},
{
title: 'Accordion Item 2',
children: 'Accordion Item 2 Content',
},
{
title: 'Accordion Item 3',
children: 'Accordion Item 3 Content',
},
],
},
};
35 changes: 35 additions & 0 deletions stories/core/Disclosure.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { Disclosure } from '../../packages/core/lib';

const meta = {
title: 'Components/Disclosure',
component: Disclosure,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
title: {
control: {
type: 'text',
},
},
children: {
control: {
type: 'object',
},
},
},
} satisfies Meta<typeof Disclosure>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
title: 'Disclosure Title',
children:
'Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content Disclosure Content',
},
};

0 comments on commit e6f5861

Please sign in to comment.