diff --git a/src/shared/assets/icons/common/ic-alt-arrow-down.svg b/src/shared/assets/icons/common/ic-alt-arrow-down.svg index ed0825f..14f21d0 100644 --- a/src/shared/assets/icons/common/ic-alt-arrow-down.svg +++ b/src/shared/assets/icons/common/ic-alt-arrow-down.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/shared/ui/accordion/Accordion.stories.tsx b/src/shared/ui/accordion/Accordion.stories.tsx new file mode 100644 index 0000000..b7c5f17 --- /dev/null +++ b/src/shared/ui/accordion/Accordion.stories.tsx @@ -0,0 +1,314 @@ +import { useState } from 'react'; + +import { Button } from '@/shared/ui/button'; +import { Input } from '@/shared/ui/input'; +import { TextArea } from '@/shared/ui/textarea'; + +import { Accordion } from './index'; + +import type { Meta, StoryObj } from '@storybook/nextjs'; + +interface AccordionPlaygroundProps { + disabled: boolean; +} + +interface ExclusiveAccordionExampleProps { + disabled?: boolean; +} + +function ExclusiveAccordionExample({ disabled = false }: ExclusiveAccordionExampleProps) { + const [openValue, setOpenValue] = useState('first'); + + const handleOpenChange = (value: string) => (open: boolean) => { + setOpenValue(open ? value : undefined); + }; + + return ( +
+ + +
+ + 첫 번째 섹션 +
+
+ +

처음에는 이 Accordion만 열린 상태입니다.

+
+
+ + + +
+ + 두 번째 섹션 +
+
+ +

+ 이 Accordion을 열면 첫 번째 Accordion은 닫힙니다. +

+
+
+ + + +
+ + 세 번째 섹션 +
+
+ +

+ 독립된 Accordion들이 하나의 exclusive 그룹처럼 동작합니다. +

+
+
+
+ ); +} + +function CoverLetterInputSections() { + return ( + + + +
+ + + 질문 + +
+
+ + 질문을 입력해 주세요. +
+ +
+ + + + + + + + +
+ ); +} + +function IndependentControlledAccordionExample() { + const [isProfileOpen, setIsProfileOpen] = useState(true); + const [isResultOpen, setIsResultOpen] = useState(true); + + return ( +
+ + +
+ + 프로필 입력 +
+
+ + + 자기소개서 제목 + + + + +
+ + + +
+ + 분석 결과 +
+
+ + + +
+
+ ); +} + +const meta = { + title: 'Shared/Accordion', + component: Accordion, + args: { + disabled: false, + }, + argTypes: { + disabled: { + control: 'boolean', + description: '전체 Accordion 비활성화 여부', + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const ExclusiveControlled: Story = { + render: ({ disabled }) => , +}; + +export const Independent: Story = { + render: ({ disabled }) => ( +
+ + +
+ + 질문 +
+
+ +

첫 번째 Accordion의 질문 영역입니다.

+
+
+ + + +
+ + 답변 +
+
+ +

두 번째 Accordion의 답변 영역입니다.

+
+
+ + + +
+ + 최종 작성본 +
+
+ +

세 번째 Accordion의 최종 작성본 영역입니다.

+
+
+
+ ), +}; + +export const DefaultOpen: Story = { + render: () => ( +
+ + +
+ + 질문 +
+
+ +

defaultOpen이 없어 닫힌 상태로 시작합니다.

+
+
+ + + +
+ + 내가 쓴 자기소개서 +
+
+ +

defaultOpen으로 열린 상태를 지정합니다.

+
+
+
+ ), +}; + +export const Controlled: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: () => ( +
+ + +
+ + 활성 섹션 +
+
+ +

클릭과 키보드 입력이 가능합니다.

+
+
+ + + +
+ + 비활성 섹션 +
+
+ +

비활성 섹션은 열리지 않습니다.

+
+
+
+ ), +}; + +export const CoverLetterInputs: Story = { + render: () => , +}; diff --git a/src/shared/ui/accordion/Accordion.tsx b/src/shared/ui/accordion/Accordion.tsx new file mode 100644 index 0000000..c32f328 --- /dev/null +++ b/src/shared/ui/accordion/Accordion.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useCallback, useId, useMemo, useState } from 'react'; + +import { cn } from '@/shared/styles/utils/cn'; + +import { AccordionContent } from './AccordionContent'; +import { AccordionContext } from './AccordionContext'; +import { AccordionHeader } from './AccordionHeader'; +import { AccordionLabel } from './AccordionLabel'; +import { AccordionTrigger } from './AccordionTrigger'; + +import type { AccordionContextValue, AccordionProps } from './Accordion.types'; + +/** + * ## Accordion + * + * @description + * 하나의 입력 섹션을 접고 펼칠 때 사용하는 공통 Compound Accordion입니다. + * 자기소개서 문항, 수정본, 키워드 분석 입력처럼 사용자가 필요한 영역에 집중해야 하는 + * 화면에서 사용합니다. + * + * ### 주요 내용 + * + * `open`을 전달하면 controlled 방식으로 동작하고, 생략하면 `defaultOpen`을 기준으로 + * 내부 상태를 관리합니다. + * 여러 Accordion 중 하나만 열어야 하는 정책은 페이지나 부모 컴포넌트에서 `open`과 + * `onOpenChange`로 제어합니다. + * + * ### 구조 + * + * Header 조합 Accordion: + * + * ```tsx + * + * + * + *
+ * + * + * 질문 + * + *
+ * + * + *
+ * ... + *
+ * ``` + * + * @param open - 외부에서 열림 상태를 제어할 때 사용하는 controlled 값 + * @param defaultOpen - uncontrolled 방식에서 최초로 열어둘지 여부 + * @param onOpenChange - 열림 상태가 바뀔 때 호출되는 콜백 + * @param collapsible - 열린 Accordion을 다시 닫을 수 있는지 여부 + * @param disabled - Trigger 동작을 비활성화할지 여부 + * + * @example 기본 입력 섹션 + * ```tsx + * + * + * + *
+ * + * + * 질문 + * + *
+ * + * + *
+ * + * + * + *
+ * ``` + * + * @example controlled Accordion + * ```tsx + * + * + * + * 답변 + * + * + * + * + * + * ``` + */ +function AccordionRoot({ + children, + className, + collapsible = true, + defaultOpen = false, + disabled = false, + onOpenChange, + open, + ...props +}: AccordionProps) { + const generatedId = useId(); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const isControlled = open !== undefined; + const isOpen = isControlled ? open : uncontrolledOpen; + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + setUncontrolledOpen(nextOpen); + } + + onOpenChange?.(nextOpen); + }, + [isControlled, onOpenChange] + ); + + const contextValue = useMemo( + () => ({ + contentId: `accordion-content-${generatedId}`, + disabled, + isLockedOpen: isOpen && !collapsible, + isOpen, + labelId: `accordion-label-${generatedId}`, + triggerId: `accordion-trigger-${generatedId}`, + toggleOpen: () => { + if (disabled || (isOpen && !collapsible)) { + return; + } + + handleOpenChange(!isOpen); + }, + }), + [collapsible, disabled, generatedId, handleOpenChange, isOpen] + ); + + return ( + +
+ {children} +
+
+ ); +} + +const Accordion = Object.assign(AccordionRoot, { + Content: AccordionContent, + Header: AccordionHeader, + Label: AccordionLabel, + Trigger: AccordionTrigger, +}); + +export { Accordion }; diff --git a/src/shared/ui/accordion/Accordion.types.ts b/src/shared/ui/accordion/Accordion.types.ts new file mode 100644 index 0000000..bdfca42 --- /dev/null +++ b/src/shared/ui/accordion/Accordion.types.ts @@ -0,0 +1,74 @@ +import type { + ComponentPropsWithRef, + ComponentPropsWithoutRef, + ReactElement, + ReactNode, +} from 'react'; + +/** Accordion Root가 공유하는 공통 props입니다. */ +interface AccordionProps extends Omit, 'onChange'> { + children: ReactNode; + collapsible?: boolean; + defaultOpen?: boolean; + disabled?: boolean; + onOpenChange?: (open: boolean) => void; + open?: boolean; +} + +/** Trigger와 항상 노출할 요약 영역을 함께 묶는 Header props입니다. */ +interface AccordionHeaderProps extends ComponentPropsWithoutRef<'div'> { + children: ReactNode; +} + +interface AccordionLabelChildProps { + className?: string; + id?: string; +} + +type AccordionLabelBaseProps = Omit, 'children' | 'id'>; + +/** Content region의 접근성 이름으로 연결되는 섹션 Label props입니다. */ +type AccordionLabelProps = + | (AccordionLabelBaseProps & { + asChild?: false; + children: ReactNode; + }) + | (AccordionLabelBaseProps & { + asChild: true; + children: ReactElement; + }); + +/** 섹션을 열고 닫는 아이콘 button 기반 Trigger props입니다. */ +interface AccordionTriggerProps extends Omit< + ComponentPropsWithRef<'button'>, + 'aria-controls' | 'aria-expanded' | 'children' | 'disabled' | 'type' +> { + /** 아이콘 전용 버튼의 접근성 이름입니다. */ + 'aria-label': string; +} + +/** 열림 상태에 따라 확장되는 Content 영역 props입니다. */ +interface AccordionContentProps extends ComponentPropsWithoutRef<'div'> { + children: ReactNode; +} + +/** Root가 Compound 하위 컴포넌트에 제공하는 상태와 동작입니다. */ +interface AccordionContextValue { + contentId: string; + disabled: boolean; + isLockedOpen: boolean; + isOpen: boolean; + labelId: string; + triggerId: string; + toggleOpen: () => void; +} + +export type { + AccordionContentProps, + AccordionContextValue, + AccordionHeaderProps, + AccordionLabelChildProps, + AccordionLabelProps, + AccordionProps, + AccordionTriggerProps, +}; diff --git a/src/shared/ui/accordion/AccordionContent.tsx b/src/shared/ui/accordion/AccordionContent.tsx new file mode 100644 index 0000000..4d36b18 --- /dev/null +++ b/src/shared/ui/accordion/AccordionContent.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { cn } from '@/shared/styles/utils/cn'; + +import { useAccordionContext } from './AccordionContext'; + +import type { AccordionContentProps } from './Accordion.types'; + +/** + * ## Accordion.Content + * + * @description + * Accordion 섹션이 열렸을 때 표시되는 본문 영역입니다. + * Input, TextArea, 안내 문구 등 섹션 안의 실제 콘텐츠를 children으로 배치합니다. + * + * ### 접근성 + * + * Header의 보이는 Label과 `aria-labelledby`로 연결된 `region`을 렌더링합니다. + * 닫힌 상태에서는 `aria-hidden`과 `inert`를 적용해 스크린 리더와 키보드 탐색에서 + * 제외하면서도 높이 애니메이션이 자연스럽게 동작하도록 DOM은 유지합니다. + */ +export function AccordionContent({ children, className, ...props }: AccordionContentProps) { + const { contentId, isOpen, labelId } = useAccordionContext(); + + return ( +
+
+
{children}
+
+
+ ); +} diff --git a/src/shared/ui/accordion/AccordionContext.ts b/src/shared/ui/accordion/AccordionContext.ts new file mode 100644 index 0000000..36599be --- /dev/null +++ b/src/shared/ui/accordion/AccordionContext.ts @@ -0,0 +1,25 @@ +import { createContext, use } from 'react'; + +import type { AccordionContextValue } from './Accordion.types'; + +const AccordionContext = createContext(null); + +/** + * ## useAccordionContext + * + * @description + * Accordion Root가 제공하는 열림 상태, 연결 id, 토글 동작을 읽는 내부 훅입니다. + * Compound 하위 컴포넌트가 Root 밖에서 사용되는 실수를 빠르게 드러내기 위해 context가 + * 없으면 명시적인 에러를 발생시킵니다. + */ +export function useAccordionContext() { + const context = use(AccordionContext); + + if (!context) { + throw new Error('Accordion compound components must be used within Accordion.'); + } + + return context; +} + +export { AccordionContext }; diff --git a/src/shared/ui/accordion/AccordionHeader.tsx b/src/shared/ui/accordion/AccordionHeader.tsx new file mode 100644 index 0000000..c657909 --- /dev/null +++ b/src/shared/ui/accordion/AccordionHeader.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { cn } from '@/shared/styles/utils/cn'; + +import { useAccordionContext } from './AccordionContext'; + +import type { AccordionHeaderProps } from './Accordion.types'; + +/** + * ## Accordion.Header + * + * @description + * Trigger와 닫힌 상태에서도 노출할 요약 입력 영역을 하나의 헤더로 묶는 선택적 Compound + * 컴포넌트입니다. + * 질문 input처럼 항상 보여야 하는 필드가 있을 때 사용합니다. + * + * ### 접근성 + * + * Header 자체는 상호작용 요소가 아닙니다. + * 열림/닫힘 동작은 내부의 `Accordion.Trigger`가 담당하며, input과 button 같은 요소는 + * Trigger 밖에 배치해야 합니다. + */ +export function AccordionHeader({ children, className, ...props }: AccordionHeaderProps) { + const { disabled, isOpen } = useAccordionContext(); + + return ( +
+ {children} +
+ ); +} diff --git a/src/shared/ui/accordion/AccordionLabel.tsx b/src/shared/ui/accordion/AccordionLabel.tsx new file mode 100644 index 0000000..e566677 --- /dev/null +++ b/src/shared/ui/accordion/AccordionLabel.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { Children, cloneElement, isValidElement } from 'react'; +import type { ReactElement } from 'react'; + +import { cn } from '@/shared/styles/utils/cn'; + +import { useAccordionContext } from './AccordionContext'; + +import type { AccordionLabelChildProps, AccordionLabelProps } from './Accordion.types'; + +/** + * ## Accordion.Label + * + * @description + * Accordion Content region의 접근성 이름으로 연결되는 섹션 Label입니다. + * `Accordion.Content`가 `aria-labelledby`로 참조할 id를 자동으로 적용합니다. + * 실제 입력 필드의 Label이 섹션 제목이기도 한 경우 `asChild`로 `Input.Label` 또는 + * `TextArea.Label`을 전달합니다. + * + * @param asChild - 자식 컴포넌트에 Accordion Label 속성을 위임할지 여부 + * @param children - Accordion 섹션 제목 + * @param className - Label의 기본 스타일을 확장하는 클래스 이름 + */ +export function AccordionLabel({ + asChild = false, + children, + className, + ...props +}: AccordionLabelProps) { + const { labelId } = useAccordionContext(); + const labelClassName = cn('mb-0 w-fit body-18 font-semibold text-current', className); + + if (asChild) { + const childCount = Children.count(children); + + if (childCount !== 1) { + throw new Error('Accordion.Label with asChild must receive exactly one React element child.'); + } + + const child = Children.only(children); + + if (!isValidElement(child)) { + throw new Error('Accordion.Label with asChild must receive a valid React element child.'); + } + + return cloneElement(child as ReactElement, { + ...(props as AccordionLabelChildProps), + className: cn(labelClassName, child.props.className), + id: labelId, + }); + } + + return ( + + {children} + + ); +} diff --git a/src/shared/ui/accordion/AccordionTrigger.tsx b/src/shared/ui/accordion/AccordionTrigger.tsx new file mode 100644 index 0000000..324c906 --- /dev/null +++ b/src/shared/ui/accordion/AccordionTrigger.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { AltArrowDownIcon } from '@/shared/assets/icons/common'; +import { cn } from '@/shared/styles/utils/cn'; + +import { useAccordionContext } from './AccordionContext'; + +import type { AccordionTriggerProps } from './Accordion.types'; + +/** + * ## Accordion.Trigger + * + * @description + * Accordion 섹션을 열고 닫는 아이콘 button 컴포넌트입니다. + * Header 안에서 Label과 함께 배치해 섹션의 조작 지점을 만듭니다. + * + * ### 접근성 + * + * 아이콘만 렌더링하는 button이므로 `aria-label`을 필수로 전달해야 합니다. + * `aria-expanded`와 `aria-controls`는 자동으로 설정해 Content와 연결합니다. + * input이나 button 같은 요소는 Trigger 안에 넣지 말고 `Accordion.Header`에서 Trigger 밖에 + * 배치합니다. + * + */ +export function AccordionTrigger({ className, onClick, ref, ...props }: AccordionTriggerProps) { + const { contentId, disabled, isLockedOpen, isOpen, toggleOpen, triggerId } = + useAccordionContext(); + const isInteractionDisabled = disabled || isLockedOpen; + + const handleClick: NonNullable = (event) => { + onClick?.(event); + + if (event.defaultPrevented || isInteractionDisabled) { + return; + } + + toggleOpen(); + }; + + return ( + + ); +} diff --git a/src/shared/ui/accordion/index.ts b/src/shared/ui/accordion/index.ts new file mode 100644 index 0000000..feee0c4 --- /dev/null +++ b/src/shared/ui/accordion/index.ts @@ -0,0 +1 @@ +export { Accordion } from './Accordion'; diff --git a/src/shared/ui/form-control/FormControlLabel.tsx b/src/shared/ui/form-control/FormControlLabel.tsx index 3ffbd94..75aae31 100644 --- a/src/shared/ui/form-control/FormControlLabel.tsx +++ b/src/shared/ui/form-control/FormControlLabel.tsx @@ -38,7 +38,7 @@ export function FormControlLabel({ children, className, ...props }: FormControlL return (