-
Notifications
You must be signed in to change notification settings - Fork 0
[✨ Feat/#49] Surface 공통 컴포넌트 구현 #50
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
c2bb63f
✨ Feat: controllable state 훅 추가
Lseojeong 802b9e0
✨ Feat: Surface 닫기 인터랙션 훅 추가
Lseojeong 0a4e3e1
✨ Feat: Surface 접근성 훅 추가
Lseojeong bd14b9a
🔧 Chore: 공통 hooks export 추가
Lseojeong b37e42e
🎨 Style: z-index 토큰 추가
Lseojeong dccef11
♻️ Refactor: textarea z-index 토큰 적용
Lseojeong c5da715
✨ Feat: Surface 공통 컴포넌트 구현
Lseojeong 51a5127
📝 Docs: Surface Storybook 예제 추가
Lseojeong 282d706
♻️ Refactor: Surface 닫기 정책 정리
Lseojeong 4300a3b
♻️ Refactor: 스토리 예제 주요 액션 제외 제거
Lseojeong 26d3957
📝 Docs: Surface Storybook Docs 설정 추가
Lseojeong 8b56cda
♻️ Refactor: TextAreaField z-index 방식 변경
Lseojeong 5dd92e7
♻️ Refactor: Surface 관련 공개 범위 정리
Lseojeong 0d1db45
🐛 Fix: asChild Fragment 사용 방지
Lseojeong c27f758
🚚 Rename: Surface stack hook 위치 정리
Lseojeong a261de5
🐛 Fix: controllable state 연속 updater 처리
Lseojeong 29203f3
♻️ Refactor: 공통 controllable state 훅 적용
Lseojeong 110c5d3
🐛 Fix: focus trap 외부 포커스 복귀 처리
Lseojeong c32f27a
🐛 Fix: Surface dirty 예제 메시지 초기화
Lseojeong 3d1f34b
🐛 Fix: Surface 스토리북 컨트롤 연결
Lseojeong 90e8e7b
🐛 Fix: Surface Close 접근성 이름 보장
Lseojeong 7d06274
🐛 Fix: Surface Close 닫기 계약 일치
Lseojeong f0aed87
🐛 Fix: Surface Portal null container 처리
Lseojeong 26d28ab
♻️ Refactor: Surface 섹션 의미 요소 적용
Lseojeong d31353a
🐛 Fix: Accordion 토글 함수형 업데이트 적용
Lseojeong 5ea98a8
♻️ Refactor: Surface 구조 단순화
Lseojeong e8f8114
♻️ Refactor: Surface 버튼 props 정리
Lseojeong 8367269
🐛 Fix: Surface asChild props 보존
Lseojeong File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { useCallback, useRef, useState } from 'react'; | ||
| import type { Dispatch, SetStateAction } from 'react'; | ||
|
|
||
| interface UseControllableStateParams<TValue> { | ||
| 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<TValue>({ | ||
| defaultValue, | ||
| onChange, | ||
| value, | ||
| }: UseControllableStateParams<TValue>) { | ||
| const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue); | ||
| const uncontrolledValueRef = useRef(uncontrolledValue); | ||
| const isControlled = value !== undefined; | ||
| const currentValue = isControlled ? value : uncontrolledValue; | ||
|
|
||
| const setValue: Dispatch<SetStateAction<TValue>> = 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] | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ); | ||
|
|
||
| return [currentValue, setValue, isControlled] as const; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLElement | null>(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(); | ||
| } | ||
| }, | ||
| [] | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<HTMLDivElement>(); | ||
|
|
||
| useFocusTrap({ | ||
| ref: containerRef, | ||
| }); | ||
|
|
||
| return ( | ||
| <> | ||
| <button type="button">외부 버튼</button> | ||
| <div ref={containerRef} tabIndex={-1}> | ||
| <button type="button">첫 번째 버튼</button> | ||
| <button type="button">마지막 버튼</button> | ||
| </div> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| 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(<FocusTrapFixture />); | ||
|
|
||
| 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(<FocusTrapFixture />); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.