diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts new file mode 100644 index 0000000..19e1838 --- /dev/null +++ b/src/shared/hooks/index.ts @@ -0,0 +1,6 @@ +export { useControllableState } from './useControllableState'; +export { useEscapeKey } from './useEscapeKey'; +export { useFocusRestore } from './useFocusRestore'; +export { useFocusTrap } from './useFocusTrap'; +export { useOutsideClick } from './useOutsideClick'; +export { useScrollLock } from './useScrollLock'; diff --git a/src/shared/hooks/useControllableState.test.ts b/src/shared/hooks/useControllableState.test.ts new file mode 100644 index 0000000..46cf44c --- /dev/null +++ b/src/shared/hooks/useControllableState.test.ts @@ -0,0 +1,105 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useControllableState } from './useControllableState'; + +describe('useControllableState', () => { + it('manages internal state when value is uncontrolled', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useControllableState({ + defaultValue: false, + onChange, + }) + ); + + act(() => { + result.current[1](true); + }); + + expect(result.current[0]).toBe(true); + expect(result.current[2]).toBe(false); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('uses external state when value is controlled', () => { + const onChange = jest.fn(); + const { result, rerender } = renderHook( + ({ value }) => + useControllableState({ + defaultValue: false, + onChange, + value, + }), + { + initialProps: { + value: false, + }, + } + ); + + act(() => { + result.current[1](true); + }); + + expect(result.current[0]).toBe(false); + expect(result.current[2]).toBe(true); + expect(onChange).toHaveBeenCalledWith(true); + + rerender({ value: true }); + + expect(result.current[0]).toBe(true); + }); + + it('supports functional updates', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useControllableState({ + defaultValue: 1, + onChange, + }) + ); + + act(() => { + result.current[1]((previousValue) => previousValue + 1); + }); + + expect(result.current[0]).toBe(2); + expect(onChange).toHaveBeenCalledWith(2); + }); + + it('applies consecutive functional updates from the latest uncontrolled value', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useControllableState({ + defaultValue: 1, + onChange, + }) + ); + + act(() => { + result.current[1]((previousValue) => previousValue + 1); + result.current[1]((previousValue) => previousValue + 1); + }); + + expect(result.current[0]).toBe(3); + expect(onChange).toHaveBeenNthCalledWith(1, 2); + expect(onChange).toHaveBeenNthCalledWith(2, 3); + }); + + it('does not call onChange when next value is equal to current value', () => { + const onChange = jest.fn(); + const { result } = renderHook(() => + useControllableState({ + defaultValue: 'open', + onChange, + }) + ); + + act(() => { + result.current[1]('open'); + }); + + expect(result.current[0]).toBe('open'); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/shared/hooks/useControllableState.ts b/src/shared/hooks/useControllableState.ts new file mode 100644 index 0000000..563d9f7 --- /dev/null +++ b/src/shared/hooks/useControllableState.ts @@ -0,0 +1,65 @@ +import { useCallback, useRef, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; + +interface UseControllableStateParams { + defaultValue: TValue | (() => TValue); + onChange?: (value: TValue) => void; + value?: TValue; +} + +/** + * ## useControllableState + * + * @description + * controlled와 uncontrolled 방식을 모두 지원해야 하는 공통 UI에서 사용하는 상태 훅입니다. + * `value`가 전달되면 외부 상태를 기준으로 동작하고, 생략되면 `defaultValue`로 내부 상태를 + * 초기화합니다. + * + * ### 주요 내용 + * + * Modal, Panel, Accordion처럼 `open`/`defaultOpen`/`onOpenChange` API를 제공하는 + * 컴포넌트에서 상태 관리 방식을 일관되게 유지하기 위해 사용합니다. + * + * @example + * ```tsx + * const [isOpen, setIsOpen] = useControllableState({ + * value: open, + * defaultValue: defaultOpen, + * onChange: onOpenChange, + * }); + * ``` + */ +export function useControllableState({ + defaultValue, + onChange, + value, +}: UseControllableStateParams) { + const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); + const uncontrolledValueRef = useRef(uncontrolledValue); + const isControlled = value !== undefined; + const currentValue = isControlled ? value : uncontrolledValue; + + const setValue: Dispatch> = useCallback( + (nextValueOrUpdater) => { + const previousValue = isControlled ? currentValue : uncontrolledValueRef.current; + const nextValue = + typeof nextValueOrUpdater === 'function' + ? (nextValueOrUpdater as (previousValue: TValue) => TValue)(previousValue) + : nextValueOrUpdater; + + if (Object.is(previousValue, nextValue)) { + return; + } + + if (!isControlled) { + uncontrolledValueRef.current = nextValue; + setUncontrolledValue(nextValue); + } + + onChange?.(nextValue); + }, + [currentValue, isControlled, onChange] + ); + + return [currentValue, setValue, isControlled] as const; +} diff --git a/src/shared/hooks/useEscapeKey.ts b/src/shared/hooks/useEscapeKey.ts new file mode 100644 index 0000000..88e88c1 --- /dev/null +++ b/src/shared/hooks/useEscapeKey.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'react'; + +interface UseEscapeKeyParams { + enabled?: boolean; + onEscapeKeyDown: (event: KeyboardEvent) => void; +} + +/** + * ## useEscapeKey + * + * @description + * ESC 키 입력을 감지하는 공통 hook입니다. Modal, Panel처럼 열려 있는 UI가 ESC로 + * 닫혀야 할 때 사용하며, 실제 닫힘 가능 여부는 호출하는 컴포넌트의 정책에서 판단합니다. + * + * @param enabled - 이벤트 리스너 활성화 여부 + * @param onEscapeKeyDown - ESC 키가 눌렸을 때 실행할 콜백 + */ +export function useEscapeKey({ enabled = true, onEscapeKeyDown }: UseEscapeKeyParams) { + const callbackRef = useRef(onEscapeKeyDown); + + useEffect(() => { + callbackRef.current = onEscapeKeyDown; + }, [onEscapeKeyDown]); + + useEffect(() => { + if (!enabled) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.key !== 'Escape' || event.isComposing) { + return; + } + + callbackRef.current(event); + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [enabled]); +} diff --git a/src/shared/hooks/useFocusRestore.ts b/src/shared/hooks/useFocusRestore.ts new file mode 100644 index 0000000..d0caa4f --- /dev/null +++ b/src/shared/hooks/useFocusRestore.ts @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react'; + +interface UseFocusRestoreParams { + enabled?: boolean; +} + +/** + * ## useFocusRestore + * + * @description + * 열린 UI가 닫힐 때 열기 전 포커스가 있던 요소로 포커스를 되돌리는 hook입니다. + * Modal, Panel처럼 사용자의 현재 작업 흐름을 잠시 가로채는 UI에서 사용합니다. + */ +export function useFocusRestore({ enabled = true }: UseFocusRestoreParams = {}) { + const previousFocusedElementRef = useRef(null); + const wasEnabledRef = useRef(false); + + useEffect(() => { + if (enabled && !wasEnabledRef.current) { + previousFocusedElementRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + + if (!enabled && wasEnabledRef.current) { + previousFocusedElementRef.current?.focus(); + previousFocusedElementRef.current = null; + } + + wasEnabledRef.current = enabled; + }, [enabled]); + + useEffect( + () => () => { + if (wasEnabledRef.current) { + previousFocusedElementRef.current?.focus(); + } + }, + [] + ); +} diff --git a/src/shared/hooks/useFocusTrap.test.tsx b/src/shared/hooks/useFocusTrap.test.tsx new file mode 100644 index 0000000..7831e02 --- /dev/null +++ b/src/shared/hooks/useFocusTrap.test.tsx @@ -0,0 +1,64 @@ +import { createRef } from 'react'; + +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import { useFocusTrap } from './useFocusTrap'; + +function FocusTrapFixture() { + const containerRef = createRef(); + + useFocusTrap({ + ref: containerRef, + }); + + return ( + <> + +
+ + +
+ + ); +} + +describe('useFocusTrap', () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'getClientRects', { + configurable: true, + value: function getClientRects() { + return this.hidden ? [] : [{ height: 1, width: 1 }]; + }, + }); + }); + + it('moves focus back to the first focusable element when focus is outside the container', () => { + render(); + + const outsideButton = screen.getByRole('button', { name: '외부 버튼' }); + const firstButton = screen.getByRole('button', { name: '첫 번째 버튼' }); + + act(() => { + outsideButton.focus(); + }); + + fireEvent.keyDown(document, { key: 'Tab' }); + + expect(firstButton).toHaveFocus(); + }); + + it('moves focus back to the last focusable element on Shift+Tab when focus is outside the container', () => { + render(); + + const outsideButton = screen.getByRole('button', { name: '외부 버튼' }); + const lastButton = screen.getByRole('button', { name: '마지막 버튼' }); + + act(() => { + outsideButton.focus(); + }); + + fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + + expect(lastButton).toHaveFocus(); + }); +}); diff --git a/src/shared/hooks/useFocusTrap.ts b/src/shared/hooks/useFocusTrap.ts new file mode 100644 index 0000000..8fd9123 --- /dev/null +++ b/src/shared/hooks/useFocusTrap.ts @@ -0,0 +1,98 @@ +import { useEffect } from 'react'; +import type { RefObject } from 'react'; + +interface UseFocusTrapParams { + enabled?: boolean; + ref: RefObject; +} + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); + +const isVisible = (element: HTMLElement) => + element.getClientRects().length > 0 && getComputedStyle(element).visibility !== 'hidden'; + +const getFocusableElements = (container: HTMLElement) => + Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (element) => + !element.hasAttribute('disabled') && + !element.hidden && + element.getAttribute('aria-hidden') !== 'true' && + isVisible(element) + ); + +/** + * ## useFocusTrap + * + * @description + * Tab 키 이동을 특정 컨테이너 내부로 제한하고, 열릴 때 첫 포커스 대상 또는 컨테이너로 + * 포커스를 이동하는 hook입니다. + */ +export function useFocusTrap({ + enabled = true, + ref, +}: UseFocusTrapParams) { + useEffect(() => { + const container = ref.current; + + if (!enabled || !container) { + return; + } + + const focusableElements = getFocusableElements(container); + const initialFocusElement = focusableElements[0] ?? container; + + initialFocusElement.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') { + return; + } + + const currentFocusableElements = getFocusableElements(container); + + if (currentFocusableElements.length === 0) { + event.preventDefault(); + container.focus(); + return; + } + + const firstElement = currentFocusableElements[0]; + const lastElement = currentFocusableElements[currentFocusableElements.length - 1]; + const activeElement = document.activeElement; + const activeFocusableElement = + activeElement instanceof HTMLElement && currentFocusableElements.includes(activeElement) + ? activeElement + : null; + + if (!activeFocusableElement) { + event.preventDefault(); + (event.shiftKey ? lastElement : firstElement).focus(); + return; + } + + if (event.shiftKey && activeFocusableElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + return; + } + + if (!event.shiftKey && activeFocusableElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [enabled, ref]); +} diff --git a/src/shared/hooks/useOutsideClick.ts b/src/shared/hooks/useOutsideClick.ts new file mode 100644 index 0000000..9743432 --- /dev/null +++ b/src/shared/hooks/useOutsideClick.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react'; +import type { RefObject } from 'react'; + +interface UseOutsideClickParams { + enabled?: boolean; + onOutsideClick: (event: PointerEvent) => void; + ref: RefObject; +} + +/** + * ## useOutsideClick + * + * @description + * 지정한 요소 바깥에서 발생한 pointer down 이벤트를 감지하는 공통 hook입니다. + * Modal Content 바깥 클릭처럼 외부 영역 상호작용을 닫기 요청으로 변환할 때 사용합니다. + * + * @param enabled - 이벤트 리스너 활성화 여부 + * @param ref - 내부 영역으로 판단할 요소 ref + * @param onOutsideClick - ref 바깥에서 pointer down이 발생했을 때 실행할 콜백 + */ +export function useOutsideClick({ + enabled = true, + onOutsideClick, + ref, +}: UseOutsideClickParams) { + const callbackRef = useRef(onOutsideClick); + + useEffect(() => { + callbackRef.current = onOutsideClick; + }, [onOutsideClick]); + + useEffect(() => { + if (!enabled) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const element = ref.current; + const target = event.target; + + if (!element || !(target instanceof Node) || element.contains(target)) { + return; + } + + callbackRef.current(event); + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + }; + }, [enabled, ref]); +} diff --git a/src/shared/hooks/useScrollLock.ts b/src/shared/hooks/useScrollLock.ts new file mode 100644 index 0000000..e7176ac --- /dev/null +++ b/src/shared/hooks/useScrollLock.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; + +interface UseScrollLockParams { + enabled?: boolean; +} + +let lockCount = 0; +let previousOverflow = ''; + +/** + * ## useScrollLock + * + * @description + * Modal처럼 배경 스크롤을 막아야 하는 UI가 열려 있는 동안 document body 스크롤을 잠그는 + * hook입니다. 여러 UI가 동시에 열려도 마지막 잠금이 해제될 때 원래 overflow 값을 복원합니다. + */ +export function useScrollLock({ enabled = true }: UseScrollLockParams = {}) { + useEffect(() => { + if (!enabled) { + return; + } + + if (lockCount === 0) { + previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + } + + lockCount += 1; + + return () => { + lockCount = Math.max(0, lockCount - 1); + + if (lockCount === 0) { + document.body.style.overflow = previousOverflow; + } + }; + }, [enabled]); +} diff --git a/src/shared/styles/base/z-index.css b/src/shared/styles/base/z-index.css new file mode 100644 index 0000000..a28f518 --- /dev/null +++ b/src/shared/styles/base/z-index.css @@ -0,0 +1,10 @@ +:root { + /** + Z-Index Token + 사용법: z-(--z-index-{name}) + 예시: z-(--z-index-surface-content) + */ + --z-index-form-floating: 10; + --z-index-surface-overlay: 40; + --z-index-surface-content: 50; +} diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index 84a1f97..1b58533 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -1,6 +1,7 @@ @import 'tailwindcss'; @import './base/fonts.css'; @import './base/colors.css'; +@import './base/z-index.css'; @import './base/scroll.css'; button { diff --git a/src/shared/ui/accordion/Accordion.tsx b/src/shared/ui/accordion/Accordion.tsx index c32f328..426d1fa 100644 --- a/src/shared/ui/accordion/Accordion.tsx +++ b/src/shared/ui/accordion/Accordion.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useCallback, useId, useMemo, useState } from 'react'; +import { useId, useMemo } from 'react'; +import { useControllableState } from '@/shared/hooks'; import { cn } from '@/shared/styles/utils/cn'; import { AccordionContent } from './AccordionContent'; @@ -104,20 +105,11 @@ function AccordionRoot({ ...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 [isOpen, setIsOpen] = useControllableState({ + defaultValue: defaultOpen, + onChange: onOpenChange, + value: open, + }); const contextValue = useMemo( () => ({ @@ -128,14 +120,20 @@ function AccordionRoot({ labelId: `accordion-label-${generatedId}`, triggerId: `accordion-trigger-${generatedId}`, toggleOpen: () => { - if (disabled || (isOpen && !collapsible)) { + if (disabled) { return; } - handleOpenChange(!isOpen); + setIsOpen((open) => { + if (open && !collapsible) { + return open; + } + + return !open; + }); }, }), - [collapsible, disabled, generatedId, handleOpenChange, isOpen] + [collapsible, disabled, generatedId, isOpen, setIsOpen] ); return ( diff --git a/src/shared/ui/accordion/AccordionLabel.tsx b/src/shared/ui/accordion/AccordionLabel.tsx index e566677..97a2ae9 100644 --- a/src/shared/ui/accordion/AccordionLabel.tsx +++ b/src/shared/ui/accordion/AccordionLabel.tsx @@ -1,9 +1,7 @@ 'use client'; -import { Children, cloneElement, isValidElement } from 'react'; -import type { ReactElement } from 'react'; - import { cn } from '@/shared/styles/utils/cn'; +import { cloneSlot, getSingleSlotChild } from '@/shared/ui/utils/Slot'; import { useAccordionContext } from './AccordionContext'; @@ -32,21 +30,11 @@ export function AccordionLabel({ 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.'); - } + const child = getSingleSlotChild(children, 'Accordion.Label'); - return cloneElement(child as ReactElement, { + return cloneSlot(child, { ...(props as AccordionLabelChildProps), - className: cn(labelClassName, child.props.className), + className: labelClassName, id: labelId, }); } diff --git a/src/shared/ui/surface/Surface.stories.tsx b/src/shared/ui/surface/Surface.stories.tsx new file mode 100644 index 0000000..9535c31 --- /dev/null +++ b/src/shared/ui/surface/Surface.stories.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react'; + +import { CancelIcon } from '@/shared/assets/icons/common'; +import { Button } from '@/shared/ui/button'; +import { Input } from '@/shared/ui/input'; +import { TextArea } from '@/shared/ui/textarea'; +import { Title } from '@/shared/ui/title'; + +import { Surface } from './index'; + +import type { SurfaceProps } from './index'; +import type { Meta, StoryObj } from '@storybook/nextjs'; + +type SurfacePlaygroundProps = Pick< + SurfaceProps, + 'canClose' | 'closeOnEscape' | 'closeOnOutsideClick' | 'focusTrap' | 'restoreFocus' | 'scrollLock' +>; + +function ModalVariantExample(props: SurfacePlaygroundProps) { + return ( + + + + + + + + + + 최종 제출하시겠습니까? + + + + + + +

제출 후에는 수정할 수 없습니다.

+
+ + + +
+
+
+ ); +} + +function PanelVariantExample(props: SurfacePlaygroundProps) { + return ( + + + + + + + + + 필터 + + + + + + + + 회사명 + + + + + 직무 + + + + + + + + + + + ); +} + +function PreventCloseWhenDirtyExample({ closeOnOutsideClick, ...props }: SurfacePlaygroundProps) { + const [value, setValue] = useState('작성 중인 자기소개서 초안입니다.'); + const [message, setMessage] = useState(''); + const isDirty = value.trim().length > 0; + + return ( + { + setMessage( + reason === 'outside-click' + ? '작성 중인 내용이 있어 닫을 수 없습니다.' + : '작성 중인 내용을 확인해 주세요.' + ); + }} + onOpenChange={(open) => { + if (!open) { + setMessage(''); + } + }} + > + + + + + + + + + 자기소개서 작성 + + + + + + + + {message ?

{message}

: null} +
+
+
+
+ ); +} + +const meta = { + title: 'Shared/Surface', + component: Surface, + args: { + canClose: true, + children: null, + closeOnEscape: true, + closeOnOutsideClick: true, + focusTrap: true, + restoreFocus: true, + }, + argTypes: { + canClose: { + control: 'boolean', + }, + closeOnEscape: { + control: 'boolean', + }, + closeOnOutsideClick: { + control: 'boolean', + }, + children: { + control: false, + table: { + disable: true, + }, + }, + focusTrap: { + control: 'boolean', + }, + restoreFocus: { + control: 'boolean', + }, + scrollLock: { + control: 'boolean', + }, + }, + parameters: { + backgrounds: { + default: 'black', + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const ModalVariant: Story = { + render: (args) => , +}; + +export const PanelVariant: Story = { + render: (args) => , +}; + +export const PreventCloseWhenDirty: Story = { + render: (args) => , +}; diff --git a/src/shared/ui/surface/Surface.tsx b/src/shared/ui/surface/Surface.tsx new file mode 100644 index 0000000..e2be11a --- /dev/null +++ b/src/shared/ui/surface/Surface.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useCallback, useId, useMemo } from 'react'; + +import { useControllableState } from '@/shared/hooks'; + +import { SurfaceClose } from './SurfaceClose'; +import { SurfaceContent } from './SurfaceContent'; +import { SurfaceContext } from './SurfaceContext'; +import { SurfaceOverlay } from './SurfaceOverlay'; +import { SurfacePortal } from './SurfacePortal'; +import { SurfaceBody, SurfaceFooter, SurfaceHeader } from './SurfaceSections'; +import { SurfaceTrigger } from './SurfaceTrigger'; + +import type { + SurfaceCloseReason, + SurfaceContextValue, + SurfaceOpenChangeReason, + SurfaceProps, +} from './Surface.types'; + +interface SurfaceClosePolicy { + canClose: boolean; + closeOnEscape: boolean; + closeOnOutsideClick: boolean; + reason: SurfaceCloseReason; +} + +const isSurfaceCloseAllowed = ({ + canClose, + closeOnEscape, + closeOnOutsideClick, + reason, +}: SurfaceClosePolicy) => { + if (reason === 'close-button') { + return true; + } + + if (!canClose) { + return false; + } + + if (reason === 'escape-key') { + return closeOnEscape; + } + + if (reason === 'outside-click') { + return closeOnOutsideClick; + } + + return true; +}; + +/** + * ## Surface + * + * @description + * 화면 위에 표시되는 공통 표면 UI를 만드는 Compound 컴포넌트입니다. + * `variant`로 중앙 모달(`modal`)과 우측 패널(`panel`) 표현을 선택합니다. + * + * ### 주요 내용 + * + * `open`을 전달하면 controlled 방식으로 동작하고, 생략하면 `defaultOpen`을 기준으로 + * 내부 상태를 관리합니다. ESC 닫기, 외부 클릭 닫기, Focus Trap, Focus Restore, + * Scroll Lock을 공통 인터페이스로 제공합니다. + * + * ### 접근성 + * + * `Surface.Content`는 `role="dialog"`를 렌더링합니다. 접근성 이름은 `aria-label` 또는 + * `aria-labelledby`로 명시합니다. `variant="modal"`에서는 `aria-modal`과 기본 scroll lock이 + * 적용됩니다. + * + * @example 중앙 모달 + * ```tsx + * + * 열기 + * + * + * + * 제목 + * 내용 + * + * + * + * + * + * + * + * + * ``` + * + * @example 우측 패널 + * ```tsx + * + * 패널 열기 + * + * + * 필터 + * ... + * + * + * + * ``` + * + * @param variant - `modal`은 중앙 모달, `panel`은 우측 패널 표현을 사용합니다. + * @param open - 외부에서 열림 상태를 제어할 때 사용하는 controlled 값입니다. + * @param defaultOpen - uncontrolled 방식에서 최초로 열어둘지 여부입니다. + * @param onOpenChange - 열림 상태가 변경될 때 변경 값과 변경 이유를 받습니다. + * @param canClose - false이면 ESC, outside click 같은 dismiss 닫기 요청을 차단합니다. + * @param onClosePrevented - 닫기 요청이 정책에 의해 차단됐을 때 호출됩니다. + * @param closeOnEscape - ESC 키 닫기 허용 여부입니다. + * @param closeOnOutsideClick - 외부 영역 클릭 닫기 허용 여부입니다. + * @param focusTrap - 열린 동안 포커스를 Content 내부에 가둘지 여부입니다. + * @param restoreFocus - 닫힐 때 열기 전 포커스로 복원할지 여부입니다. + * @param scrollLock - body scroll lock 적용 여부입니다. 생략하면 variant 기본값을 따릅니다. + */ +function SurfaceRoot({ + canClose = true, + children, + closeOnEscape = true, + closeOnOutsideClick = true, + defaultOpen = false, + focusTrap = true, + onClosePrevented, + onOpenChange, + open, + restoreFocus = true, + scrollLock, + variant = 'modal', +}: SurfaceProps) { + const generatedId = useId(); + const shouldLockScroll = scrollLock ?? variant === 'modal'; + const [isOpen, setIsOpen] = useControllableState({ + defaultValue: defaultOpen, + value: open, + }); + + const requestOpenChange = useCallback( + (nextOpen: boolean, reason: SurfaceOpenChangeReason = 'programmatic') => { + if (!nextOpen) { + const closeReason = reason as SurfaceCloseReason; + + if ( + !isSurfaceCloseAllowed({ + canClose, + closeOnEscape, + closeOnOutsideClick, + reason: closeReason, + }) + ) { + onClosePrevented?.({ reason: closeReason }); + return; + } + } + + if (Object.is(isOpen, nextOpen)) { + return; + } + + setIsOpen(nextOpen); + onOpenChange?.(nextOpen, { reason }); + }, + [ + canClose, + closeOnEscape, + closeOnOutsideClick, + isOpen, + onClosePrevented, + onOpenChange, + setIsOpen, + ] + ); + + const contextValue = useMemo( + () => ({ + actions: { + close: (reason) => requestOpenChange(false, reason), + toggle: (reason = 'trigger') => requestOpenChange(!isOpen, reason), + }, + meta: { + contentId: `${variant}-content-${generatedId}`, + }, + state: { + focusTrap, + isOpen, + restoreFocus, + scrollLock: shouldLockScroll, + variant, + }, + }), + [focusTrap, generatedId, isOpen, requestOpenChange, restoreFocus, shouldLockScroll, variant] + ); + + return {children}; +} + +const Surface = Object.assign(SurfaceRoot, { + Body: SurfaceBody, + Close: SurfaceClose, + Content: SurfaceContent, + Footer: SurfaceFooter, + Header: SurfaceHeader, + Overlay: SurfaceOverlay, + Portal: SurfacePortal, + Trigger: SurfaceTrigger, +}); + +export { Surface }; diff --git a/src/shared/ui/surface/Surface.types.ts b/src/shared/ui/surface/Surface.types.ts new file mode 100644 index 0000000..34ba7a3 --- /dev/null +++ b/src/shared/ui/surface/Surface.types.ts @@ -0,0 +1,202 @@ +import type { ComponentPropsWithRef, ReactElement, ReactNode } from 'react'; + +/** Surface가 화면에 표시되는 형태입니다. */ +type SurfaceType = 'modal' | 'panel'; + +/** Surface 열림 상태가 변경된 원인입니다. */ +type SurfaceOpenChangeReason = + | 'close-button' + | 'escape-key' + | 'outside-click' + | 'programmatic' + | 'trigger'; + +/** Surface 닫기 요청이 발생한 원인입니다. */ +type SurfaceCloseReason = SurfaceOpenChangeReason; + +/** `onOpenChange` 콜백에 전달되는 상태 변경 메타 정보입니다. */ +interface SurfaceOpenChangeMeta { + reason: SurfaceOpenChangeReason; +} + +/** 닫기 정책 콜백에 전달되는 닫기 요청 메타 정보입니다. */ +interface SurfaceCloseMeta { + reason: SurfaceCloseReason; +} + +/** Surface가 공유하는 공통 props입니다. */ +interface SurfaceProps { + /** dismiss 닫기 요청을 전역적으로 허용할지 여부입니다. Close 버튼은 항상 닫힙니다. */ + canClose?: boolean; + /** Surface compound 하위 컴포넌트입니다. */ + children: ReactNode; + /** ESC 키로 닫을 수 있는지 여부입니다. */ + closeOnEscape?: boolean; + /** Content 바깥 pointer down으로 닫을 수 있는지 여부입니다. */ + closeOnOutsideClick?: boolean; + /** uncontrolled 방식에서 최초로 열어둘지 여부입니다. */ + defaultOpen?: boolean; + /** 열린 동안 포커스를 Content 안에 가둘지 여부입니다. */ + focusTrap?: boolean; + /** 닫기 요청이 정책에 의해 차단됐을 때 호출됩니다. */ + onClosePrevented?: (meta: SurfaceCloseMeta) => void; + /** 열림 상태가 변경될 때 호출됩니다. */ + onOpenChange?: (open: boolean, meta: SurfaceOpenChangeMeta) => void; + /** controlled 방식으로 열림 상태를 제어할 때 사용합니다. */ + open?: boolean; + /** 닫힌 뒤 열기 전 포커스로 복원할지 여부입니다. */ + restoreFocus?: boolean; + /** body scroll lock 적용 여부입니다. 생략하면 variant 기본값을 사용합니다. */ + scrollLock?: boolean; + /** 중앙 모달 또는 우측 패널 표현을 선택합니다. */ + variant?: SurfaceType; +} + +/** Surface.Portal이 렌더링될 수 있는 DOM container 타입입니다. */ +type SurfacePortalContainer = DocumentFragment | Element; + +/** Surface.Portal props입니다. */ +interface SurfacePortalProps { + children: ReactNode; + /** portal을 렌더링할 대상 요소입니다. */ + container?: SurfacePortalContainer | null; +} + +/** Surface.Trigger가 직접 button을 렌더링할 때 사용하는 props입니다. */ +type SurfaceTriggerButtonProps = Omit< + ComponentPropsWithRef<'button'>, + 'aria-controls' | 'aria-expanded' | 'children' | 'type' +>; + +interface SurfaceTriggerAsChildProps { + asChild: true; + children: ReactElement<{ + className?: string; + onClick?: ComponentPropsWithRef<'button'>['onClick']; + }>; +} + +/** Surface.Trigger props입니다. */ +type SurfaceTriggerProps = + | (SurfaceTriggerButtonProps & { + /** 자식 컴포넌트에 Trigger 동작과 ARIA 속성을 위임할지 여부입니다. */ + asChild?: false; + children: ReactNode; + }) + | (SurfaceTriggerButtonProps & SurfaceTriggerAsChildProps); + +/** Surface.Overlay props입니다. */ +type SurfaceOverlayProps = ComponentPropsWithRef<'div'>; + +type SurfaceContentBaseProps = Omit< + ComponentPropsWithRef<'div'>, + 'aria-label' | 'aria-labelledby' | 'role' +>; + +type SurfaceContentAccessibilityProps = + | { + /** Surface.Content의 접근성 이름입니다. */ + 'aria-label': string; + 'aria-labelledby'?: string; + } + | { + 'aria-label'?: string; + /** Surface.Content의 접근성 이름으로 사용할 heading id입니다. */ + 'aria-labelledby': string; + }; + +/** Surface.Content props입니다. 접근성 이름을 반드시 전달해야 합니다. */ +type SurfaceContentProps = SurfaceContentBaseProps & SurfaceContentAccessibilityProps; + +/** Surface.Header props입니다. */ +interface SurfaceHeaderProps extends ComponentPropsWithRef<'header'> { + children: ReactNode; +} + +/** Surface.Body props입니다. */ +interface SurfaceBodyProps extends ComponentPropsWithRef<'div'> { + children: ReactNode; +} + +/** Surface.Footer props입니다. */ +interface SurfaceFooterProps extends ComponentPropsWithRef<'footer'> { + children: ReactNode; +} + +type SurfaceCloseBaseButtonProps = Omit< + ComponentPropsWithRef<'button'>, + 'aria-label' | 'children' | 'type' +>; + +type SurfaceCloseButtonProps = SurfaceCloseBaseButtonProps & { + /** 닫기 동작의 접근성 이름입니다. */ + 'aria-label': string; + /** 닫기 버튼의 텍스트 또는 아이콘입니다. */ + children: ReactNode; +}; + +interface SurfaceCloseAsChildProps { + /** 닫기 동작의 접근성 이름입니다. asChild 사용 시 자식 요소에 전달됩니다. */ + 'aria-label': string; + asChild: true; + children: ReactElement<{ + 'aria-label'?: string; + className?: string; + onClick?: ComponentPropsWithRef<'button'>['onClick']; + }>; +} + +type SurfaceCloseProps = + | (SurfaceCloseButtonProps & { + /** 자식 컴포넌트에 Close 동작을 위임할지 여부입니다. */ + asChild?: false; + }) + | (SurfaceCloseBaseButtonProps & SurfaceCloseAsChildProps); + +/** Surface 내부 Context가 하위 컴포넌트에 제공하는 상태입니다. */ +interface SurfaceState { + focusTrap: boolean; + isOpen: boolean; + restoreFocus: boolean; + scrollLock: boolean; + variant: SurfaceType; +} + +/** Surface 내부 Context가 하위 컴포넌트에 제공하는 액션입니다. */ +interface SurfaceActions { + close: (reason: SurfaceCloseReason) => void; + toggle: (reason?: SurfaceOpenChangeReason) => void; +} + +/** Surface 내부 Context에서 사용하는 자동 생성 id 모음입니다. */ +interface SurfaceMeta { + contentId: string; +} + +/** Surface compound 하위 컴포넌트가 공유하는 내부 Context 값입니다. */ +interface SurfaceContextValue { + actions: SurfaceActions; + meta: SurfaceMeta; + state: SurfaceState; +} + +export type { + SurfaceActions, + SurfaceBodyProps, + SurfaceCloseProps, + SurfaceCloseMeta, + SurfaceCloseReason, + SurfaceContextValue, + SurfaceContentProps, + SurfaceFooterProps, + SurfaceHeaderProps, + SurfaceOverlayProps, + SurfaceOpenChangeMeta, + SurfaceOpenChangeReason, + SurfaceMeta, + SurfacePortalProps, + SurfaceProps, + SurfaceState, + SurfaceType, + SurfaceTriggerProps, +}; diff --git a/src/shared/ui/surface/SurfaceClose.tsx b/src/shared/ui/surface/SurfaceClose.tsx new file mode 100644 index 0000000..f54d9bd --- /dev/null +++ b/src/shared/ui/surface/SurfaceClose.tsx @@ -0,0 +1,89 @@ +'use client'; + +import type { ComponentPropsWithRef } from 'react'; + +import { cloneSlot, getSingleSlotChild, getSlotProps } from '@/shared/ui/utils/Slot'; + +import { useSurfaceContext } from './SurfaceContext'; + +import type { SurfaceCloseProps } from './Surface.types'; + +interface SurfaceCloseChildProps { + 'aria-label'?: string; + className?: string; + onClick?: ComponentPropsWithRef<'button'>['onClick']; +} + +const getCloseButtonProps = (props: Extract) => { + const buttonProps = { ...props }; + + delete buttonProps.asChild; + return buttonProps; +}; + +/** + * ## Surface.Close + * + * @description + * Surface 닫기 요청을 발생시키는 컴포넌트입니다. + * + * ### 주요 내용 + * + * Surface의 닫기 정책과 무관하게 항상 닫기를 요청합니다. + * + * ### 접근성 + * + * `aria-label`은 필수이며, `asChild` 사용 시 자식 요소에 전달됩니다. + * + * @example + * ```tsx + * + * + * + * + * + * + * + * ``` + */ +export function SurfaceClose(props: SurfaceCloseProps) { + const { actions } = useSurfaceContext(); + + const createHandleClick = + ( + onClick?: SurfaceCloseChildProps['onClick'], + childOnClick?: SurfaceCloseChildProps['onClick'] + ): NonNullable => + (event) => { + childOnClick?.(event); + onClick?.(event); + actions.close('close-button'); + }; + + if (props.asChild) { + const { children, onClick } = props; + const slotProps = getSlotProps(props); + const child = getSingleSlotChild(children, 'Surface.Close'); + + return cloneSlot(child, { + ...slotProps, + onClick: createHandleClick(onClick, child.props.onClick), + }); + } + + const { children, className, onClick, ref, ...buttonProps } = getCloseButtonProps(props); + + return ( + + ); +} diff --git a/src/shared/ui/surface/SurfaceContent.tsx b/src/shared/ui/surface/SurfaceContent.tsx new file mode 100644 index 0000000..ee90bf6 --- /dev/null +++ b/src/shared/ui/surface/SurfaceContent.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useCallback, useRef } from 'react'; +import type { Ref } from 'react'; + +import { cva } from 'class-variance-authority'; + +import { cn } from '@/shared/styles/utils/cn'; + +import { useSurfaceInteractions } from './hooks/useSurfaceInteractions'; +import { useSurfaceContext } from './SurfaceContext'; + +import type { SurfaceContentProps } from './Surface.types'; + +const surfaceContentVariants = cva( + [ + 'fixed z-(--z-index-surface-content) flex flex-col overflow-hidden bg-gray-800 text-white shadow-2xl outline-none', + 'transition-opacity duration-150', + ], + { + variants: { + state: { + closed: 'pointer-events-none opacity-0', + open: 'opacity-100', + }, + variant: { + modal: + 'top-1/2 left-1/2 max-h-[calc(100dvh-2rem)] w-[min(37.5rem,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-2xl', + panel: 'top-0 right-0 h-dvh w-[min(27.5rem,100vw)] rounded-l-2xl', + }, + }, + } +); + +const assignRef = (ref: Ref | undefined, value: TElement | null) => { + if (!ref) { + return; + } + + if (typeof ref === 'function') { + ref(value); + return; + } + + ref.current = value; +}; + +/** + * ## Surface.Content + * + * @description + * Surface의 실제 컨테이너를 렌더링합니다. `Surface`의 `variant`에 따라 중앙 모달 또는 + * 우측 패널 레이아웃을 적용합니다. + * + * ### 주요 내용 + * + * ESC 닫기, 외부 클릭 닫기, Focus Trap, Focus Restore, Scroll Lock을 이 컴포넌트에서 + * 연결합니다. dismiss 닫기 가능 여부는 Surface의 `canClose`, `closeOnEscape`, + * `closeOnOutsideClick` 정책을 따릅니다. + * + * ### 접근성 + * + * `role="dialog"`를 렌더링합니다. 접근성 이름은 `aria-label` 또는 `aria-labelledby`로 + * 명시합니다. + * Modal variant에서는 `aria-modal`을 적용합니다. + */ +export function SurfaceContent({ + children, + className, + onPointerDown, + ref, + ...props +}: SurfaceContentProps) { + const { actions, meta, state } = useSurfaceContext(); + const contentRef = useRef(null); + + const setContentRef = useCallback( + (element: HTMLDivElement | null) => { + contentRef.current = element; + assignRef(ref, element); + }, + [ref] + ); + + useSurfaceInteractions({ actions, contentRef, meta, state }); + + if (!state.isOpen) { + return null; + } + + return ( + + ); +} diff --git a/src/shared/ui/surface/SurfaceContext.ts b/src/shared/ui/surface/SurfaceContext.ts new file mode 100644 index 0000000..b0cbdb8 --- /dev/null +++ b/src/shared/ui/surface/SurfaceContext.ts @@ -0,0 +1,24 @@ +import { createContext, use } from 'react'; + +import type { SurfaceContextValue } from './Surface.types'; + +const SurfaceContext = createContext(null); + +/** + * ## useSurfaceContext + * + * @description + * Surface compound 하위 컴포넌트가 Surface의 상태, 액션, 접근성 id를 읽는 내부 hook입니다. + * Surface 밖에서 사용하면 명확한 에러를 발생시켜 잘못된 조합을 빠르게 확인할 수 있습니다. + */ +function useSurfaceContext() { + const context = use(SurfaceContext); + + if (!context) { + throw new Error('Surface compound components must be used within Surface.'); + } + + return context; +} + +export { SurfaceContext, useSurfaceContext }; diff --git a/src/shared/ui/surface/SurfaceOverlay.tsx b/src/shared/ui/surface/SurfaceOverlay.tsx new file mode 100644 index 0000000..cc5f31d --- /dev/null +++ b/src/shared/ui/surface/SurfaceOverlay.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { cn } from '@/shared/styles/utils/cn'; + +import { useSurfaceContext } from './SurfaceContext'; + +import type { SurfaceOverlayProps } from './Surface.types'; + +/** + * ## Surface.Overlay + * + * @description + * `variant="modal"`에서 배경을 덮는 overlay입니다. + * + * ### 주요 내용 + * + * Panel variant에서는 overlay가 필요 없으므로 렌더링하지 않습니다. Modal variant에서는 + * Content 뒤에 배치해 배경과 현재 Surface를 시각적으로 분리합니다. + */ +export function SurfaceOverlay({ className, ref, ...props }: SurfaceOverlayProps) { + const { state } = useSurfaceContext(); + + if (state.variant === 'panel' || !state.isOpen) { + return null; + } + + return ( +