From c2bb63f2c74fe18b58f90e64caeaac0d8f1d3595 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 01:18:03 +0900 Subject: [PATCH 01/28] =?UTF-8?q?=E2=9C=A8=20Feat:=20controllable=20state?= =?UTF-8?q?=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/hooks/useControllableState.test.ts | 86 +++++++++++++++++++ src/shared/hooks/useControllableState.ts | 62 +++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 src/shared/hooks/useControllableState.test.ts create mode 100644 src/shared/hooks/useControllableState.ts diff --git a/src/shared/hooks/useControllableState.test.ts b/src/shared/hooks/useControllableState.test.ts new file mode 100644 index 0000000..2792b9a --- /dev/null +++ b/src/shared/hooks/useControllableState.test.ts @@ -0,0 +1,86 @@ +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('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..a8e8b38 --- /dev/null +++ b/src/shared/hooks/useControllableState.ts @@ -0,0 +1,62 @@ +import { useCallback, 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 isControlled = value !== undefined; + const currentValue = isControlled ? value : uncontrolledValue; + + const setValue: Dispatch> = useCallback( + (nextValueOrUpdater) => { + const nextValue = + typeof nextValueOrUpdater === 'function' + ? (nextValueOrUpdater as (previousValue: TValue) => TValue)(currentValue) + : nextValueOrUpdater; + + if (Object.is(currentValue, nextValue)) { + return; + } + + if (!isControlled) { + setUncontrolledValue(nextValue); + } + + onChange?.(nextValue); + }, + [currentValue, isControlled, onChange] + ); + + return [currentValue, setValue, isControlled] as const; +} From 802b9e06e1ed34e13c761bb7eb3c57e932d3fed4 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 01:18:36 +0900 Subject: [PATCH 02/28] =?UTF-8?q?=E2=9C=A8=20Feat:=20Surface=20=EB=8B=AB?= =?UTF-8?q?=EA=B8=B0=20=EC=9D=B8=ED=84=B0=EB=9E=99=EC=85=98=20=ED=9B=85=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/hooks/useEscapeKey.ts | 44 +++++++++++++++++++++++ src/shared/hooks/useOutsideClick.ts | 54 +++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/shared/hooks/useEscapeKey.ts create mode 100644 src/shared/hooks/useOutsideClick.ts 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/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]); +} From 0a4e3e13b469c67471ee5d82bad21c78f9f48e6b Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 01:18:53 +0900 Subject: [PATCH 03/28] =?UTF-8?q?=E2=9C=A8=20Feat:=20Surface=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=84=B1=20=ED=9B=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/hooks/useFocusRestore.ts | 40 +++++++++++++ src/shared/hooks/useFocusTrap.ts | 92 +++++++++++++++++++++++++++++ src/shared/hooks/useScrollLock.ts | 38 ++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 src/shared/hooks/useFocusRestore.ts create mode 100644 src/shared/hooks/useFocusTrap.ts create mode 100644 src/shared/hooks/useScrollLock.ts 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.ts b/src/shared/hooks/useFocusTrap.ts new file mode 100644 index 0000000..62fc09c --- /dev/null +++ b/src/shared/hooks/useFocusTrap.ts @@ -0,0 +1,92 @@ +import { useEffect } from 'react'; +import type { RefObject } from 'react'; + +interface UseFocusTrapParams { + enabled?: boolean; + ref: RefObject; +} + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'area[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'iframe', + 'object', + 'embed', + '[contenteditable="true"]', + '[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]; + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + return; + } + + if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [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]); +} From bd14b9a493e5fcd9cd337f6b51e8488a3e3c91f9 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 01:19:07 +0900 Subject: [PATCH 04/28] =?UTF-8?q?=F0=9F=94=A7=20Chore:=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20hooks=20export=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/hooks/index.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/shared/hooks/index.ts 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'; From b37e42e6d5e6983960799b5b9c90924c9c714433 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 01:19:19 +0900 Subject: [PATCH 05/28] =?UTF-8?q?=F0=9F=8E=A8=20Style:=20z-index=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/styles/base/z-index.css | 10 ++++++++++ src/shared/styles/globals.css | 1 + 2 files changed, 11 insertions(+) create mode 100644 src/shared/styles/base/z-index.css 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 { From dccef11e965631547e55487e86dd4983e7a6c272 Mon Sep 17 00:00:00 2001 From: Leeseojeong Date: Mon, 29 Jun 2026 01:19:28 +0900 Subject: [PATCH 06/28] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20textarea?= =?UTF-8?q?=20z-index=20=ED=86=A0=ED=81=B0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/textarea/TextAreaField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/shared/ui/textarea/TextAreaField.tsx b/src/shared/ui/textarea/TextAreaField.tsx index 8cc6010..25e1b3c 100644 --- a/src/shared/ui/textarea/TextAreaField.tsx +++ b/src/shared/ui/textarea/TextAreaField.tsx @@ -99,7 +99,7 @@ export function TextAreaField({ {showCount ? (

Date: Mon, 29 Jun 2026 01:19:42 +0900 Subject: [PATCH 07/28] =?UTF-8?q?=E2=9C=A8=20Feat:=20Surface=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/ui/surface/Surface.tsx | 180 +++++++++++++++++++++ src/shared/ui/surface/Surface.types.ts | 187 ++++++++++++++++++++++ src/shared/ui/surface/SurfaceClose.tsx | 111 +++++++++++++ src/shared/ui/surface/SurfaceContent.tsx | 151 +++++++++++++++++ src/shared/ui/surface/SurfaceContext.ts | 24 +++ src/shared/ui/surface/SurfaceOverlay.tsx | 39 +++++ src/shared/ui/surface/SurfacePortal.tsx | 30 ++++ src/shared/ui/surface/SurfaceSections.tsx | 83 ++++++++++ src/shared/ui/surface/SurfaceStack.ts | 95 +++++++++++ src/shared/ui/surface/SurfaceTrigger.tsx | 120 ++++++++++++++ src/shared/ui/surface/index.ts | 15 ++ 11 files changed, 1035 insertions(+) create mode 100644 src/shared/ui/surface/Surface.tsx create mode 100644 src/shared/ui/surface/Surface.types.ts create mode 100644 src/shared/ui/surface/SurfaceClose.tsx create mode 100644 src/shared/ui/surface/SurfaceContent.tsx create mode 100644 src/shared/ui/surface/SurfaceContext.ts create mode 100644 src/shared/ui/surface/SurfaceOverlay.tsx create mode 100644 src/shared/ui/surface/SurfacePortal.tsx create mode 100644 src/shared/ui/surface/SurfaceSections.tsx create mode 100644 src/shared/ui/surface/SurfaceStack.ts create mode 100644 src/shared/ui/surface/SurfaceTrigger.tsx create mode 100644 src/shared/ui/surface/index.ts diff --git a/src/shared/ui/surface/Surface.tsx b/src/shared/ui/surface/Surface.tsx new file mode 100644 index 0000000..23d2085 --- /dev/null +++ b/src/shared/ui/surface/Surface.tsx @@ -0,0 +1,180 @@ +'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'; + +/** + * ## 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이면 Close, ESC, outside click 닫기 요청을 차단합니다. + * @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 isCloseAllowed = useCallback( + (reason: SurfaceCloseReason) => { + if (!canClose) { + return false; + } + + if (reason === 'escape-key' && !closeOnEscape) { + return false; + } + + if (reason === 'outside-click' && !closeOnOutsideClick) { + return false; + } + + return true; + }, + [canClose, closeOnEscape, closeOnOutsideClick] + ); + + const requestOpenChange = useCallback( + (nextOpen: boolean, reason: SurfaceOpenChangeReason = 'programmatic') => { + if (!nextOpen) { + const closeReason = reason as SurfaceCloseReason; + + if (!isCloseAllowed(closeReason)) { + onClosePrevented?.({ reason: closeReason }); + return; + } + } + + if (Object.is(isOpen, nextOpen)) { + return; + } + + setIsOpen(nextOpen); + onOpenChange?.(nextOpen, { reason }); + }, + [isCloseAllowed, 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..c1c2686 --- /dev/null +++ b/src/shared/ui/surface/Surface.types.ts @@ -0,0 +1,187 @@ +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 { + /** 닫기 요청을 전역적으로 허용할지 여부입니다. */ + 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']; + }>; + className?: string; + onClick?: ComponentPropsWithRef<'button'>['onClick']; +} + +/** Surface.Trigger props입니다. */ +type SurfaceTriggerProps = + | (SurfaceTriggerButtonProps & { + /** 자식 컴포넌트에 Trigger 동작과 ARIA 속성을 위임할지 여부입니다. */ + asChild?: false; + children: ReactNode; + }) + | 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, Body, Footer가 공유하는 props입니다. */ +interface SurfaceSectionProps extends ComponentPropsWithRef<'div'> { + children: ReactNode; +} + +type SurfaceCloseBaseButtonProps = Omit< + ComponentPropsWithRef<'button'>, + 'aria-label' | 'children' | 'type' +>; + +type SurfaceCloseButtonProps = SurfaceCloseBaseButtonProps & { + /** 닫기 버튼의 텍스트 또는 아이콘입니다. */ + children: ReactNode; + 'aria-label'?: string; +}; + +interface SurfaceCloseAsChildProps { + asChild: true; + children: ReactElement<{ + className?: string; + onClick?: ComponentPropsWithRef<'button'>['onClick']; + }>; + className?: string; + onClick?: ComponentPropsWithRef<'button'>['onClick']; +} + +type SurfaceCloseProps = + | (SurfaceCloseButtonProps & { + /** 자식 컴포넌트에 Close 동작을 위임할지 여부입니다. */ + asChild?: false; + }) + | 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 { + SurfaceCloseMeta, + SurfaceCloseReason, + SurfaceContextValue, + SurfaceCloseProps, + SurfaceContentProps, + SurfaceOverlayProps, + SurfaceOpenChangeMeta, + SurfaceOpenChangeReason, + SurfacePortalProps, + SurfaceProps, + SurfaceSectionProps, + SurfaceType, + SurfaceTriggerProps, +}; diff --git a/src/shared/ui/surface/SurfaceClose.tsx b/src/shared/ui/surface/SurfaceClose.tsx new file mode 100644 index 0000000..da44040 --- /dev/null +++ b/src/shared/ui/surface/SurfaceClose.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { Children, cloneElement, isValidElement } from 'react'; +import type { ComponentPropsWithRef, ReactElement } from 'react'; + +import { cn } from '@/shared/styles/utils/cn'; + +import { useSurfaceContext } from './SurfaceContext'; + +import type { SurfaceCloseProps } from './Surface.types'; + +interface SurfaceCloseChildProps { + className?: string; + onClick?: ComponentPropsWithRef<'button'>['onClick']; +} + +const getSingleCloseChild = (children: SurfaceCloseProps['children']) => { + if (Children.count(children) !== 1) { + throw new Error('Surface.Close with asChild must receive exactly one React element child.'); + } + + const child = Children.only(children); + + if (!isValidElement(child)) { + throw new Error('Surface.Close with asChild must receive a valid React element child.'); + } + + return child as ReactElement; +}; + +/** + * ## Surface.Close + * + * @description + * Surface 닫기 요청을 발생시키는 컴포넌트입니다. + * + * ### 주요 내용 + * + * 기본적으로 Surface의 닫기 정책(`canClose`)을 따릅니다. + * + * ### 접근성 + * + * 아이콘 버튼은 공통 Button의 `iconOnly`와 `aria-label`을 사용합니다. + * + * @example + * ```tsx + * + * + * + * + * + * + * + * ``` + */ +export function SurfaceClose(props: SurfaceCloseProps) { + const { actions } = useSurfaceContext(); + const { children, className, onClick } = props; + + const createHandleClick = + ( + childOnClick?: SurfaceCloseChildProps['onClick'] + ): NonNullable => + (event) => { + childOnClick?.(event); + + if (event.defaultPrevented) { + return; + } + + onClick?.(event); + + if (event.defaultPrevented) { + return; + } + + actions.close('close-button'); + }; + + if (props.asChild) { + const child = getSingleCloseChild(children); + + return cloneElement(child, { + className: cn(child.props.className, className), + onClick: createHandleClick(child.props.onClick), + }); + } + + const { + asChild: _asChild, + children: _children, + className: _className, + onClick: _onClick, + ref, + ...buttonProps + } = props; + + return ( + + ); +} diff --git a/src/shared/ui/surface/SurfaceContent.tsx b/src/shared/ui/surface/SurfaceContent.tsx new file mode 100644 index 0000000..d7643b8 --- /dev/null +++ b/src/shared/ui/surface/SurfaceContent.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useCallback, useRef } from 'react'; +import type { Ref } from 'react'; + +import { cva } from 'class-variance-authority'; + +import { + useEscapeKey, + useFocusRestore, + useFocusTrap, + useOutsideClick, + useScrollLock, +} from '@/shared/hooks'; +import { cn } from '@/shared/styles/utils/cn'; + +import { useSurfaceContext } from './SurfaceContext'; +import { useSurfaceStackItem } from './SurfaceStack'; + +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을 이 컴포넌트에서 + * 연결합니다. 닫기 가능 여부는 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 isTopSurface = useSurfaceStackItem(meta.contentId, state.isOpen); + + const setContentRef = useCallback( + (element: HTMLDivElement | null) => { + contentRef.current = element; + assignRef(ref, element); + }, + [ref] + ); + + useEscapeKey({ + enabled: state.isOpen && isTopSurface, + onEscapeKeyDown: () => actions.close('escape-key'), + }); + + useOutsideClick({ + enabled: state.isOpen && isTopSurface, + onOutsideClick: (event) => { + const target = event.target; + const trigger = target instanceof Element ? target.closest('[data-surface-trigger]') : null; + + if (trigger?.getAttribute('data-surface-trigger') === meta.contentId) { + return; + } + + actions.close('outside-click'); + }, + ref: contentRef, + }); + + useFocusRestore({ + enabled: state.isOpen && state.restoreFocus, + }); + + useFocusTrap({ + enabled: state.isOpen && state.focusTrap && isTopSurface, + ref: contentRef, + }); + + useScrollLock({ + enabled: state.isOpen && state.scrollLock, + }); + + 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 ( +