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,172 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react';
|
|
2
|
+
import { cx, type KrdsSize } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 스켈레톤(로딩 자리표시) — 킷에 클래스가 없다. KRDS 토큰으로 그리는 보충 부품이다.
|
|
6
|
+
*
|
|
7
|
+
* **올 것의 모양을 이미 아는 자리**(목록 행·카드·본문 문단)에 쓴다. 모양을 모르거나 좁은 자리는
|
|
8
|
+
* `ui/spinner`, 오래 걸리는 작업은 `ui/progress` 의 진행 막대다 — 한 화면에 섞어 쓰지 않는다.
|
|
9
|
+
* **크기는 `className` 으로 준다**(`<Skeleton className="h-4 w-40" />`).
|
|
10
|
+
* 자리표시는 전부 `aria-hidden` 이라 **`SkeletonGroup` 으로 감싸야** 로딩이 소리로 전해진다.
|
|
11
|
+
* 그 라이브 영역은 항상 DOM 에 두고 안의 내용만 바꾼다. 1초 미만이면 아예 쓰지 않는다.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* <SkeletonGroup loading={pending} label="목록을 불러오는 중" done="목록을 불러왔습니다">
|
|
15
|
+
* <SkeletonText lines={5} />
|
|
16
|
+
* </SkeletonGroup>
|
|
17
|
+
* {!pending && <List items={items} />}
|
|
18
|
+
*
|
|
19
|
+
* 자세히: docs/krds/09-부품-노트.md#스켈레톤
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** 자리표시의 모양. 둥글기가 갈린다. */
|
|
23
|
+
export type SkeletonShape = 'block' | 'text' | 'circle';
|
|
24
|
+
|
|
25
|
+
/** 모양 → 둥글기 유틸리티. 완성된 문자열로 적는다 — 조립하면 Tailwind 가 클래스를 만들지 않는다. */
|
|
26
|
+
const SHAPE_RADIUS: Record<SkeletonShape, string> = {
|
|
27
|
+
block: 'rounded-md', // 6 — 버튼·입력칸과 같은 둥글기(--krds-radius-medium1)
|
|
28
|
+
text: 'rounded-sm', // 4 — 글줄 막대(--krds-radius-small1)
|
|
29
|
+
circle: 'rounded-full', // 아바타·아이콘 자리
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type SkeletonProps = ComponentPropsWithRef<'div'> & {
|
|
33
|
+
/** 모양. 기본 `block`(버튼·입력칸과 같은 둥글기). */
|
|
34
|
+
shape?: SkeletonShape;
|
|
35
|
+
/** 맥동을 끈다. 자리표시를 아주 많이 깔 때는 꺼서 시선을 덜 흔든다. */
|
|
36
|
+
animated?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 자리표시 막대 하나. **크기는 반드시 `className` 으로 준다** — 부품에 기본 크기가 없다.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* <Skeleton className="h-4 w-40" />
|
|
44
|
+
* <Skeleton shape="circle" className="size-12" />
|
|
45
|
+
*/
|
|
46
|
+
export function Skeleton({ shape = 'block', animated = true, className, ...rest }: SkeletonProps) {
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
// aria-hidden 은 rest 보다 앞에 둔다 — 바깥에서 지울 길은 열어 두되 기본은 숨김이다.
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
className={cx(
|
|
52
|
+
'bg-surface-subtle',
|
|
53
|
+
SHAPE_RADIUS[shape],
|
|
54
|
+
// 기본 정지. 모션을 막지 않은 사용자에게만 맥동한다.
|
|
55
|
+
animated && 'motion-safe:animate-pulse',
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
{...rest}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 글줄 크기. KRDS 본문 글자 축과 같다 — 13 / 15 / 17 / 19px. */
|
|
64
|
+
export type SkeletonTextSize = Exclude<KrdsSize, 'xlarge'>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 글줄 크기 → 막대 높이 + 줄 간격. 자리표시를 진짜 글로 바꿔도 높이가 변하지 않도록
|
|
68
|
+
* KRDS 줄 상자(글자 × 1.5)에 맞춰 골랐다.
|
|
69
|
+
*/
|
|
70
|
+
const TEXT_METRIC: Record<SkeletonTextSize, { bar: string; gap: string }> = {
|
|
71
|
+
xsmall: { bar: 'h-3', gap: 'gap-2' },
|
|
72
|
+
small: { bar: 'h-3', gap: 'gap-2.5' },
|
|
73
|
+
medium: { bar: 'h-4', gap: 'gap-2.5' },
|
|
74
|
+
large: { bar: 'h-4', gap: 'gap-3' },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** 마지막 줄 폭. 문단 끝줄이 짧은 것을 흉내 낸다. */
|
|
78
|
+
export type SkeletonLastLineWidth = 'full' | '4/5' | '3/5' | '1/2' | '2/5';
|
|
79
|
+
|
|
80
|
+
/** 완성된 문자열로 적는다(Tailwind 스캐너). */
|
|
81
|
+
const LAST_WIDTH: Record<SkeletonLastLineWidth, string> = {
|
|
82
|
+
full: 'w-full',
|
|
83
|
+
'4/5': 'w-4/5',
|
|
84
|
+
'3/5': 'w-3/5',
|
|
85
|
+
'1/2': 'w-1/2',
|
|
86
|
+
'2/5': 'w-2/5',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type SkeletonTextProps = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
|
|
90
|
+
/** 줄 수. **실제로 올 줄 수와 맞춘다** — 어긋나면 내용이 도착할 때 화면이 접힌다. */
|
|
91
|
+
lines?: number;
|
|
92
|
+
/** 글줄 크기. 기본 medium(17px 줄). */
|
|
93
|
+
size?: SkeletonTextSize;
|
|
94
|
+
/** 마지막 줄 폭. 기본 3/5. */
|
|
95
|
+
lastLineWidth?: SkeletonLastLineWidth;
|
|
96
|
+
/** 맥동을 끈다. */
|
|
97
|
+
animated?: boolean;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 여러 줄짜리 글 자리표시. 한 줄일 때는 끝줄을 줄이지 않는다.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* <SkeletonText lines={3} />
|
|
105
|
+
* <SkeletonText lines={1} size="large" className="w-1/3" />
|
|
106
|
+
*/
|
|
107
|
+
export function SkeletonText({
|
|
108
|
+
lines = 3,
|
|
109
|
+
size = 'medium',
|
|
110
|
+
lastLineWidth = '3/5',
|
|
111
|
+
animated = true,
|
|
112
|
+
className,
|
|
113
|
+
...rest
|
|
114
|
+
}: SkeletonTextProps) {
|
|
115
|
+
const { bar, gap } = TEXT_METRIC[size];
|
|
116
|
+
// 0줄·음수·소수를 막는다. 부모가 목록 길이를 그대로 넘기는 일이 흔하다.
|
|
117
|
+
const count = Math.max(1, Math.trunc(lines) || 1);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div aria-hidden="true" className={cx('flex flex-col', gap, className)} {...rest}>
|
|
121
|
+
{Array.from({ length: count }, (_, i) => (
|
|
122
|
+
<Skeleton
|
|
123
|
+
key={i}
|
|
124
|
+
shape="text"
|
|
125
|
+
animated={animated}
|
|
126
|
+
className={cx(bar, i === count - 1 && count > 1 ? LAST_WIDTH[lastLineWidth] : 'w-full')}
|
|
127
|
+
/>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type SkeletonGroupProps = ComponentPropsWithRef<'div'> & {
|
|
134
|
+
/** 참이면 자리표시를, 거짓이면 완료 문구만 라이브 영역에 담는다. */
|
|
135
|
+
loading: boolean;
|
|
136
|
+
/** 로딩 중 스크린리더 문구. */
|
|
137
|
+
label?: string;
|
|
138
|
+
/** 완료 시 스크린리더 문구. 빈 문자열이면 완료를 알리지 않는다. */
|
|
139
|
+
done?: string;
|
|
140
|
+
/** 자리표시들. */
|
|
141
|
+
children?: ReactNode;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 자리표시 묶음 + 로딩 알림 — 스켈레톤을 쓸 때 **이것으로 감싸는 것이 기본**이다.
|
|
146
|
+
*
|
|
147
|
+
* 이 라이브 영역은 **항상 DOM 에 두고 안의 내용만 바꾼다** — 통째로 넣었다 뺐다 하면
|
|
148
|
+
* 완료를 알릴 자리가 사라진다. `aria-live` · `aria-busy` 를 손으로 덮어쓰지 마라.
|
|
149
|
+
* 스피너 쪽 짝은 `ui/spinner` 의 `LoadingStatus` 다.
|
|
150
|
+
*/
|
|
151
|
+
export function SkeletonGroup({
|
|
152
|
+
loading,
|
|
153
|
+
label = '불러오는 중',
|
|
154
|
+
done = '불러왔습니다',
|
|
155
|
+
className,
|
|
156
|
+
children,
|
|
157
|
+
...rest
|
|
158
|
+
}: SkeletonGroupProps) {
|
|
159
|
+
return (
|
|
160
|
+
<div role="status" className={className} {...rest}>
|
|
161
|
+
{loading ? (
|
|
162
|
+
<>
|
|
163
|
+
{/* 킷 `.sr-only` — 화면에서 숨고 스크린리더만 읽는다. */}
|
|
164
|
+
<span className="sr-only">{label}</span>
|
|
165
|
+
{children}
|
|
166
|
+
</>
|
|
167
|
+
) : (
|
|
168
|
+
<span className="sr-only">{done}</span>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react';
|
|
2
|
+
import { cx } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 건너뛰기 링크 — KRDS `#krds-skip-link`.
|
|
6
|
+
*
|
|
7
|
+
* `<body>` 를 열자마자, 문서의 가장 첫 요소로 둔다. 링크는 3개까지고 첫 번째가 핵심 영역이다.
|
|
8
|
+
* 목적지에는 브레드크럼·사이드 메뉴 같은 반복 영역이 들어가면 안 된다 —
|
|
9
|
+
* 본문에 별도 앵커를 두고 `skipTargetProps()`(또는 `SkipTarget`)로 `tabIndex={-1}` 을 붙인다.
|
|
10
|
+
* 킷이 아이디 선택자로 모양을 잡으므로 한 문서에 하나만 둔다.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <SkipLink links={[{ href: '#content', label: '본문 바로가기' }]} />
|
|
14
|
+
* <div {...skipTargetProps('content')}>…본문…</div>
|
|
15
|
+
*
|
|
16
|
+
* 자세히: docs/krds/09-부품-노트.md#건너뛰기링크
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 건너뛰기 링크 한 줄. */
|
|
20
|
+
export type SkipLinkItem = {
|
|
21
|
+
/** 목적지 앵커. `#` 로 시작한다. */
|
|
22
|
+
href: string;
|
|
23
|
+
/** 링크 글자. 예: 「본문 바로가기」 */
|
|
24
|
+
label: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type SkipLinkProps = Omit<ComponentPropsWithRef<'div'>, 'id' | 'children'> & {
|
|
28
|
+
/** 링크 목록. 첫 번째가 핵심 영역이다. 3개를 넘기면 개발 모드에서 경고하고 앞 3개만 그린다. */
|
|
29
|
+
links: SkipLinkItem[];
|
|
30
|
+
/** 탐색 랜드마크 이름. 기본 「건너뛰기 링크」. */
|
|
31
|
+
label?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** 규격이 정한 상한. */
|
|
35
|
+
const MAX_LINKS = 3;
|
|
36
|
+
|
|
37
|
+
export function SkipLink({ links, label = '건너뛰기 링크', className, ...rest }: SkipLinkProps) {
|
|
38
|
+
const shown = links.slice(0, MAX_LINKS);
|
|
39
|
+
|
|
40
|
+
if (process.env.NODE_ENV !== 'production' && links.length > MAX_LINKS) {
|
|
41
|
+
// 규격: 「개수는 3개 이내」. 조용히 자르지 않고 알린다.
|
|
42
|
+
console.warn(
|
|
43
|
+
`[SkipLink] 건너뛰기 링크는 3개 이내여야 한다(받은 개수 ${links.length}). 앞 ${MAX_LINKS}개만 그린다.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
// 킷이 이 id 에 모양을 전부 걸어 두어 기본 클래스가 없다 — 빈 class 를 남기지 않는다.
|
|
50
|
+
className={cx(className) || undefined}
|
|
51
|
+
role="navigation"
|
|
52
|
+
aria-label={label}
|
|
53
|
+
{...rest}
|
|
54
|
+
// id 는 킷 CSS 가 물고 있는 선택자다. rest 로 덮이지 않도록 마지막에 고정한다.
|
|
55
|
+
id="krds-skip-link"
|
|
56
|
+
>
|
|
57
|
+
{shown.map((item) => (
|
|
58
|
+
<a key={item.href} href={item.href}>
|
|
59
|
+
{item.label}
|
|
60
|
+
</a>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 건너뛰기 목적지에 붙일 속성. `tabIndex={-1}` 이 있어야 앵커 이동이 초점까지 옮긴다.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* <div {...skipTargetProps('content')}>…본문…</div>
|
|
71
|
+
*/
|
|
72
|
+
export function skipTargetProps(id: string): { id: string; tabIndex: number } {
|
|
73
|
+
return { id, tabIndex: -1 };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 목적지 감싸개 — {@link skipTargetProps} 를 바로 쓰는 형태. 클래스는 붙이지 않는다. */
|
|
77
|
+
export type SkipTargetProps = ComponentPropsWithRef<'div'> & {
|
|
78
|
+
id: string;
|
|
79
|
+
children?: ReactNode;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function SkipTarget({ id, className, children, ...rest }: SkipTargetProps) {
|
|
83
|
+
return (
|
|
84
|
+
// tabIndex 는 규격이 요구하는 것이라 rest 로 덮이지 않게 뒤에 둔다.
|
|
85
|
+
<div className={cx(className) || undefined} {...rest} {...skipTargetProps(id)}>
|
|
86
|
+
{children}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react';
|
|
2
|
+
import { cx } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 스피너 — KRDS `krds-spinner`.
|
|
6
|
+
*
|
|
7
|
+
* **모양을 모르거나 좁은 자리**(버튼 안·입력칸 안·화면 한복판)의 로딩 표시다.
|
|
8
|
+
* 올 것의 모양을 아는 목록·카드는 `ui/skeleton`, 진행을 셀 수 있으면 `ui/progress` 다 —
|
|
9
|
+
* 한 화면에 섞어 쓰지 않는다. 1초 미만이면 아예 쓰지 않는다.
|
|
10
|
+
* 크기 변형이 없다(지름 20px 고정) — 넓은 영역에서는 `className` 으로 여백을 벌린다.
|
|
11
|
+
* 시작과 완료를 둘 다 알리려면 {@link LoadingStatus} 로 감싼다.
|
|
12
|
+
* 입력칸 안에 겹치려면 {@link FormSpinner} 안에 넣는다.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* <LoadingStatus loading={busy} label="목록을 불러오는 중" done="목록을 불러왔습니다">
|
|
16
|
+
* 불러오는 중…
|
|
17
|
+
* </LoadingStatus>
|
|
18
|
+
* <FormSpinner>
|
|
19
|
+
* <TextInput id="q" readOnly />
|
|
20
|
+
* <Spinner label="조회 중" />
|
|
21
|
+
* </FormSpinner>
|
|
22
|
+
*
|
|
23
|
+
* 자세히: docs/krds/09-부품-노트.md#스피너
|
|
24
|
+
*/
|
|
25
|
+
export type SpinnerProps = ComponentPropsWithRef<'div'> & {
|
|
26
|
+
/** 스크린리더가 읽을 상태 문구. 회전 원뿐이라 이것이 없으면 소리로 아무것도 남지 않는다. */
|
|
27
|
+
label?: string;
|
|
28
|
+
/** 눈에 보이는 문구. 없어도 된다. */
|
|
29
|
+
children?: ReactNode;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function Spinner({ label = '로딩 중', className, children, ...rest }: SpinnerProps) {
|
|
33
|
+
return (
|
|
34
|
+
// role 은 rest 보다 앞에 둔다 — 바깥에 라이브 영역이 이미 있으면 `role={undefined}` 로 지운다.
|
|
35
|
+
<div role="status" className={cx('krds-spinner', className)} {...rest}>
|
|
36
|
+
<span className="sr-only">{label}</span>
|
|
37
|
+
{children}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 입력칸 안 스피너 감싸개 — 킷 `form-spinner`. 이 안에 넣어야 스피너가 입력칸 오른쪽 안쪽에 겹친다.
|
|
44
|
+
* 스피너가 글자 위를 덮으므로 조회 중에는 입력칸을 `readOnly` 로 두는 편이 낫다.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* <FormSpinner>
|
|
48
|
+
* <TextInput id="q" readOnly />
|
|
49
|
+
* <Spinner label="조회 중" />
|
|
50
|
+
* </FormSpinner>
|
|
51
|
+
*/
|
|
52
|
+
export function FormSpinner({ className, children, ...rest }: ComponentPropsWithRef<'div'>) {
|
|
53
|
+
return (
|
|
54
|
+
<div className={cx('form-spinner', className)} {...rest}>
|
|
55
|
+
{children}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 로딩 상태 알림 — 킷에 없다. 시작과 완료 **두 시점**을 소리로 알리려고 덧붙인 조합이다.
|
|
62
|
+
*
|
|
63
|
+
* 이 라이브 영역은 **항상 DOM 에 두고 안의 내용만 바꾼다** — 통째로 넣었다 뺐다 하면
|
|
64
|
+
* 완료를 알릴 자리가 사라진다. 스켈레톤 쪽 짝은 `ui/skeleton` 의 `SkeletonGroup` 이다.
|
|
65
|
+
*/
|
|
66
|
+
export type LoadingStatusProps = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
|
|
67
|
+
/** 참이면 스피너를, 거짓이면 완료 문구를 라이브 영역에 담는다. */
|
|
68
|
+
loading: boolean;
|
|
69
|
+
/** 로딩 중 스크린리더 문구. */
|
|
70
|
+
label?: string;
|
|
71
|
+
/** 완료 시 스크린리더 문구. 빈 문자열이면 완료를 알리지 않는다. */
|
|
72
|
+
done?: string;
|
|
73
|
+
/** 로딩 중 눈에 보이는 문구. */
|
|
74
|
+
children?: ReactNode;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function LoadingStatus({
|
|
78
|
+
loading,
|
|
79
|
+
label = '로딩 중',
|
|
80
|
+
done = '로딩이 완료되었습니다',
|
|
81
|
+
className,
|
|
82
|
+
children,
|
|
83
|
+
...rest
|
|
84
|
+
}: LoadingStatusProps) {
|
|
85
|
+
return (
|
|
86
|
+
<div role="status" className={className} {...rest}>
|
|
87
|
+
{loading ? (
|
|
88
|
+
// 바깥이 이미 라이브 영역이라 안쪽 role 은 지운다.
|
|
89
|
+
<Spinner role={undefined} label={label}>
|
|
90
|
+
{children}
|
|
91
|
+
</Spinner>
|
|
92
|
+
) : (
|
|
93
|
+
// 완료 문구는 눈에 보일 필요가 없다. 킷 `.sr-only` 로 숨긴다.
|
|
94
|
+
<span className="sr-only">{done}</span>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react';
|
|
2
|
+
import { cx } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 단계 표시기 — KRDS `krds-step-wrap`.
|
|
6
|
+
*
|
|
7
|
+
* 다단계 신청 화면 위에 놓는다. **단계는 3~7개**이고 어기면 개발 모드에서 경고한다.
|
|
8
|
+
* `current` 는 0부터 센다 — `steps.length` 를 주면 전부 완료, 음수면 전부 예정이다.
|
|
9
|
+
* 연결선·완료 체크 아이콘·여백은 킷이 그리므로 마크업이나 `mb-*` 를 더하지 마라.
|
|
10
|
+
* 레이블은 짧게 쓴다 — 킷이 줄바꿈을 막아 길면 넘쳐 잘린다.
|
|
11
|
+
* 좁은 화면에서 킷이 글자를 전부 숨기므로 「N단계 중 M단계」 요약을 부품이 대신 그린다.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* <StepIndicator current={2} steps={['유의 사항 확인', '신청인 정보', '이사 전 살던 곳', '이사 온 곳']} />
|
|
15
|
+
*
|
|
16
|
+
* 자세히: docs/krds/09-부품-노트.md#단계표시
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 단계 하나의 상태. 완료 `done` · 현재 `active` · 예정 `todo`. */
|
|
20
|
+
export type StepStatus = 'done' | 'active' | 'todo';
|
|
21
|
+
|
|
22
|
+
export type StepItem = {
|
|
23
|
+
/** 단계 레이블. 짧은 단어·문구로 — 길면 줄바꿈 없이 잘린다. */
|
|
24
|
+
label: ReactNode;
|
|
25
|
+
/** 단계 번호 글자. 생략하면 「N단계」. 다국어는 여기로 넘긴다. */
|
|
26
|
+
number?: ReactNode;
|
|
27
|
+
/** 상태를 손으로 정한다. 생략하면 `current` 로 계산한다 — 순서대로 떨어지지 않을 때만 쓴다. */
|
|
28
|
+
status?: StepStatus;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type StepIndicatorProps = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
|
|
32
|
+
/** 단계 목록. 문자열만 주면 레이블로 본다. 규격 상한·하한은 3~7 이다. */
|
|
33
|
+
steps: Array<string | StepItem>;
|
|
34
|
+
/** 현재 단계의 **0부터 센 번호**. `steps.length` 면 전부 완료, 음수면 전부 예정이다. */
|
|
35
|
+
current: number;
|
|
36
|
+
/** 현재 단계임을 알리는 숨은 글자. 기본 「현재단계」. */
|
|
37
|
+
currentLabel?: string;
|
|
38
|
+
/** 좁은 화면 요약을 직접 정한다. 생략하면 「N단계 중 M단계 + 레이블」. */
|
|
39
|
+
summary?: ReactNode;
|
|
40
|
+
/** 좁은 화면 요약을 끄고 킷 그대로(원만 남는 모습) 둔다. */
|
|
41
|
+
hideSummary?: boolean;
|
|
42
|
+
/** 감싸개에 붙일 클래스. */
|
|
43
|
+
className?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** 규격이 정한 단계 수의 하한·상한. */
|
|
47
|
+
const MIN_STEPS = 3;
|
|
48
|
+
const MAX_STEPS = 7;
|
|
49
|
+
|
|
50
|
+
export function StepIndicator({
|
|
51
|
+
steps,
|
|
52
|
+
current,
|
|
53
|
+
currentLabel = '현재단계',
|
|
54
|
+
summary,
|
|
55
|
+
hideSummary,
|
|
56
|
+
className,
|
|
57
|
+
...rest
|
|
58
|
+
}: StepIndicatorProps) {
|
|
59
|
+
if (!steps.length) return null;
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
process.env.NODE_ENV !== 'production' &&
|
|
63
|
+
(steps.length < MIN_STEPS || steps.length > MAX_STEPS)
|
|
64
|
+
) {
|
|
65
|
+
console.warn(
|
|
66
|
+
`[StepIndicator] 단계는 ${MIN_STEPS}개 이상 ${MAX_STEPS}개 이하여야 한다(받은 개수 ${steps.length}). 그대로 그리되 절차를 다시 나눠라.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const items: StepItem[] = steps.map((step) =>
|
|
71
|
+
typeof step === 'string' ? { label: step } : step,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// 현재 단계가 목록 안에 있을 때만 요약을 그린다(전부 완료·전부 예정이면 셀 「M단계」가 없다).
|
|
75
|
+
const activeIndex = current >= 0 && current < items.length ? current : -1;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
// 킷에 감싸개 클래스가 없다. 빈 class 를 남기지 않는다.
|
|
79
|
+
<div className={cx(className) || undefined} {...rest}>
|
|
80
|
+
{!hideSummary && activeIndex >= 0 && (
|
|
81
|
+
// 좁은 화면 요약. 킷이 글자를 전부 숨기는 767px 이하에서만 보인다.
|
|
82
|
+
// 숨긴 글자는 DOM 에 남아 스크린리더가 읽으므로 이 요약은 aria-hidden 이다.
|
|
83
|
+
<p className="mb-2 text-sm md:hidden" aria-hidden="true">
|
|
84
|
+
{summary ?? (
|
|
85
|
+
<>
|
|
86
|
+
<span className="text-fg-subtle">{`${items.length}단계 중 ${activeIndex + 1}단계`}</span>
|
|
87
|
+
<span className="ml-2 font-bold text-fg">{items[activeIndex]!.label}</span>
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</p>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
<ol
|
|
94
|
+
className="krds-step-wrap"
|
|
95
|
+
// 킷 전역 `ol,ul{list-style:none}` 이 목록 의미를 떨어뜨리는 브라우저가 있어 되돌린다.
|
|
96
|
+
role="list"
|
|
97
|
+
>
|
|
98
|
+
{items.map((item, i) => {
|
|
99
|
+
const status: StepStatus =
|
|
100
|
+
item.status ?? (i < current ? 'done' : i === current ? 'active' : 'todo');
|
|
101
|
+
const isActive = status === 'active';
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<li
|
|
105
|
+
key={i}
|
|
106
|
+
className={status === 'todo' ? undefined : status}
|
|
107
|
+
// ARIA 에 더 정확한 값 `step` 이 있지만 KRDS 가 적은 값은 `true` 다 — 규격을 따른다.
|
|
108
|
+
aria-current={isActive ? 'true' : undefined}
|
|
109
|
+
>
|
|
110
|
+
<span>
|
|
111
|
+
{/* 킷이 `.sr-only` 를 !important 로 숨긴다 — 화면에는 안 보이고 낭독만 된다. */}
|
|
112
|
+
{isActive && <em className="sr-only">{currentLabel}</em>}
|
|
113
|
+
<i className="step">{item.number ?? `${i + 1}단계`}</i>
|
|
114
|
+
<span className="step-tit">{item.label}</span>
|
|
115
|
+
</span>
|
|
116
|
+
</li>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</ol>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|