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,541 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ComponentPropsWithRef,
|
|
10
|
+
type FocusEvent as ReactFocusEvent,
|
|
11
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import { cx } from '../lib/krds';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 화면 크기 조정 — KRDS `krds-drop-wrap krds-resize`.
|
|
18
|
+
* 선명한 화면 모드 전환({@link ViewModeSwitch}) — 같은 드롭다운 껍데기를 쓰는 우리 부품이다.
|
|
19
|
+
*
|
|
20
|
+
* 헤더 유틸리티 줄에 놓는다. 배율은 문서 루트 글자 크기로 걸리므로 화면 전체가 따라온다.
|
|
21
|
+
* 첫 화면이 기본 모드로 번쩍이지 않게 `<head>` 에 {@link ViewSettingsScript} 를 두고 **nonce 를 넘긴다**.
|
|
22
|
+
* `ViewModeSwitch` 는 `<html data-krds-mode>` 를 붙이는 **유일한 곳**이다 —
|
|
23
|
+
* 이것 없이는 킷의 고대비 규칙이 통째로 죽은 코드로 남는다.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* // app/layout.tsx <head>
|
|
27
|
+
* <ViewSettingsScript nonce={nonce} />
|
|
28
|
+
* // 헤더 유틸리티 줄
|
|
29
|
+
* <HeaderUtility>
|
|
30
|
+
* <Resize />
|
|
31
|
+
* <ViewModeSwitch />
|
|
32
|
+
* </HeaderUtility>
|
|
33
|
+
*
|
|
34
|
+
* 자세히: docs/krds/09-부품-노트.md#크기조절
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
38
|
+
* 1. 배율
|
|
39
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
40
|
+
|
|
41
|
+
/** 킷의 5단. 클래스 이름(`.item-link.sm` …)과 값이 같아야 한다. */
|
|
42
|
+
export type ResizeScale = 'sm' | 'md' | 'lg' | 'xlg' | 'xxlg';
|
|
43
|
+
|
|
44
|
+
/** 5단의 레이블과 배율 — 작게 90% · 보통 100% · 조금 크게 110% · 크게 130% · 가장 크게 150%. */
|
|
45
|
+
export const RESIZE_SCALES: ReadonlyArray<{
|
|
46
|
+
value: ResizeScale;
|
|
47
|
+
label: string;
|
|
48
|
+
percent: number;
|
|
49
|
+
}> = [
|
|
50
|
+
{ value: 'sm', label: '작게', percent: 90 },
|
|
51
|
+
{ value: 'md', label: '보통', percent: 100 },
|
|
52
|
+
{ value: 'lg', label: '조금 크게', percent: 110 },
|
|
53
|
+
{ value: 'xlg', label: '크게', percent: 130 },
|
|
54
|
+
{ value: 'xxlg', label: '가장 크게', percent: 150 },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** 킷 기본 루트 글자 크기. */
|
|
58
|
+
const ROOT_FONT_BASE_PERCENT = 62.5;
|
|
59
|
+
|
|
60
|
+
/** 기본 배율(킷의 `.active` 가 붙어 있는 단). */
|
|
61
|
+
const DEFAULT_SCALE: ResizeScale = 'md';
|
|
62
|
+
|
|
63
|
+
function scaleInfo(scale: ResizeScale) {
|
|
64
|
+
return RESIZE_SCALES.find((s) => s.value === scale) ?? RESIZE_SCALES[1];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 그 배율에서 문서 루트에 넣을 글자 크기. %(브라우저 기본 글자 크기의 비율)라
|
|
69
|
+
* 사용자의 브라우저 글꼴 설정을 덮지 않는다.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* rootFontSize('lg') // '68.75%'
|
|
73
|
+
*/
|
|
74
|
+
export function rootFontSize(scale: ResizeScale): string {
|
|
75
|
+
return `${(ROOT_FONT_BASE_PERCENT * scaleInfo(scale).percent) / 100}%`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 배율을 문서에 건다 — `<html>` 인라인 글자 크기 · `data-krds-scale` 표시 ·
|
|
80
|
+
* 확대(100% 초과)일 때 `krds-scaled-layout`(목차의 고정 위치를 푼다).
|
|
81
|
+
*/
|
|
82
|
+
export function applyScale(scale: ResizeScale): void {
|
|
83
|
+
const root = document.documentElement;
|
|
84
|
+
root.style.fontSize = rootFontSize(scale);
|
|
85
|
+
root.dataset.krdsScale = scale;
|
|
86
|
+
document.body?.classList.toggle('krds-scaled-layout', scaleInfo(scale).percent > 100);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
90
|
+
* 2. 선명한 화면 모드
|
|
91
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
92
|
+
|
|
93
|
+
/** 화면 모드. `light` 는 속성을 떼는 것이고, 나머지 둘은 `<html data-krds-mode>` 값이다. */
|
|
94
|
+
export type ViewMode = 'light' | 'high-contrast' | 'theme';
|
|
95
|
+
|
|
96
|
+
/** 모드 레이블. 「고대비」 대신 KRDS 용어인 「선명한 화면」을 쓴다. */
|
|
97
|
+
export const VIEW_MODES: ReadonlyArray<{ value: ViewMode; label: string }> = [
|
|
98
|
+
{ value: 'light', label: '기본 화면' },
|
|
99
|
+
{ value: 'high-contrast', label: '선명한 화면' },
|
|
100
|
+
{ value: 'theme', label: '기기 설정 따름' },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/** 화면 모드를 `<html>` 에 건다. 킷 선택자가 후손 선택자라 반드시 루트에 붙어야 한다. */
|
|
104
|
+
export function applyViewMode(mode: ViewMode): void {
|
|
105
|
+
const root = document.documentElement;
|
|
106
|
+
if (mode === 'light') delete root.dataset.krdsMode;
|
|
107
|
+
else root.dataset.krdsMode = mode;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
111
|
+
* 3. 저장 — 브라우저가 막아도 화면은 떠야 한다
|
|
112
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
113
|
+
|
|
114
|
+
/** 배율 저장 키. */
|
|
115
|
+
export const SCALE_STORAGE_KEY = 'krds.scale';
|
|
116
|
+
/** 화면 모드 저장 키. */
|
|
117
|
+
export const VIEW_MODE_STORAGE_KEY = 'krds.view-mode';
|
|
118
|
+
|
|
119
|
+
/** 사생활 보호 모드·쿠키 차단에서는 접근만 해도 예외가 난다 — try/catch 로 감싼다. */
|
|
120
|
+
function readStored<T extends string>(key: string, allowed: ReadonlyArray<T>): T | null {
|
|
121
|
+
try {
|
|
122
|
+
const v = window.localStorage.getItem(key);
|
|
123
|
+
return v && (allowed as ReadonlyArray<string>).includes(v) ? (v as T) : null;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeStored(key: string, value: string): void {
|
|
130
|
+
try {
|
|
131
|
+
window.localStorage.setItem(key, value);
|
|
132
|
+
} catch {
|
|
133
|
+
/* 저장이 막힌 브라우저 — 이번 방문에만 적용된다 */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 저장된 배율. 없거나 못 읽으면 null. */
|
|
138
|
+
export function readStoredScale(): ResizeScale | null {
|
|
139
|
+
return readStored(
|
|
140
|
+
SCALE_STORAGE_KEY,
|
|
141
|
+
RESIZE_SCALES.map((s) => s.value),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 저장된 화면 모드. 없거나 못 읽으면 null. */
|
|
146
|
+
export function readStoredViewMode(): ViewMode | null {
|
|
147
|
+
return readStored(
|
|
148
|
+
VIEW_MODE_STORAGE_KEY,
|
|
149
|
+
VIEW_MODES.map((m) => m.value),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 첫 그림 전에 저장값을 거는 인라인 스크립트 본문. `RESIZE_SCALES` 에서 만들어 낸다. */
|
|
154
|
+
const BOOT_SCRIPT = ((): string => {
|
|
155
|
+
const map: Record<string, string> = {};
|
|
156
|
+
for (const s of RESIZE_SCALES) map[s.value] = rootFontSize(s.value);
|
|
157
|
+
return (
|
|
158
|
+
'(function(){try{var d=document.documentElement,' +
|
|
159
|
+
`m=${JSON.stringify(map)},` +
|
|
160
|
+
`s=localStorage.getItem(${JSON.stringify(SCALE_STORAGE_KEY)});` +
|
|
161
|
+
'if(s&&m[s]){d.style.fontSize=m[s];d.dataset.krdsScale=s;}' +
|
|
162
|
+
`var v=localStorage.getItem(${JSON.stringify(VIEW_MODE_STORAGE_KEY)});` +
|
|
163
|
+
"if(v==='high-contrast'||v==='theme'){d.dataset.krdsMode=v;}" +
|
|
164
|
+
'}catch(e){}})();'
|
|
165
|
+
);
|
|
166
|
+
})();
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 첫 그림 전에 저장된 배율·화면 모드를 거는 인라인 스크립트. 없으면 첫 화면이 한 번 번쩍인다.
|
|
170
|
+
* `<head>` 안, 스타일시트 뒤에 놓는다.
|
|
171
|
+
*
|
|
172
|
+
* **`nonce` 를 반드시 넘겨라** — CSP 가 nonce 없는 인라인 스크립트를 막는다.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* const nonce = await requestNonce();
|
|
176
|
+
* <ViewSettingsScript nonce={nonce ?? undefined} />
|
|
177
|
+
*/
|
|
178
|
+
export function ViewSettingsScript({ nonce }: { nonce?: string }) {
|
|
179
|
+
return <script nonce={nonce} dangerouslySetInnerHTML={{ __html: BOOT_SCRIPT }} />;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
183
|
+
* 4. 드롭다운 껍데기 — 두 부품이 함께 쓴다
|
|
184
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
185
|
+
|
|
186
|
+
/** 드롭다운 정렬. 생략하면 가운데(킷 기본). */
|
|
187
|
+
export type DropAlign = 'left' | 'right';
|
|
188
|
+
|
|
189
|
+
type DropOption<T extends string> = {
|
|
190
|
+
value: T;
|
|
191
|
+
label: string;
|
|
192
|
+
/** 항목에 덧붙일 킷 클래스. 화면 크기 조정의 `sm`~`xxlg` 미리보기 아이콘이 여기로 온다. */
|
|
193
|
+
itemClass?: string;
|
|
194
|
+
/** 항목에 붙일 `data-adjust-scale` 값(킷 샘플의 손잡이). */
|
|
195
|
+
data?: string;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
type DropdownShellProps<T extends string> = {
|
|
199
|
+
/** 껍데기 종류 클래스. `krds-resize` 처럼 킷에 실재하는 것만 넣는다. */
|
|
200
|
+
wrapClass?: string;
|
|
201
|
+
/** 버튼 글자(구조 ①). */
|
|
202
|
+
triggerLabel: string;
|
|
203
|
+
/** 버튼 앞 아이콘 이름(`ico-` 뺀 것). 없으면 글자만. */
|
|
204
|
+
leadingIcon?: string;
|
|
205
|
+
options: ReadonlyArray<DropOption<T>>;
|
|
206
|
+
value: T;
|
|
207
|
+
onSelect: (value: T) => void;
|
|
208
|
+
align?: DropAlign;
|
|
209
|
+
/**
|
|
210
|
+
* `.drop-bottom` 자리. 여기서 일어난 클릭은 **목록을 닫고 버튼으로 초점을 돌린다**
|
|
211
|
+
* — 초기화처럼 값을 바꾸는 동작이 들어오는 자리라 옵션을 고른 것과 같이 다룬다.
|
|
212
|
+
*/
|
|
213
|
+
footer?: ReactNode;
|
|
214
|
+
className?: string;
|
|
215
|
+
/** 감싸개에 붙일 `data-adjust` 값(킷 샘플의 손잡이). */
|
|
216
|
+
dataAdjust?: string;
|
|
217
|
+
wrapProps: Omit<ComponentPropsWithRef<'div'>, 'children'>;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 드롭다운 껍데기. 상호작용은 10_02 원문 그대로 — Esc 는 접고 버튼으로 초점 복귀,
|
|
222
|
+
* 접힌 상태의 ↑↓ 는 **선택값 자체를** 옮기고, 목록 안에서는 초점만 옮긴다.
|
|
223
|
+
* `role="menu"` 를 쓰지 않는다 — 가이드가 Tab 으로 항목에 닿을 것을 요구한다.
|
|
224
|
+
*/
|
|
225
|
+
function DropdownShell<T extends string>({
|
|
226
|
+
wrapClass,
|
|
227
|
+
triggerLabel,
|
|
228
|
+
leadingIcon,
|
|
229
|
+
options,
|
|
230
|
+
value,
|
|
231
|
+
onSelect,
|
|
232
|
+
align,
|
|
233
|
+
footer,
|
|
234
|
+
className,
|
|
235
|
+
dataAdjust,
|
|
236
|
+
wrapProps,
|
|
237
|
+
}: DropdownShellProps<T>) {
|
|
238
|
+
const [open, setOpen] = useState(false);
|
|
239
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
240
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
241
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
242
|
+
const menuId = useId();
|
|
243
|
+
|
|
244
|
+
const close = useCallback((refocus = false) => {
|
|
245
|
+
setOpen(false);
|
|
246
|
+
if (refocus) triggerRef.current?.focus();
|
|
247
|
+
}, []);
|
|
248
|
+
|
|
249
|
+
// 바깥을 누르면 접는다(가이드라인: 「레이블, 컨테이너, 옵션 목록이 아닌 영역을 Click 하면 축소」).
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
if (!open) return;
|
|
252
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
253
|
+
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
|
254
|
+
};
|
|
255
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
256
|
+
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
257
|
+
}, [open]);
|
|
258
|
+
|
|
259
|
+
/** 목록 안 n 번째 항목으로 초점을 옮긴다. */
|
|
260
|
+
const focusItem = (index: number) => {
|
|
261
|
+
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('.item-link');
|
|
262
|
+
if (!items?.length) return;
|
|
263
|
+
const i = (index + items.length) % items.length;
|
|
264
|
+
items[i]?.focus();
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const onTriggerKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>) => {
|
|
268
|
+
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
|
269
|
+
e.preventDefault();
|
|
270
|
+
if (open) {
|
|
271
|
+
// 펼쳐져 있으면 목록으로 들어간다.
|
|
272
|
+
focusItem(e.key === 'ArrowDown' ? 0 : options.length - 1);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// 접혀 있으면 **선택값 자체가** 이전/다음으로 바뀐다(가이드라인 원문).
|
|
276
|
+
const at = options.findIndex((o) => o.value === value);
|
|
277
|
+
const next = e.key === 'ArrowDown' ? at + 1 : at - 1;
|
|
278
|
+
if (next >= 0 && next < options.length) onSelect(options[next].value);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const onMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
282
|
+
const items = Array.from(
|
|
283
|
+
menuRef.current?.querySelectorAll<HTMLButtonElement>('.item-link') ?? [],
|
|
284
|
+
);
|
|
285
|
+
const at = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
286
|
+
if (at < 0) return;
|
|
287
|
+
if (e.key === 'ArrowDown') {
|
|
288
|
+
e.preventDefault();
|
|
289
|
+
focusItem(at + 1);
|
|
290
|
+
} else if (e.key === 'ArrowUp') {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
focusItem(at - 1);
|
|
293
|
+
} else if (e.key === 'Home') {
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
focusItem(0);
|
|
296
|
+
} else if (e.key === 'End') {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
focusItem(items.length - 1);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Esc 는 감싸개에서 한 번에 받는다 — 버튼에 있든 항목에 있든 접고 버튼으로 돌아간다.
|
|
303
|
+
const onWrapKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
304
|
+
if (e.key === 'Escape' && open) {
|
|
305
|
+
e.stopPropagation();
|
|
306
|
+
close(true);
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// Tab 으로 껍데기 밖으로 나가면 접는다. relatedTarget 이 없으면(브라우저 UI 로 이동 등) 그대로 둔다.
|
|
311
|
+
const onBlurCapture = (e: ReactFocusEvent<HTMLDivElement>) => {
|
|
312
|
+
const next = e.relatedTarget as Node | null;
|
|
313
|
+
if (next && !e.currentTarget.contains(next)) setOpen(false);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const current = options.find((o) => o.value === value);
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div
|
|
320
|
+
// 밖에서 온 props 를 먼저 편다 — 아래 넷은 부품이 도는 데 꼭 필요한 배선이라 덮이면 안 된다.
|
|
321
|
+
{...wrapProps}
|
|
322
|
+
ref={wrapRef}
|
|
323
|
+
className={cx('krds-drop-wrap', wrapClass, align && `drop-${align}`, className)}
|
|
324
|
+
data-adjust={dataAdjust}
|
|
325
|
+
onKeyDown={onWrapKeyDown}
|
|
326
|
+
onBlurCapture={onBlurCapture}
|
|
327
|
+
>
|
|
328
|
+
{/* 킷은 앞뒤로 아이콘 둘을 두는데 `Button` 은 하나만 받아 여기서는 마크업을 그대로 쓴다. */}
|
|
329
|
+
<button
|
|
330
|
+
ref={triggerRef}
|
|
331
|
+
type="button"
|
|
332
|
+
className="krds-btn small text drop-btn"
|
|
333
|
+
aria-expanded={open}
|
|
334
|
+
aria-controls={menuId}
|
|
335
|
+
onClick={() => setOpen((v) => !v)}
|
|
336
|
+
onKeyDown={onTriggerKeyDown}
|
|
337
|
+
>
|
|
338
|
+
{leadingIcon && <i className={`svg-icon ico-${leadingIcon}`} aria-hidden="true" />}
|
|
339
|
+
{triggerLabel}
|
|
340
|
+
{/* 구조 ③ 「선택값」 — 킷 샘플은 목록의 .active 로만 보여 준다. 스크린리더에도 알린다. */}
|
|
341
|
+
<span className="sr-only">현재 {current?.label ?? ''}</span>
|
|
342
|
+
<i className="svg-icon ico-toggle" aria-hidden="true" />
|
|
343
|
+
</button>
|
|
344
|
+
|
|
345
|
+
{/* 킷에 여는 클래스가 없어 인라인 display 로 연다. 접힌 동안은 스크린리더도 읽지 않는다. */}
|
|
346
|
+
<div
|
|
347
|
+
ref={menuRef}
|
|
348
|
+
id={menuId}
|
|
349
|
+
className="drop-menu"
|
|
350
|
+
style={open ? { display: 'block' } : undefined}
|
|
351
|
+
onKeyDown={onMenuKeyDown}
|
|
352
|
+
>
|
|
353
|
+
<div className="drop-in">
|
|
354
|
+
<ul className="drop-list">
|
|
355
|
+
{options.map((option) => (
|
|
356
|
+
<li key={option.value}>
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
className={cx(
|
|
360
|
+
'item-link',
|
|
361
|
+
option.itemClass,
|
|
362
|
+
option.value === value && 'active',
|
|
363
|
+
)}
|
|
364
|
+
data-adjust-scale={option.data}
|
|
365
|
+
// 고른 값 표시. 목록이 role=listbox 가 아니므로 aria-selected 가 아니라 aria-current 다.
|
|
366
|
+
aria-current={option.value === value ? 'true' : undefined}
|
|
367
|
+
onClick={() => {
|
|
368
|
+
onSelect(option.value);
|
|
369
|
+
close(true);
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{option.label}
|
|
373
|
+
</button>
|
|
374
|
+
</li>
|
|
375
|
+
))}
|
|
376
|
+
</ul>
|
|
377
|
+
{footer && (
|
|
378
|
+
<div className="drop-bottom" onClick={() => close(true)}>
|
|
379
|
+
{footer}
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
389
|
+
* 5. 부품
|
|
390
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
391
|
+
|
|
392
|
+
export type ResizeProps = Omit<ComponentPropsWithRef<'div'>, 'children' | 'onSelect'> & {
|
|
393
|
+
/** 버튼 글자. 기본 「화면 크기」. */
|
|
394
|
+
label?: string;
|
|
395
|
+
/** 초기화 버튼 글자. 기본 「초기화」. */
|
|
396
|
+
resetLabel?: string;
|
|
397
|
+
/** 드롭다운 정렬. */
|
|
398
|
+
align?: DropAlign;
|
|
399
|
+
/** 저장값이 없을 때의 배율. 기본 `md`(100%). */
|
|
400
|
+
defaultScale?: ResizeScale;
|
|
401
|
+
/** localStorage 기억. 기본 켬. */
|
|
402
|
+
persist?: boolean;
|
|
403
|
+
/** 배율이 바뀔 때. 서버에 남기고 싶을 때 쓴다. */
|
|
404
|
+
onScaleChange?: (scale: ResizeScale) => void;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* 화면 크기 조정 — 헤더 유틸리티 줄에 놓는다. 5단(90~150%)에서 고른다.
|
|
409
|
+
* 저장값은 붙은 뒤에 읽는다 — 그 한 프레임의 번쩍임은 {@link ViewSettingsScript} 가 없앤다.
|
|
410
|
+
*/
|
|
411
|
+
export function Resize({
|
|
412
|
+
label = '화면 크기',
|
|
413
|
+
resetLabel = '초기화',
|
|
414
|
+
align,
|
|
415
|
+
defaultScale = DEFAULT_SCALE,
|
|
416
|
+
persist = true,
|
|
417
|
+
onScaleChange,
|
|
418
|
+
className,
|
|
419
|
+
...rest
|
|
420
|
+
}: ResizeProps) {
|
|
421
|
+
const [scale, setScale] = useState<ResizeScale>(defaultScale);
|
|
422
|
+
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
const initial = (persist && readStoredScale()) || defaultScale;
|
|
425
|
+
setScale(initial);
|
|
426
|
+
applyScale(initial);
|
|
427
|
+
}, [persist, defaultScale]);
|
|
428
|
+
|
|
429
|
+
const select = useCallback(
|
|
430
|
+
(next: ResizeScale) => {
|
|
431
|
+
setScale(next);
|
|
432
|
+
applyScale(next);
|
|
433
|
+
if (persist) writeStored(SCALE_STORAGE_KEY, next);
|
|
434
|
+
onScaleChange?.(next);
|
|
435
|
+
},
|
|
436
|
+
[persist, onScaleChange],
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<DropdownShell
|
|
441
|
+
wrapClass="krds-resize"
|
|
442
|
+
dataAdjust="scale"
|
|
443
|
+
triggerLabel={label}
|
|
444
|
+
options={RESIZE_SCALES.map((s) => ({
|
|
445
|
+
value: s.value,
|
|
446
|
+
label: s.label,
|
|
447
|
+
// 킷이 이 클래스로 목록 앞 글자 미리보기 아이콘 크기(2.2~3.0rem)를 그린다.
|
|
448
|
+
itemClass: s.value,
|
|
449
|
+
data: s.value,
|
|
450
|
+
}))}
|
|
451
|
+
value={scale}
|
|
452
|
+
onSelect={select}
|
|
453
|
+
align={align}
|
|
454
|
+
footer={
|
|
455
|
+
// 킷 샘플 그대로 `krds-btn medium text` + ico-reset. 아이콘이 글자 앞에 온다.
|
|
456
|
+
// 목록을 닫는 일은 껍데기(`.drop-bottom` 의 클릭)가 맡는다.
|
|
457
|
+
<button
|
|
458
|
+
type="button"
|
|
459
|
+
className="krds-btn medium text"
|
|
460
|
+
data-adjust-scale={DEFAULT_SCALE}
|
|
461
|
+
onClick={() => select(DEFAULT_SCALE)}
|
|
462
|
+
>
|
|
463
|
+
<i className="svg-icon ico-reset" aria-hidden="true" /> {resetLabel}
|
|
464
|
+
</button>
|
|
465
|
+
}
|
|
466
|
+
className={className}
|
|
467
|
+
wrapProps={rest}
|
|
468
|
+
/>
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export type ViewModeSwitchProps = Omit<ComponentPropsWithRef<'div'>, 'children' | 'onSelect'> & {
|
|
473
|
+
/** 버튼 글자. 기본 「화면 모드」. */
|
|
474
|
+
label?: string;
|
|
475
|
+
/** 드롭다운 정렬. */
|
|
476
|
+
align?: DropAlign;
|
|
477
|
+
/** 저장값이 없을 때의 모드. 기본 `light`. */
|
|
478
|
+
defaultMode?: ViewMode;
|
|
479
|
+
/** localStorage 기억. 기본 켬. */
|
|
480
|
+
persist?: boolean;
|
|
481
|
+
/**
|
|
482
|
+
* 「기기 설정 따름」을 옵션에 넣는다. **기본 끔** — 보충 층이 그 모드를 재정의하지 않아
|
|
483
|
+
* 킷 부품만 어두워지고 유틸리티로 그린 부분은 밝게 남는다.
|
|
484
|
+
*/
|
|
485
|
+
systemOption?: boolean;
|
|
486
|
+
/** 모드가 바뀔 때. */
|
|
487
|
+
onModeChange?: (mode: ViewMode) => void;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* 선명한 화면 모드 전환 — 킷에 이름이 없는 우리 부품이다. 껍데기와 아이콘은 킷 것을 쓴다.
|
|
492
|
+
*
|
|
493
|
+
* **`<html data-krds-mode>` 를 붙이는 유일한 곳이다** — 이것 없이는 킷의 고대비 규칙이
|
|
494
|
+
* 통째로 죽은 코드로 남는다.
|
|
495
|
+
*/
|
|
496
|
+
export function ViewModeSwitch({
|
|
497
|
+
label = '화면 모드',
|
|
498
|
+
align,
|
|
499
|
+
defaultMode = 'light',
|
|
500
|
+
persist = true,
|
|
501
|
+
systemOption = false,
|
|
502
|
+
onModeChange,
|
|
503
|
+
className,
|
|
504
|
+
...rest
|
|
505
|
+
}: ViewModeSwitchProps) {
|
|
506
|
+
const [mode, setMode] = useState<ViewMode>(defaultMode);
|
|
507
|
+
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
const saved = (persist && readStoredViewMode()) || defaultMode;
|
|
510
|
+
// 목록에서 뺀 값이 저장돼 있으면 기본 화면으로 되돌린다 — 고를 수도 끌 수도 없는 상태를 막는다.
|
|
511
|
+
const initial: ViewMode = saved === 'theme' && !systemOption ? 'light' : saved;
|
|
512
|
+
setMode(initial);
|
|
513
|
+
applyViewMode(initial);
|
|
514
|
+
}, [persist, defaultMode, systemOption]);
|
|
515
|
+
|
|
516
|
+
const select = useCallback(
|
|
517
|
+
(next: ViewMode) => {
|
|
518
|
+
setMode(next);
|
|
519
|
+
applyViewMode(next);
|
|
520
|
+
if (persist) writeStored(VIEW_MODE_STORAGE_KEY, next);
|
|
521
|
+
onModeChange?.(next);
|
|
522
|
+
},
|
|
523
|
+
[persist, onModeChange],
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const options = VIEW_MODES.filter((m) => systemOption || m.value !== 'theme');
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
<DropdownShell
|
|
530
|
+
wrapClass={undefined}
|
|
531
|
+
triggerLabel={label}
|
|
532
|
+
leadingIcon="view-mode"
|
|
533
|
+
options={options.map((m) => ({ value: m.value, label: m.label }))}
|
|
534
|
+
value={mode}
|
|
535
|
+
onSelect={select}
|
|
536
|
+
align={align}
|
|
537
|
+
className={className}
|
|
538
|
+
wrapProps={rest}
|
|
539
|
+
/>
|
|
540
|
+
);
|
|
541
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react';
|
|
2
|
+
import { cx } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 스크롤 영역 — KRDS 에 없다. 킷 스크롤 막대 모양을 유틸리티로 옮긴 보충 부품이다.
|
|
6
|
+
*
|
|
7
|
+
* 넘치는 내용을 가둬 굴린다. **초점을 받아 키보드로도 굴러간다** — 그것이 이 부품의 존재 이유다.
|
|
8
|
+
* `role="region"` 이라 이름(`label` 또는 `labelledBy`)이 필수이고, 랜드마크가 목록에 쌓이므로
|
|
9
|
+
* 이름을 붙일 만큼 뜻이 있는 칸에만 쓴다. 단순히 넘치는 표는 `ui/table` 의 감싸개다.
|
|
10
|
+
* **높이(폭)를 제한하지 않으면 아무것도 스크롤되지 않는다** — `className="max-h-80"` 처럼 준다.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <ScrollArea label="약관 전문" className="max-h-80 rounded-lg border border-solid border-line p-4">
|
|
14
|
+
* {terms}
|
|
15
|
+
* </ScrollArea>
|
|
16
|
+
*
|
|
17
|
+
* 자세히: docs/krds/09-부품-노트.md#스크롤영역
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 굴릴 방향. 기본은 세로. */
|
|
21
|
+
export type ScrollAreaAxis = 'vertical' | 'horizontal' | 'both';
|
|
22
|
+
|
|
23
|
+
type BaseProps = {
|
|
24
|
+
/** 굴릴 방향. 기본 세로. */
|
|
25
|
+
axis?: ScrollAreaAxis;
|
|
26
|
+
/** 끝에 닿았을 때 바깥까지 따라 굴러가는 것을 막는다. 모달·서랍처럼 겹쳐 뜨는 칸에 켠다. */
|
|
27
|
+
containScroll?: boolean;
|
|
28
|
+
/** 칸에 붙일 클래스. **여기서 높이(폭)를 제한해야 스크롤이 생긴다.** */
|
|
29
|
+
className?: string;
|
|
30
|
+
/** 굴릴 내용. */
|
|
31
|
+
children?: ReactNode;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** 이름은 반드시 하나 있어야 한다 — 둘 다 주지도 둘 다 빼지도 못하게 타입으로 막는다. */
|
|
35
|
+
type NameProps =
|
|
36
|
+
| { label: string; labelledBy?: never }
|
|
37
|
+
| { label?: never; labelledBy: string };
|
|
38
|
+
|
|
39
|
+
export type ScrollAreaProps = Omit<
|
|
40
|
+
ComponentPropsWithRef<'div'>,
|
|
41
|
+
'aria-label' | 'aria-labelledby' | 'children'
|
|
42
|
+
> &
|
|
43
|
+
BaseProps &
|
|
44
|
+
NameProps;
|
|
45
|
+
|
|
46
|
+
const OVERFLOW: Record<ScrollAreaAxis, string> = {
|
|
47
|
+
vertical: 'overflow-y-auto overflow-x-hidden',
|
|
48
|
+
horizontal: 'overflow-x-auto overflow-y-hidden',
|
|
49
|
+
both: 'overflow-auto',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** 스크롤 막대 모양. 킷 모달의 막대 mixin 을 유틸리티로 옮긴 것이다(8px). */
|
|
53
|
+
const SCROLLBAR = cx(
|
|
54
|
+
// 표준 속성 — 파이어폭스와 크롬 121+ 가 본다.
|
|
55
|
+
'[scrollbar-width:thin]',
|
|
56
|
+
'[scrollbar-color:var(--color-line)_var(--color-surface-subtler)]',
|
|
57
|
+
// 웹킷 의사요소 — 킷과 같은 8px 막대.
|
|
58
|
+
'[&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2',
|
|
59
|
+
'[&::-webkit-scrollbar-track]:bg-surface-subtler',
|
|
60
|
+
'[&::-webkit-scrollbar-thumb]:rounded-lg [&::-webkit-scrollbar-thumb]:bg-line',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export function ScrollArea(props: ScrollAreaProps) {
|
|
64
|
+
const {
|
|
65
|
+
axis = 'vertical',
|
|
66
|
+
containScroll,
|
|
67
|
+
label,
|
|
68
|
+
labelledBy,
|
|
69
|
+
className,
|
|
70
|
+
children,
|
|
71
|
+
// 기본 0. 스크롤 칸은 초점을 받아야 ↑↓·PageUp/PageDown·Space 로 굴러간다(브라우저가 처리한다).
|
|
72
|
+
tabIndex = 0,
|
|
73
|
+
...rest
|
|
74
|
+
} = props as BaseProps &
|
|
75
|
+
Omit<ComponentPropsWithRef<'div'>, 'children'> & { label?: string; labelledBy?: string };
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
role="region"
|
|
80
|
+
aria-label={label}
|
|
81
|
+
aria-labelledby={labelledBy}
|
|
82
|
+
tabIndex={tabIndex}
|
|
83
|
+
className={cx(
|
|
84
|
+
OVERFLOW[axis],
|
|
85
|
+
// flex/grid 자식일 때 내용만큼 늘어나 스크롤이 안 생기는 것을 막는다.
|
|
86
|
+
'min-h-0 min-w-0',
|
|
87
|
+
containScroll && 'overscroll-contain',
|
|
88
|
+
SCROLLBAR,
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
91
|
+
{...rest}
|
|
92
|
+
>
|
|
93
|
+
{children}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|