Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c2bb63f
✨ Feat: controllable state 훅 추가
Lseojeong Jun 28, 2026
802b9e0
✨ Feat: Surface 닫기 인터랙션 훅 추가
Lseojeong Jun 28, 2026
0a4e3e1
✨ Feat: Surface 접근성 훅 추가
Lseojeong Jun 28, 2026
bd14b9a
🔧 Chore: 공통 hooks export 추가
Lseojeong Jun 28, 2026
b37e42e
🎨 Style: z-index 토큰 추가
Lseojeong Jun 28, 2026
dccef11
♻️ Refactor: textarea z-index 토큰 적용
Lseojeong Jun 28, 2026
c5da715
✨ Feat: Surface 공통 컴포넌트 구현
Lseojeong Jun 28, 2026
51a5127
📝 Docs: Surface Storybook 예제 추가
Lseojeong Jun 28, 2026
282d706
♻️ Refactor: Surface 닫기 정책 정리
Lseojeong Jun 28, 2026
4300a3b
♻️ Refactor: 스토리 예제 주요 액션 제외 제거
Lseojeong Jun 28, 2026
26d3957
📝 Docs: Surface Storybook Docs 설정 추가
Lseojeong Jun 29, 2026
8b56cda
♻️ Refactor: TextAreaField z-index 방식 변경
Lseojeong Jun 29, 2026
5dd92e7
♻️ Refactor: Surface 관련 공개 범위 정리
Lseojeong Jun 29, 2026
0d1db45
🐛 Fix: asChild Fragment 사용 방지
Lseojeong Jun 29, 2026
c27f758
🚚 Rename: Surface stack hook 위치 정리
Lseojeong Jun 29, 2026
a261de5
🐛 Fix: controllable state 연속 updater 처리
Lseojeong Jun 29, 2026
29203f3
♻️ Refactor: 공통 controllable state 훅 적용
Lseojeong Jun 29, 2026
110c5d3
🐛 Fix: focus trap 외부 포커스 복귀 처리
Lseojeong Jun 29, 2026
c32f27a
🐛 Fix: Surface dirty 예제 메시지 초기화
Lseojeong Jun 29, 2026
3d1f34b
🐛 Fix: Surface 스토리북 컨트롤 연결
Lseojeong Jun 29, 2026
90e8e7b
🐛 Fix: Surface Close 접근성 이름 보장
Lseojeong Jun 29, 2026
7d06274
🐛 Fix: Surface Close 닫기 계약 일치
Lseojeong Jun 29, 2026
f0aed87
🐛 Fix: Surface Portal null container 처리
Lseojeong Jun 29, 2026
26d28ab
♻️ Refactor: Surface 섹션 의미 요소 적용
Lseojeong Jun 29, 2026
d31353a
🐛 Fix: Accordion 토글 함수형 업데이트 적용
Lseojeong Jun 29, 2026
5ea98a8
♻️ Refactor: Surface 구조 단순화
Lseojeong Jun 29, 2026
e8f8114
♻️ Refactor: Surface 버튼 props 정리
Lseojeong Jun 29, 2026
8367269
🐛 Fix: Surface asChild props 보존
Lseojeong Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/shared/hooks/index.ts
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';
105 changes: 105 additions & 0 deletions src/shared/hooks/useControllableState.test.ts
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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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();
});
});
65 changes: 65 additions & 0 deletions src/shared/hooks/useControllableState.ts
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]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

return [currentValue, setValue, isControlled] as const;
}
44 changes: 44 additions & 0 deletions src/shared/hooks/useEscapeKey.ts
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]);
}
40 changes: 40 additions & 0 deletions src/shared/hooks/useFocusRestore.ts
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();
}
},
[]
);
}
64 changes: 64 additions & 0 deletions src/shared/hooks/useFocusTrap.test.tsx
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();
});
});
Loading
Loading