moru-krds-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/accordion/index.d.ts +63 -0
- package/dist/accordion/index.d.ts.map +1 -0
- package/dist/accordion/index.js +145 -0
- package/dist/accordion/index.js.map +1 -0
- package/dist/alert/index.d.ts +44 -0
- package/dist/alert/index.d.ts.map +1 -0
- package/dist/alert/index.js +66 -0
- package/dist/alert/index.js.map +1 -0
- package/dist/avatar/index.d.ts +102 -0
- package/dist/avatar/index.d.ts.map +1 -0
- package/dist/avatar/index.js +146 -0
- package/dist/avatar/index.js.map +1 -0
- package/dist/badge/index.d.ts +49 -0
- package/dist/badge/index.d.ts.map +1 -0
- package/dist/badge/index.js +41 -0
- package/dist/badge/index.js.map +1 -0
- package/dist/breadcrumb/index.d.ts +43 -0
- package/dist/breadcrumb/index.d.ts.map +1 -0
- package/dist/breadcrumb/index.js +43 -0
- package/dist/breadcrumb/index.js.map +1 -0
- package/dist/button/index.d.ts +78 -0
- package/dist/button/index.d.ts.map +1 -0
- package/dist/button/index.js +83 -0
- package/dist/button/index.js.map +1 -0
- package/dist/checkbox/index.d.ts +97 -0
- package/dist/checkbox/index.d.ts.map +1 -0
- package/dist/checkbox/index.js +110 -0
- package/dist/checkbox/index.js.map +1 -0
- package/dist/combobox/index.d.ts +135 -0
- package/dist/combobox/index.d.ts.map +1 -0
- package/dist/combobox/index.js +416 -0
- package/dist/combobox/index.js.map +1 -0
- package/dist/context-menu/index.d.ts +94 -0
- package/dist/context-menu/index.d.ts.map +1 -0
- package/dist/context-menu/index.js +399 -0
- package/dist/context-menu/index.js.map +1 -0
- package/dist/critical-alert/index.d.ts +39 -0
- package/dist/critical-alert/index.d.ts.map +1 -0
- package/dist/critical-alert/index.js +50 -0
- package/dist/critical-alert/index.js.map +1 -0
- package/dist/data-table/index.d.ts +94 -0
- package/dist/data-table/index.d.ts.map +1 -0
- package/dist/data-table/index.js +128 -0
- package/dist/data-table/index.js.map +1 -0
- package/dist/disclosure/index.d.ts +35 -0
- package/dist/disclosure/index.d.ts.map +1 -0
- package/dist/disclosure/index.js +54 -0
- package/dist/disclosure/index.js.map +1 -0
- package/dist/drawer/index.d.ts +106 -0
- package/dist/drawer/index.d.ts.map +1 -0
- package/dist/drawer/index.js +352 -0
- package/dist/drawer/index.js.map +1 -0
- package/dist/empty-state/index.d.ts +86 -0
- package/dist/empty-state/index.d.ts.map +1 -0
- package/dist/empty-state/index.js +138 -0
- package/dist/empty-state/index.js.map +1 -0
- package/dist/env.d.js +1 -0
- package/dist/env.d.js.map +1 -0
- package/dist/field/index.d.ts +149 -0
- package/dist/field/index.d.ts.map +1 -0
- package/dist/field/index.js +121 -0
- package/dist/field/index.js.map +1 -0
- package/dist/file-upload/index.d.ts +140 -0
- package/dist/file-upload/index.d.ts.map +1 -0
- package/dist/file-upload/index.js +249 -0
- package/dist/file-upload/index.js.map +1 -0
- package/dist/footer/index.d.ts +155 -0
- package/dist/footer/index.d.ts.map +1 -0
- package/dist/footer/index.js +186 -0
- package/dist/footer/index.js.map +1 -0
- package/dist/header/index.d.ts +170 -0
- package/dist/header/index.d.ts.map +1 -0
- package/dist/header/index.js +258 -0
- package/dist/header/index.js.map +1 -0
- package/dist/identifier/index.d.ts +38 -0
- package/dist/identifier/index.d.ts.map +1 -0
- package/dist/identifier/index.js +55 -0
- package/dist/identifier/index.js.map +1 -0
- package/dist/in-page-navigation/index.d.ts +71 -0
- package/dist/in-page-navigation/index.d.ts.map +1 -0
- package/dist/in-page-navigation/index.js +137 -0
- package/dist/in-page-navigation/index.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/input-group/index.d.ts +66 -0
- package/dist/input-group/index.d.ts.map +1 -0
- package/dist/input-group/index.js +85 -0
- package/dist/input-group/index.js.map +1 -0
- package/dist/lib/krds.d.ts +45 -0
- package/dist/lib/krds.d.ts.map +1 -0
- package/dist/lib/krds.js +11 -0
- package/dist/lib/krds.js.map +1 -0
- package/dist/lib/link.d.ts +34 -0
- package/dist/lib/link.d.ts.map +1 -0
- package/dist/lib/link.js +8 -0
- package/dist/lib/link.js.map +1 -0
- package/dist/main-menu/index.d.ts +123 -0
- package/dist/main-menu/index.d.ts.map +1 -0
- package/dist/main-menu/index.js +613 -0
- package/dist/main-menu/index.js.map +1 -0
- package/dist/masthead/index.d.ts +25 -0
- package/dist/masthead/index.d.ts.map +1 -0
- package/dist/masthead/index.js +21 -0
- package/dist/masthead/index.js.map +1 -0
- package/dist/modal/index.d.ts +105 -0
- package/dist/modal/index.d.ts.map +1 -0
- package/dist/modal/index.js +285 -0
- package/dist/modal/index.js.map +1 -0
- package/dist/pagination/index.d.ts +58 -0
- package/dist/pagination/index.d.ts.map +1 -0
- package/dist/pagination/index.js +82 -0
- package/dist/pagination/index.js.map +1 -0
- package/dist/progress/index.d.ts +58 -0
- package/dist/progress/index.d.ts.map +1 -0
- package/dist/progress/index.js +125 -0
- package/dist/progress/index.js.map +1 -0
- package/dist/radio/index.d.ts +111 -0
- package/dist/radio/index.d.ts.map +1 -0
- package/dist/radio/index.js +136 -0
- package/dist/radio/index.js.map +1 -0
- package/dist/resize/index.d.ts +118 -0
- package/dist/resize/index.d.ts.map +1 -0
- package/dist/resize/index.js +342 -0
- package/dist/resize/index.js.map +1 -0
- package/dist/scroll-area/index.d.ts +40 -0
- package/dist/scroll-area/index.d.ts.map +1 -0
- package/dist/scroll-area/index.js +52 -0
- package/dist/scroll-area/index.js.map +1 -0
- package/dist/select/index.d.ts +67 -0
- package/dist/select/index.d.ts.map +1 -0
- package/dist/select/index.js +61 -0
- package/dist/select/index.js.map +1 -0
- package/dist/separator/index.d.ts +47 -0
- package/dist/separator/index.d.ts.map +1 -0
- package/dist/separator/index.js +61 -0
- package/dist/separator/index.js.map +1 -0
- package/dist/side-navigation/index.d.ts +78 -0
- package/dist/side-navigation/index.d.ts.map +1 -0
- package/dist/side-navigation/index.js +188 -0
- package/dist/side-navigation/index.js.map +1 -0
- package/dist/skeleton/index.d.ts +76 -0
- package/dist/skeleton/index.d.ts.map +1 -0
- package/dist/skeleton/index.js +78 -0
- package/dist/skeleton/index.js.map +1 -0
- package/dist/skip-link/index.d.ts +46 -0
- package/dist/skip-link/index.d.ts.map +1 -0
- package/dist/skip-link/index.js +37 -0
- package/dist/skip-link/index.js.map +1 -0
- package/dist/spinner/index.d.ts +58 -0
- package/dist/spinner/index.d.ts.map +1 -0
- package/dist/spinner/index.js +36 -0
- package/dist/spinner/index.js.map +1 -0
- package/dist/step-indicator/index.d.ts +41 -0
- package/dist/step-indicator/index.d.ts.map +1 -0
- package/dist/step-indicator/index.js +63 -0
- package/dist/step-indicator/index.js.map +1 -0
- package/dist/structured-list/index.d.ts +150 -0
- package/dist/structured-list/index.d.ts.map +1 -0
- package/dist/structured-list/index.js +194 -0
- package/dist/structured-list/index.js.map +1 -0
- package/dist/tab/index.d.ts +108 -0
- package/dist/tab/index.d.ts.map +1 -0
- package/dist/tab/index.js +198 -0
- package/dist/tab/index.js.map +1 -0
- package/dist/table/index.d.ts +75 -0
- package/dist/table/index.d.ts.map +1 -0
- package/dist/table/index.js +91 -0
- package/dist/table/index.js.map +1 -0
- package/dist/tag/index.d.ts +71 -0
- package/dist/tag/index.d.ts.map +1 -0
- package/dist/tag/index.js +45 -0
- package/dist/tag/index.js.map +1 -0
- package/dist/text-input/index.d.ts +69 -0
- package/dist/text-input/index.d.ts.map +1 -0
- package/dist/text-input/index.js +70 -0
- package/dist/text-input/index.js.map +1 -0
- package/dist/text-list/index.d.ts +71 -0
- package/dist/text-list/index.d.ts.map +1 -0
- package/dist/text-list/index.js +79 -0
- package/dist/text-list/index.js.map +1 -0
- package/dist/textarea/index.d.ts +29 -0
- package/dist/textarea/index.d.ts.map +1 -0
- package/dist/textarea/index.js +69 -0
- package/dist/textarea/index.js.map +1 -0
- package/dist/toast/index.d.ts +76 -0
- package/dist/toast/index.d.ts.map +1 -0
- package/dist/toast/index.js +183 -0
- package/dist/toast/index.js.map +1 -0
- package/dist/tooltip/index.d.ts +84 -0
- package/dist/tooltip/index.d.ts.map +1 -0
- package/dist/tooltip/index.js +329 -0
- package/dist/tooltip/index.js.map +1 -0
- package/package.json +57 -0
- package/src/accordion/index.tsx +251 -0
- package/src/alert/index.tsx +111 -0
- package/src/avatar/index.tsx +272 -0
- package/src/badge/index.tsx +91 -0
- package/src/breadcrumb/index.tsx +125 -0
- package/src/button/index.tsx +190 -0
- package/src/checkbox/index.tsx +247 -0
- package/src/combobox/index.tsx +700 -0
- package/src/context-menu/index.tsx +623 -0
- package/src/critical-alert/index.tsx +102 -0
- package/src/data-table/index.tsx +289 -0
- package/src/disclosure/index.tsx +87 -0
- package/src/drawer/index.tsx +516 -0
- package/src/empty-state/index.tsx +256 -0
- package/src/env.d.ts +10 -0
- package/src/field/index.tsx +307 -0
- package/src/file-upload/index.tsx +492 -0
- package/src/footer/index.tsx +393 -0
- package/src/header/index.tsx +498 -0
- package/src/identifier/index.tsx +96 -0
- package/src/in-page-navigation/index.tsx +258 -0
- package/src/index.ts +72 -0
- package/src/input-group/index.tsx +197 -0
- package/src/lib/krds.ts +75 -0
- package/src/lib/link.tsx +35 -0
- package/src/main-menu/index.tsx +912 -0
- package/src/masthead/index.tsx +50 -0
- package/src/modal/index.tsx +457 -0
- package/src/pagination/index.tsx +198 -0
- package/src/progress/index.tsx +198 -0
- package/src/radio/index.tsx +283 -0
- package/src/resize/index.tsx +541 -0
- package/src/scroll-area/index.tsx +96 -0
- package/src/select/index.tsx +136 -0
- package/src/separator/index.tsx +107 -0
- package/src/side-navigation/index.tsx +336 -0
- package/src/skeleton/index.tsx +172 -0
- package/src/skip-link/index.tsx +89 -0
- package/src/spinner/index.tsx +98 -0
- package/src/step-indicator/index.tsx +122 -0
- package/src/structured-list/index.tsx +395 -0
- package/src/tab/index.tsx +348 -0
- package/src/table/index.tsx +186 -0
- package/src/tag/index.tsx +127 -0
- package/src/text-input/index.tsx +156 -0
- package/src/text-list/index.tsx +177 -0
- package/src/textarea/index.tsx +113 -0
- package/src/toast/index.tsx +311 -0
- package/src/tooltip/index.tsx +496 -0
- package/styles/krds-tailwind.css +335 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useId,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
type ComponentPropsWithRef,
|
|
12
|
+
type CSSProperties,
|
|
13
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
14
|
+
type MouseEvent as ReactMouseEvent,
|
|
15
|
+
type ReactNode,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import { createPortal } from 'react-dom';
|
|
18
|
+
import { cx } from '../lib/krds';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 서랍(측면 패널) — KRDS 에 없다. 모양은 KRDS 토큰, 동작은 `Modal` 과 같은 보충 부품이다.
|
|
22
|
+
*
|
|
23
|
+
* 목록 옆에서 필터·상세를 여는 자리. 폭은 `size` 3단에서 고른다(픽셀을 넘기지 마라).
|
|
24
|
+
* 확정·취소는 `Drawer.Footer` 에 두고 제출이 가장 오른쪽이다.
|
|
25
|
+
* 뒤 화면을 계속 쓸 수 있어야 하면 `modal={false}` — 이때는 초점을 가두지 않는다.
|
|
26
|
+
* 화면 한복판에 띄우는 대화상자는 `ui/modal` 이다.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* <Drawer.Root open={open} onClose={close} side="right" size="small">
|
|
30
|
+
* <Drawer.Header extra={<Drawer.Close />}>검색 조건</Drawer.Header>
|
|
31
|
+
* <Drawer.Body>…필터 폼…</Drawer.Body>
|
|
32
|
+
* <Drawer.Footer split>
|
|
33
|
+
* <Button variant="text" onClick={reset}>초기화</Button>
|
|
34
|
+
* <Button variant="primary" onClick={apply}>적용</Button>
|
|
35
|
+
* </Drawer.Footer>
|
|
36
|
+
* </Drawer.Root>
|
|
37
|
+
*
|
|
38
|
+
* 자세히: docs/krds/09-부품-노트.md#서랍
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** 어느 쪽에서 나오는가. 아래에서 올라오는 것은 킷의 바텀시트(`ui/modal` 의 `type`) 자리다. */
|
|
42
|
+
export type DrawerSide = 'left' | 'right';
|
|
43
|
+
|
|
44
|
+
/** 폭 3단 — 400 / 560 / 760px. 킷 모달과 같은 눈금이다. */
|
|
45
|
+
export type DrawerSize = 'small' | 'medium' | 'large';
|
|
46
|
+
|
|
47
|
+
const WIDTH: Record<DrawerSize, string> = {
|
|
48
|
+
small: '40rem', // 400px — 킷 --krds-modal--size-small
|
|
49
|
+
medium: '56rem', // 560px — 킷 --krds-modal--size-medium
|
|
50
|
+
large: '76rem', // 760px — 킷 --krds-modal--size-large
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** 전환 시간(ms). 킷 `--krds-transition-base` 와 같은 값이고 `duration-400` 과 짝이다. */
|
|
54
|
+
const TRANSITION_MS = 400;
|
|
55
|
+
|
|
56
|
+
/** 겹침 층. 킷 모달과 같은 값이라 서랍과 모달이 함께 열려도 순서가 뒤집히지 않는다. */
|
|
57
|
+
const Z_BASE = 1010;
|
|
58
|
+
|
|
59
|
+
/** 초점 가둠 대상. `Modal` 과 같은 목록이다. */
|
|
60
|
+
const FOCUSABLE_SELECTOR = [
|
|
61
|
+
'a[href]',
|
|
62
|
+
'area[href]',
|
|
63
|
+
'button:not([disabled])',
|
|
64
|
+
'input:not([disabled]):not([type="hidden"])',
|
|
65
|
+
'select:not([disabled])',
|
|
66
|
+
'textarea:not([disabled])',
|
|
67
|
+
'details > summary',
|
|
68
|
+
'audio[controls]',
|
|
69
|
+
'video[controls]',
|
|
70
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
71
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
72
|
+
].join(',');
|
|
73
|
+
|
|
74
|
+
function focusables(root: HTMLElement): HTMLElement[] {
|
|
75
|
+
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
|
|
76
|
+
(el) =>
|
|
77
|
+
!el.hasAttribute('inert') &&
|
|
78
|
+
(el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 열려 있는 서랍 수. 겹쳐 열릴 때 z-index 를 킷 모달과 같은 방식으로 올린다. */
|
|
83
|
+
let openDrawers = 0;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 뒤 문서 스크롤 잠금. 킷의 `body.scroll-no` 는 CSS 규칙이 없어 `body.style.overflow` 를 직접 다룬다.
|
|
87
|
+
* 셈은 이 모듈 안에서만 유효하다 — 모달과 겹쳐 열리면 어긋날 수 있다.
|
|
88
|
+
*/
|
|
89
|
+
let scrollLocks = 0;
|
|
90
|
+
let savedOverflow = '';
|
|
91
|
+
|
|
92
|
+
function lockScroll() {
|
|
93
|
+
if (scrollLocks++ > 0) return;
|
|
94
|
+
savedOverflow = document.body.style.overflow;
|
|
95
|
+
document.body.style.overflow = 'hidden';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function unlockScroll() {
|
|
99
|
+
scrollLocks = Math.max(0, scrollLocks - 1);
|
|
100
|
+
if (scrollLocks > 0) return;
|
|
101
|
+
document.body.style.overflow = savedOverflow;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type DrawerContextValue = {
|
|
105
|
+
/** 제목 요소의 id. `Drawer.Header` 가 붙이고 `Drawer.Root` 가 `aria-labelledby` 로 가리킨다. */
|
|
106
|
+
titleId: string;
|
|
107
|
+
close: () => void;
|
|
108
|
+
/** 제목이 실제로 있는지 알린다 — 없으면 `aria-labelledby` 가 허공을 가리킨다. */
|
|
109
|
+
registerTitle: (has: boolean) => void;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const DrawerContext = createContext<DrawerContextValue | null>(null);
|
|
113
|
+
|
|
114
|
+
function useDrawerContext(name: string): DrawerContextValue {
|
|
115
|
+
const ctx = useContext(DrawerContext);
|
|
116
|
+
if (!ctx) throw new Error(`<${name}> 은 <Drawer.Root> 안에서만 쓸 수 있다.`);
|
|
117
|
+
return ctx;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type DrawerRootProps = ComponentPropsWithRef<'div'> & {
|
|
121
|
+
/** 열림 여부. 상태는 화면 쪽이 쥔다. */
|
|
122
|
+
open: boolean;
|
|
123
|
+
/** 닫아 달라는 요청 — Esc·딤 클릭·`Drawer.Close` 에서 온다. */
|
|
124
|
+
onClose: () => void;
|
|
125
|
+
/** 나오는 방향. 기본 오른쪽. */
|
|
126
|
+
side?: DrawerSide;
|
|
127
|
+
/** 기본 small(400px). 3단에서 고른다. */
|
|
128
|
+
size?: DrawerSize;
|
|
129
|
+
/** 딤을 깔고 초점을 가두는가. 기본 true — false 면 뒤 화면을 계속 쓸 수 있다. */
|
|
130
|
+
modal?: boolean;
|
|
131
|
+
/** Esc 로 닫을 수 있는가. */
|
|
132
|
+
dismissible?: boolean;
|
|
133
|
+
/** 딤을 눌렀을 때 닫는가. 기본 true — 값을 잃으면 곤란한 서랍에서는 꺼라. */
|
|
134
|
+
closeOnBackdrop?: boolean;
|
|
135
|
+
/** 열릴 때 첫 초점. 기본 `panel`(보조기술이 이름부터 읽는다) · `first` 는 첫 초점 요소로 보낸다. */
|
|
136
|
+
initialFocus?: 'panel' | 'first';
|
|
137
|
+
/** 포털 대상. 기본 `document.body`. */
|
|
138
|
+
container?: Element | null;
|
|
139
|
+
/** 패널(흰 면)에 붙일 클래스. 바깥틀은 `className` 이 받는다. */
|
|
140
|
+
panelClassName?: string;
|
|
141
|
+
/** 패널에 붙일 인라인 style. 폭을 3단 밖으로 바꿔야 할 때. */
|
|
142
|
+
panelStyle?: CSSProperties;
|
|
143
|
+
children?: ReactNode;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 서랍 바깥틀. 포털 · 초점 가둠 · Esc · 스크롤 잠금 · 초점 복귀를 여기서 한다.
|
|
148
|
+
*
|
|
149
|
+
* `Drawer.Header` 를 두면 `aria-labelledby` 가 자동으로 붙는다. 헤더 없이 쓸 거면
|
|
150
|
+
* `aria-label` 을 직접 넘겨라 — 대화상자에는 반드시 이름이 있어야 한다.
|
|
151
|
+
*/
|
|
152
|
+
export function DrawerRoot({
|
|
153
|
+
open,
|
|
154
|
+
onClose,
|
|
155
|
+
side = 'right',
|
|
156
|
+
size = 'small',
|
|
157
|
+
modal = true,
|
|
158
|
+
dismissible = true,
|
|
159
|
+
closeOnBackdrop = true,
|
|
160
|
+
initialFocus = 'panel',
|
|
161
|
+
container,
|
|
162
|
+
panelClassName,
|
|
163
|
+
panelStyle,
|
|
164
|
+
className,
|
|
165
|
+
style,
|
|
166
|
+
children,
|
|
167
|
+
onKeyDown,
|
|
168
|
+
onMouseDown,
|
|
169
|
+
...rest
|
|
170
|
+
}: DrawerRootProps) {
|
|
171
|
+
const reactId = useId();
|
|
172
|
+
const titleId = `${reactId}-title`;
|
|
173
|
+
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
174
|
+
const [mounted, setMounted] = useState(false);
|
|
175
|
+
/** DOM 에 붙어 있는가. 닫힌 뒤에도 미끄러져 나가는 동안 유지한다. */
|
|
176
|
+
const [present, setPresent] = useState(false);
|
|
177
|
+
/** 다 들어왔는가(= 딤이 보이고 패널이 제자리). 첫 그림 뒤에 켜야 전환이 돈다. */
|
|
178
|
+
const [entered, setEntered] = useState(false);
|
|
179
|
+
const [hasTitle, setHasTitle] = useState(false);
|
|
180
|
+
/** 몇 번째로 열린 서랍인가(1부터). */
|
|
181
|
+
const [stack, setStack] = useState(1);
|
|
182
|
+
|
|
183
|
+
// 초점 정책이 도중에 바뀌어도 열림 효과가 다시 돌지 않게 ref 로 읽는다.
|
|
184
|
+
const initialFocusRef = useRef(initialFocus);
|
|
185
|
+
initialFocusRef.current = initialFocus;
|
|
186
|
+
const modalRef = useRef(modal);
|
|
187
|
+
modalRef.current = modal;
|
|
188
|
+
|
|
189
|
+
// 서버에서는 포털을 만들지 않는다(hydration 어긋남 방지).
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
setMounted(true);
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
// 열림 — 스크롤 잠금 · 상태 · 초점 이동. 정리 단계에서 잠금 해제와 초점 복귀.
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!open) return;
|
|
197
|
+
const restore = document.activeElement as HTMLElement | null;
|
|
198
|
+
openDrawers += 1;
|
|
199
|
+
setStack(openDrawers);
|
|
200
|
+
setPresent(true);
|
|
201
|
+
// 잠갔는지를 지역 변수로 들고 있는다 — 열려 있는 동안 `modal` 이 바뀌어도
|
|
202
|
+
// 잠금과 해제가 짝을 이루게 한다(ref 를 정리 단계에서 다시 읽으면 한쪽만 돌 수 있다).
|
|
203
|
+
const didLock = modalRef.current;
|
|
204
|
+
if (didLock) lockScroll();
|
|
205
|
+
|
|
206
|
+
// 프레임을 두 번 넘겨야 브라우저가 「밖 → 안」을 전환으로 본다.
|
|
207
|
+
let inner = 0;
|
|
208
|
+
const outer = requestAnimationFrame(() => {
|
|
209
|
+
inner = requestAnimationFrame(() => setEntered(true));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// 서랍은 visibility 를 쓰지 않아 전환을 기다리지 않고 바로 초점을 줄 수 있다.
|
|
213
|
+
const focusTimer = window.setTimeout(() => {
|
|
214
|
+
const panel = panelRef.current;
|
|
215
|
+
if (!panel) return;
|
|
216
|
+
if (initialFocusRef.current === 'first') {
|
|
217
|
+
const first = focusables(panel)[0];
|
|
218
|
+
if (first) {
|
|
219
|
+
first.focus();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
panel.focus();
|
|
224
|
+
}, 0);
|
|
225
|
+
|
|
226
|
+
return () => {
|
|
227
|
+
cancelAnimationFrame(outer);
|
|
228
|
+
cancelAnimationFrame(inner);
|
|
229
|
+
window.clearTimeout(focusTimer);
|
|
230
|
+
openDrawers = Math.max(0, openDrawers - 1);
|
|
231
|
+
setEntered(false);
|
|
232
|
+
if (didLock) unlockScroll();
|
|
233
|
+
// 서랍을 열었던 요소로 초점 복귀 — 킷 도움 패널의 `lastFocusedButton.focus()` 에 해당한다.
|
|
234
|
+
restore?.focus?.();
|
|
235
|
+
};
|
|
236
|
+
}, [open]);
|
|
237
|
+
|
|
238
|
+
// 닫힘 — 전환이 끝난 뒤에 DOM 에서 뗀다.
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (open || !present) return;
|
|
241
|
+
const timer = window.setTimeout(() => setPresent(false), TRANSITION_MS);
|
|
242
|
+
return () => window.clearTimeout(timer);
|
|
243
|
+
}, [open, present]);
|
|
244
|
+
|
|
245
|
+
const ctx = useMemo<DrawerContextValue>(
|
|
246
|
+
() => ({ titleId, close: onClose, registerTitle: setHasTitle }),
|
|
247
|
+
[titleId, onClose],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
|
251
|
+
onKeyDown?.(event);
|
|
252
|
+
if (event.defaultPrevented) return;
|
|
253
|
+
|
|
254
|
+
if (event.key === 'Escape' || event.key === 'Esc') {
|
|
255
|
+
if (!dismissible) return;
|
|
256
|
+
// 겹쳐 열린 경우 바깥 서랍·모달까지 닫히지 않도록 여기서 끊는다.
|
|
257
|
+
event.stopPropagation();
|
|
258
|
+
onClose();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// 딤 없는 서랍은 가두지 않는다 — 뒤 화면을 계속 쓸 수 있어야 하므로 Tab 이 나가야 한다.
|
|
263
|
+
if (!modal) return;
|
|
264
|
+
if (event.key !== 'Tab') return;
|
|
265
|
+
const panel = panelRef.current;
|
|
266
|
+
if (!panel) return;
|
|
267
|
+
const nodes = focusables(panel);
|
|
268
|
+
if (nodes.length === 0) {
|
|
269
|
+
event.preventDefault();
|
|
270
|
+
panel.focus();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const first = nodes[0];
|
|
274
|
+
const last = nodes[nodes.length - 1];
|
|
275
|
+
const active = document.activeElement;
|
|
276
|
+
if (event.shiftKey && (active === first || active === panel)) {
|
|
277
|
+
event.preventDefault();
|
|
278
|
+
last.focus();
|
|
279
|
+
} else if (!event.shiftKey && active === last) {
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
first.focus();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function handleMouseDown(event: ReactMouseEvent<HTMLDivElement>) {
|
|
286
|
+
onMouseDown?.(event);
|
|
287
|
+
if (event.defaultPrevented) return;
|
|
288
|
+
if (!modal) return;
|
|
289
|
+
// 패널 안을 누른 것은 딤 클릭이 아니다. `mousedown` 으로 보는 이유는 킷 모달과 같다 —
|
|
290
|
+
// 패널 안에서 시작해 딤에서 끝나는 드래그를 「바깥 클릭」으로 오해하지 않기 위해서다.
|
|
291
|
+
const target = event.target as Node | null;
|
|
292
|
+
if (target && panelRef.current?.contains(target)) return;
|
|
293
|
+
if (closeOnBackdrop && dismissible) {
|
|
294
|
+
onClose();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// 닫지 않기로 했으면 초점만 되돌린다 — 킷 모달의 바깥 클릭 동작이 이것이다.
|
|
298
|
+
panelRef.current?.focus();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!mounted || !present) return null;
|
|
302
|
+
|
|
303
|
+
const slideOut = side === 'right' ? 'translate-x-full' : '-translate-x-full';
|
|
304
|
+
|
|
305
|
+
return createPortal(
|
|
306
|
+
<DrawerContext.Provider value={ctx}>
|
|
307
|
+
<div
|
|
308
|
+
role="dialog"
|
|
309
|
+
aria-modal={modal ? true : undefined}
|
|
310
|
+
aria-labelledby={hasTitle ? titleId : undefined}
|
|
311
|
+
// 딤이 없을 때는 바깥틀이 화면을 덮은 채 클릭을 먹지 않게 한다(뒤 화면을 계속 쓴다).
|
|
312
|
+
className={cx('fixed inset-0', !modal && 'pointer-events-none', className)}
|
|
313
|
+
style={{ zIndex: Z_BASE + stack, ...style }}
|
|
314
|
+
onKeyDown={handleKeyDown}
|
|
315
|
+
onMouseDown={handleMouseDown}
|
|
316
|
+
{...rest}
|
|
317
|
+
>
|
|
318
|
+
{modal && (
|
|
319
|
+
<div
|
|
320
|
+
aria-hidden="true"
|
|
321
|
+
className={cx(
|
|
322
|
+
'absolute inset-0 bg-bg-dim transition-opacity duration-400 ease-in-out motion-reduce:transition-none',
|
|
323
|
+
entered ? 'opacity-100' : 'opacity-0',
|
|
324
|
+
)}
|
|
325
|
+
/>
|
|
326
|
+
)}
|
|
327
|
+
{/*
|
|
328
|
+
패널. tabIndex -1 로 초점만 받고 Tab 순서에는 들어가지 않는다.
|
|
329
|
+
`:focus` 테두리는 킷 모달(`.modal-content:focus`)과 같은 토큰
|
|
330
|
+
`--krds-box-shadow-outline-inset` 을 그대로 쓴다. 표기는 `ui/radio` 와 맞춘 임의 속성 형태다.
|
|
331
|
+
(선명한 화면 모드에서 이 토큰 색이 안 바뀌는 것은 킷 결함이다 —
|
|
332
|
+
`[data-krds-mode=high-contrast] :root` 는 :root 가 자기 후손일 수 없어 맞지 않는다.
|
|
333
|
+
킷 컴포넌트 전부가 같은 상태라 여기서만 따로 고치지 않는다. `ui/resize` 주석 참조.)
|
|
334
|
+
*/}
|
|
335
|
+
<div
|
|
336
|
+
ref={panelRef}
|
|
337
|
+
tabIndex={-1}
|
|
338
|
+
className={cx(
|
|
339
|
+
'pointer-events-auto absolute inset-y-0 flex h-full max-w-full flex-col bg-surface text-fg',
|
|
340
|
+
'transition-transform duration-400 ease-in-out motion-reduce:transition-none',
|
|
341
|
+
'focus:[box-shadow:var(--krds-box-shadow-outline-inset)] focus:outline-none',
|
|
342
|
+
side === 'right' ? 'right-0 border-l border-line' : 'left-0 border-r border-line',
|
|
343
|
+
!entered && slideOut,
|
|
344
|
+
panelClassName,
|
|
345
|
+
)}
|
|
346
|
+
style={{ width: WIDTH[size], ...panelStyle }}
|
|
347
|
+
>
|
|
348
|
+
{children}
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
</DrawerContext.Provider>,
|
|
352
|
+
container ?? document.body,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export type DrawerHeaderProps = ComponentPropsWithRef<'div'> & {
|
|
357
|
+
/** 제목 태그 단계. 기본 h2 — 화면의 제목 위계에 맞춘다. */
|
|
358
|
+
level?: 1 | 2 | 3 | 4 | 5 | 6;
|
|
359
|
+
/** 제목을 눈에서만 감춘다. 이름 자체는 남는다. */
|
|
360
|
+
hideTitle?: boolean;
|
|
361
|
+
/** 제목 요소에 붙일 클래스. */
|
|
362
|
+
titleClassName?: string;
|
|
363
|
+
/** 제목 오른쪽 자리. 닫기 버튼(`Drawer.Close`)을 여기에 둔다. */
|
|
364
|
+
extra?: ReactNode;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 제목 줄. 이 부품을 두면 서랍의 접근 가능한 이름이 자동으로 이어진다.
|
|
369
|
+
*
|
|
370
|
+
* @example
|
|
371
|
+
* <Drawer.Header extra={<Drawer.Close />}>검색 조건</Drawer.Header>
|
|
372
|
+
*/
|
|
373
|
+
export function DrawerHeader({
|
|
374
|
+
level = 2,
|
|
375
|
+
hideTitle,
|
|
376
|
+
titleClassName,
|
|
377
|
+
extra,
|
|
378
|
+
className,
|
|
379
|
+
children,
|
|
380
|
+
...rest
|
|
381
|
+
}: DrawerHeaderProps) {
|
|
382
|
+
const { titleId, registerTitle } = useDrawerContext('Drawer.Header');
|
|
383
|
+
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
registerTitle(true);
|
|
386
|
+
return () => registerTitle(false);
|
|
387
|
+
}, [registerTitle]);
|
|
388
|
+
|
|
389
|
+
const Heading = `h${level}` as 'h2';
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<div
|
|
393
|
+
className={cx(
|
|
394
|
+
'flex shrink-0 items-center justify-between gap-4 border-b border-line px-4 py-5 md:px-10',
|
|
395
|
+
className,
|
|
396
|
+
)}
|
|
397
|
+
{...rest}
|
|
398
|
+
>
|
|
399
|
+
<Heading
|
|
400
|
+
id={titleId}
|
|
401
|
+
className={cx(
|
|
402
|
+
hideTitle ? 'sr-only' : 'truncate text-heading-sm font-bold text-fg-bolder',
|
|
403
|
+
titleClassName,
|
|
404
|
+
)}
|
|
405
|
+
>
|
|
406
|
+
{children}
|
|
407
|
+
</Heading>
|
|
408
|
+
{extra}
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** 본문. 스크롤이 생기면 `tabindex="0"` 을 붙여 키보드로도 굴릴 수 있게 한다. */
|
|
414
|
+
export function DrawerBody({ className, children, ref, ...rest }: ComponentPropsWithRef<'div'>) {
|
|
415
|
+
const innerRef = useRef<HTMLDivElement | null>(null);
|
|
416
|
+
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
const node = innerRef.current;
|
|
419
|
+
if (!node) return;
|
|
420
|
+
const sync = () => {
|
|
421
|
+
if (node.scrollHeight > node.clientHeight) node.setAttribute('tabindex', '0');
|
|
422
|
+
else node.removeAttribute('tabindex');
|
|
423
|
+
};
|
|
424
|
+
sync();
|
|
425
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
426
|
+
const observer = new ResizeObserver(sync);
|
|
427
|
+
observer.observe(node);
|
|
428
|
+
for (const child of Array.from(node.children)) observer.observe(child);
|
|
429
|
+
return () => observer.disconnect();
|
|
430
|
+
}, []);
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<div
|
|
434
|
+
className={cx('min-h-0 flex-1 overflow-y-auto px-4 py-6 md:px-10', className)}
|
|
435
|
+
ref={(node) => {
|
|
436
|
+
innerRef.current = node;
|
|
437
|
+
if (typeof ref === 'function') ref(node);
|
|
438
|
+
else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
|
|
439
|
+
}}
|
|
440
|
+
{...rest}
|
|
441
|
+
>
|
|
442
|
+
{children}
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export type DrawerFooterProps = ComponentPropsWithRef<'div'> & {
|
|
448
|
+
/** 좌우로 갈라 놓는다. 왼쪽에 보조 액션, 오른쪽에 확정 액션을 둘 때. */
|
|
449
|
+
split?: boolean;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 푸터 — 확정·취소 액션 자리. 제출(primary)이 가장 오른쪽이다.
|
|
454
|
+
* `split` 을 주면 왼쪽 보조 액션과 오른쪽 확정 액션으로 갈라 놓는다.
|
|
455
|
+
*/
|
|
456
|
+
export function DrawerFooter({ split, className, children, ...rest }: DrawerFooterProps) {
|
|
457
|
+
return (
|
|
458
|
+
<div
|
|
459
|
+
className={cx(
|
|
460
|
+
'btn-wrap flex shrink-0 flex-wrap items-center gap-2 border-t border-line px-4 py-5 md:px-10',
|
|
461
|
+
split ? 'justify-between' : 'justify-end',
|
|
462
|
+
className,
|
|
463
|
+
)}
|
|
464
|
+
{...rest}
|
|
465
|
+
>
|
|
466
|
+
{children}
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export type DrawerCloseProps = Omit<ComponentPropsWithRef<'button'>, 'children'> & {
|
|
472
|
+
/** 스크린리더가 읽을 이름. 아이콘만 있는 버튼이라 반드시 있어야 한다. */
|
|
473
|
+
label?: string;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* 닫기 버튼 — 킷 `krds-btn small icon` + `ico-popup-close`.
|
|
478
|
+
* **모달과 달리 `Drawer.Header` 의 `extra` 에 넣는다.**
|
|
479
|
+
*
|
|
480
|
+
* @example
|
|
481
|
+
* <Drawer.Header extra={<Drawer.Close />}>필터</Drawer.Header>
|
|
482
|
+
*/
|
|
483
|
+
export function DrawerClose({
|
|
484
|
+
label = '닫기',
|
|
485
|
+
className,
|
|
486
|
+
onClick,
|
|
487
|
+
type = 'button',
|
|
488
|
+
...rest
|
|
489
|
+
}: DrawerCloseProps) {
|
|
490
|
+
const { close } = useDrawerContext('Drawer.Close');
|
|
491
|
+
|
|
492
|
+
return (
|
|
493
|
+
<button
|
|
494
|
+
type={type}
|
|
495
|
+
// small(40px) — 헤더 한 줄에 들어가야 해서 모달의 medium(48px)보다 한 단 작다.
|
|
496
|
+
className={cx('krds-btn small icon shrink-0', className)}
|
|
497
|
+
onClick={(event) => {
|
|
498
|
+
onClick?.(event);
|
|
499
|
+
if (!event.defaultPrevented) close();
|
|
500
|
+
}}
|
|
501
|
+
{...rest}
|
|
502
|
+
>
|
|
503
|
+
<span className="sr-only">{label}</span>
|
|
504
|
+
<i className="svg-icon ico-popup-close" aria-hidden="true" />
|
|
505
|
+
</button>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** 합성용 묶음. `Root` · `Header` · `Body` · `Footer` · `Close`. */
|
|
510
|
+
export const Drawer = {
|
|
511
|
+
Root: DrawerRoot,
|
|
512
|
+
Header: DrawerHeader,
|
|
513
|
+
Body: DrawerBody,
|
|
514
|
+
Footer: DrawerFooter,
|
|
515
|
+
Close: DrawerClose,
|
|
516
|
+
};
|