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,198 @@
|
|
|
1
|
+
import { UiLink } from '../lib/link';
|
|
2
|
+
import type { ComponentPropsWithRef } from 'react';
|
|
3
|
+
import { cx } from '../lib/krds';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 페이지네이션 — KRDS `krds-pagination`.
|
|
7
|
+
*
|
|
8
|
+
* 쪽 번호는 **화면에 보이는 그대로 1부터** 센다. 전체 쪽이 1 이하면 아무것도 그리지 않는다.
|
|
9
|
+
* 쪽이 주소에 실리면 `href`, 상태로만 있으면 `onPageChange` 를 준다(둘 중 하나만).
|
|
10
|
+
* 전체 쪽을 다 그리지 않고 `maxLinks`(기본 10) 안에서 말줄임으로 줄인다 — 규격 상한은 11 이다.
|
|
11
|
+
* 여백은 킷이 잡으므로 `mt-*` 를 더하지 마라. 한 화면에 하나만 둔다.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* <Pagination page={page} totalPages={total} href={(p) => `?page=${p}`} label="공지사항 페이지네이션" />
|
|
15
|
+
* <Pagination page={page} totalPages={total} onPageChange={setPage} maxLinks={7} />
|
|
16
|
+
*
|
|
17
|
+
* 자세히: docs/krds/09-부품-노트.md#페이지네이션
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 숫자 칸 하나. 숫자이거나 말줄임(`null`)이다. */
|
|
21
|
+
export type PageSlot = number | null;
|
|
22
|
+
|
|
23
|
+
const range = (from: number, to: number): number[] =>
|
|
24
|
+
Array.from({ length: to - from + 1 }, (_, i) => from + i);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 그릴 칸을 고른다 — 첫 쪽·마지막 쪽은 항상, 가운데는 현재 쪽 둘레만, 나머지는 말줄임(`null`).
|
|
28
|
+
* `totalPages > maxLinks` 이면 길이가 정확히 `maxLinks` 다.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* pageSlots(4, 99) // [1,2,3,4,5,6,7,8,null,99]
|
|
32
|
+
* pageSlots(50, 99) // [1,null,48,49,50,51,52,53,null,99]
|
|
33
|
+
*/
|
|
34
|
+
export function pageSlots(page: number, totalPages: number, maxLinks = 10): PageSlot[] {
|
|
35
|
+
const total = Math.max(1, Math.floor(totalPages));
|
|
36
|
+
const cur = Math.min(Math.max(1, Math.floor(page)), total);
|
|
37
|
+
// 첫 쪽·말줄임·현재 쪽·말줄임·마지막 쪽 — 최소 5칸은 있어야 한다.
|
|
38
|
+
const n = Math.max(5, Math.floor(maxLinks));
|
|
39
|
+
|
|
40
|
+
if (total <= n) return range(1, total);
|
|
41
|
+
|
|
42
|
+
// 가운데 창 = 전체 칸 - (첫·마지막 숫자 2 + 말줄임 2)
|
|
43
|
+
const win = n - 4;
|
|
44
|
+
let start = cur - Math.floor((win - 1) / 2);
|
|
45
|
+
let end = start + win - 1;
|
|
46
|
+
|
|
47
|
+
if (start <= 3) {
|
|
48
|
+
// 앞쪽에 붙었다 — 왼쪽 말줄임이 필요 없으니 창이 한 칸 넓어진다.
|
|
49
|
+
start = 2;
|
|
50
|
+
end = start + (n - 3) - 1;
|
|
51
|
+
return [1, ...range(start, end), null, total];
|
|
52
|
+
}
|
|
53
|
+
if (end >= total - 2) {
|
|
54
|
+
// 뒤쪽에 붙었다.
|
|
55
|
+
end = total - 1;
|
|
56
|
+
start = end - (n - 3) + 1;
|
|
57
|
+
return [1, null, ...range(start, end), total];
|
|
58
|
+
}
|
|
59
|
+
return [1, null, ...range(start, end), null, total];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type PaginationBaseProps = {
|
|
63
|
+
/** 현재 쪽. 화면에 보이는 번호 그대로 1부터 센다. */
|
|
64
|
+
page: number;
|
|
65
|
+
/** 전체 쪽수. 1 이하면 아무것도 그리지 않는다. */
|
|
66
|
+
totalPages: number;
|
|
67
|
+
/** 말줄임을 포함한 숫자 칸 최대 개수. 기본 10(규격 상한 11, 좁은 화면 권장 7). */
|
|
68
|
+
maxLinks?: number;
|
|
69
|
+
/** `<nav>` 의 이름. 「공지사항 페이지네이션」처럼 어떤 목록인지 담는다. */
|
|
70
|
+
label?: string;
|
|
71
|
+
/** 이전 단추 글자. */
|
|
72
|
+
prevLabel?: string;
|
|
73
|
+
/** 다음 단추 글자. */
|
|
74
|
+
nextLabel?: string;
|
|
75
|
+
/** `<nav>` 에 붙일 클래스. */
|
|
76
|
+
className?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** 링크 방식(기본). 쪽이 주소에 실릴 때 — 공유·뒤로가기가 되어야 하는 목록. */
|
|
80
|
+
export type PaginationLinkProps = PaginationBaseProps & {
|
|
81
|
+
/** 1부터 센 쪽 번호를 받아 주소를 돌려준다. 서버가 0부터 세면 여기서 낮춘다. */
|
|
82
|
+
href: (page: number) => string;
|
|
83
|
+
onPageChange?: never;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** 단추 방식. 쪽이 주소에 없고 상태로만 있을 때. 부르는 쪽이 클라이언트 컴포넌트여야 한다. */
|
|
87
|
+
export type PaginationButtonProps = PaginationBaseProps & {
|
|
88
|
+
/** 쪽을 옮길 때. */
|
|
89
|
+
onPageChange: (page: number) => void;
|
|
90
|
+
href?: never;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type PaginationProps = Omit<ComponentPropsWithRef<'nav'>, 'children' | 'className'> &
|
|
94
|
+
(PaginationLinkProps | PaginationButtonProps);
|
|
95
|
+
|
|
96
|
+
/** 위 합집합을 안에서 풀어 쓰기 위한 형태. */
|
|
97
|
+
type ResolvedPaginationProps = PaginationBaseProps &
|
|
98
|
+
Omit<ComponentPropsWithRef<'nav'>, 'children' | 'className'> & {
|
|
99
|
+
href?: (page: number) => string;
|
|
100
|
+
onPageChange?: (page: number) => void;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function Pagination(props: PaginationProps) {
|
|
104
|
+
const {
|
|
105
|
+
page,
|
|
106
|
+
totalPages,
|
|
107
|
+
maxLinks = 10,
|
|
108
|
+
label = '페이지네이션',
|
|
109
|
+
prevLabel = '이전',
|
|
110
|
+
nextLabel = '다음',
|
|
111
|
+
href,
|
|
112
|
+
onPageChange,
|
|
113
|
+
className,
|
|
114
|
+
...rest
|
|
115
|
+
} = props as ResolvedPaginationProps;
|
|
116
|
+
|
|
117
|
+
const total = Math.max(0, Math.floor(totalPages));
|
|
118
|
+
// 규격: 전체 데이터가 적을 때(화면이 하나뿐일 때)는 페이지네이션을 쓰지 않는다.
|
|
119
|
+
if (total <= 1) return null;
|
|
120
|
+
|
|
121
|
+
const cur = Math.min(Math.max(1, Math.floor(page)), total);
|
|
122
|
+
|
|
123
|
+
// 현재 쪽만 `href` 를 빼고 `aria-current="true"` 를 준다(규격). 이름은 가이드 예문 그대로다.
|
|
124
|
+
const renderPage = (n: number) => {
|
|
125
|
+
if (n === cur) {
|
|
126
|
+
return (
|
|
127
|
+
<a className="page-link active" aria-current="true">
|
|
128
|
+
<span className="sr-only">현재페이지 </span>
|
|
129
|
+
{n}
|
|
130
|
+
</a>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const name = n === total ? `마지막 페이지, ${n}` : `페이지 ${n}`;
|
|
134
|
+
return href ? (
|
|
135
|
+
<UiLink className="page-link" href={href(n)} aria-label={name}>
|
|
136
|
+
{n}
|
|
137
|
+
</UiLink>
|
|
138
|
+
) : (
|
|
139
|
+
<button type="button" className="page-link" aria-label={name} onClick={() => onPageChange?.(n)}>
|
|
140
|
+
{n}
|
|
141
|
+
</button>
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// 이전/다음. 끝에 닿으면 킷 샘플대로 `<span class="disabled">` 다.
|
|
146
|
+
// hover 배경은 킷이 없는 토큰을 참조하는 결함이 있어 킷의 자기 토큰으로 되돌린다.
|
|
147
|
+
//
|
|
148
|
+
// `aria-disabled` 를 반드시 붙인다. 킷 글자색이 흐려(#8a949e, 배경 #f4f5f6 위 2.82:1) 그냥 두면
|
|
149
|
+
// axe 가 「읽으라고 둔 글자인데 대비가 모자라다」로 본다(WCAG 1.4.3). 비활성 컨트롤은 대비 요구에서
|
|
150
|
+
// 빠지지만, `<span>` 만으로는 「비활성」임을 알릴 방법이 없어 판정에 걸린다 — 실측으로 잡은 것이다.
|
|
151
|
+
const renderNavi = (dir: 'prev' | 'next') => {
|
|
152
|
+
const to = dir === 'prev' ? cur - 1 : cur + 1;
|
|
153
|
+
const text = dir === 'prev' ? prevLabel : nextLabel;
|
|
154
|
+
const off = dir === 'prev' ? cur <= 1 : cur >= total;
|
|
155
|
+
if (off) {
|
|
156
|
+
return (
|
|
157
|
+
<span
|
|
158
|
+
aria-disabled="true"
|
|
159
|
+
className={cx(
|
|
160
|
+
'page-navi',
|
|
161
|
+
dir,
|
|
162
|
+
'disabled',
|
|
163
|
+
'hover:bg-[var(--krds-pagination--color-action)] active:bg-[var(--krds-pagination--color-action)]',
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{text}
|
|
167
|
+
</span>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return href ? (
|
|
171
|
+
<UiLink className={cx('page-navi', dir)} href={href(to)}>
|
|
172
|
+
{text}
|
|
173
|
+
</UiLink>
|
|
174
|
+
) : (
|
|
175
|
+
<button type="button" className={cx('page-navi', dir)} onClick={() => onPageChange?.(to)}>
|
|
176
|
+
{text}
|
|
177
|
+
</button>
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<nav aria-label={label} className={cx('krds-pagination', className)} {...rest}>
|
|
183
|
+
{renderNavi('prev')}
|
|
184
|
+
<ul className="page-links">
|
|
185
|
+
{pageSlots(cur, total, maxLinks).map((slot, i) => (
|
|
186
|
+
<li key={slot === null ? `dot-${i}` : slot}>
|
|
187
|
+
{slot === null ? (
|
|
188
|
+
<span className="page-link link-dot" aria-hidden="true" />
|
|
189
|
+
) : (
|
|
190
|
+
renderPage(slot)
|
|
191
|
+
)}
|
|
192
|
+
</li>
|
|
193
|
+
))}
|
|
194
|
+
</ul>
|
|
195
|
+
{renderNavi('next')}
|
|
196
|
+
</nav>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef, ReactNode } from 'react';
|
|
2
|
+
import { cx, type KrdsSize, type KrdsTone } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 진행 막대 — 킷에 없다. KRDS 토큰으로 그리는 보충 부품이다.
|
|
6
|
+
*
|
|
7
|
+
* **끝이 있는 일의 진행**에 쓴다. 한도 사용량·준수율처럼 오르내리는 값은 {@link Meter} 다
|
|
8
|
+
* (ARIA 가 둘을 다른 역할로 나눠 두었다). 1초 미만이면 아예 쓰지 않는다.
|
|
9
|
+
* `value` 를 생략하면 불확정(맥동)이 되고 `aria-valuenow` 가 빠진다.
|
|
10
|
+
* 진행 자체는 조용히 두고, 알릴 말은 `status`(= `role="status"`)로 보낸다.
|
|
11
|
+
* 모양을 모르거나 좁은 자리는 `ui/spinner`, 목록·카드 자리는 `ui/skeleton` 이다.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* <Progress label="파일 올리는 중" value={42} status={`3/12 — 「${name}」 처리 중`} />
|
|
15
|
+
* <Progress label="집계 중" size="small" />
|
|
16
|
+
* <Meter label="오늘 한도 사용량" value={used} max={quota} valueText={`${used}건 / ${quota}건`} />
|
|
17
|
+
*
|
|
18
|
+
* 자세히: docs/krds/09-부품-노트.md#진행상태
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 굵기 — 8 / 12 / 16px. KRDS 모서리 표의 프로그레스 바 컨테이너 크기다. */
|
|
22
|
+
export type ProgressSize = Extract<KrdsSize, 'small' | 'medium' | 'large'>;
|
|
23
|
+
|
|
24
|
+
/** 색. `gray`(solid 토큰 없음)와 `warning`(트랙 대비 1.48:1 로 규격 미달)은 뺐다. */
|
|
25
|
+
export type ProgressTone = Exclude<KrdsTone, 'gray' | 'warning'>;
|
|
26
|
+
|
|
27
|
+
/** 굵기 → 높이 유틸리티. */
|
|
28
|
+
const SIZE_CLASS: Record<ProgressSize, string> = {
|
|
29
|
+
small: 'h-2',
|
|
30
|
+
medium: 'h-3',
|
|
31
|
+
large: 'h-4',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** 색 → 채움 유틸리티. 완성된 문자열로 적는다 — 조립하면 Tailwind 가 클래스를 만들지 않는다. */
|
|
35
|
+
const TONE_CLASS: Record<ProgressTone, string> = {
|
|
36
|
+
primary: 'bg-primary-solid',
|
|
37
|
+
secondary: 'bg-secondary-solid',
|
|
38
|
+
point: 'bg-point-solid',
|
|
39
|
+
danger: 'bg-danger-solid',
|
|
40
|
+
success: 'bg-success-solid',
|
|
41
|
+
information: 'bg-information-solid',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** 트랙. 채움과의 대비를 3:1 위로 올려 두는 값이다. */
|
|
45
|
+
const TRACK_CLASS = 'w-full overflow-hidden rounded-xs bg-surface-subtle';
|
|
46
|
+
|
|
47
|
+
export type ProgressProps = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
|
|
48
|
+
/** 무엇의 진행인지. 필수다 — 막대의 `aria-label` 이 되므로 `string` 이어야 한다. */
|
|
49
|
+
label: string;
|
|
50
|
+
/** 레이블을 눈에 보이지 않게 한다. 이미 머리글이 있는 자리에서만. */
|
|
51
|
+
labelHidden?: boolean;
|
|
52
|
+
/** 현재 값. **생략하면 불확정**(`aria-valuenow` 를 빼고 맥동한다). */
|
|
53
|
+
value?: number;
|
|
54
|
+
/** 값의 아래끝. 기본 0. */
|
|
55
|
+
min?: number;
|
|
56
|
+
/** 값의 위끝. 기본 100. */
|
|
57
|
+
max?: number;
|
|
58
|
+
/** 스크린리더가 읽을 값 문구. 「12개 중 5개 끝남」처럼. 주면 보이는 수치도 이 문구가 된다. */
|
|
59
|
+
valueText?: string;
|
|
60
|
+
/** 눈에 보이는 수치를 그린다. 기본 true(불확정이면 그리지 않는다). */
|
|
61
|
+
showValue?: boolean;
|
|
62
|
+
/** 굵기. 기본 medium(12px). */
|
|
63
|
+
size?: ProgressSize;
|
|
64
|
+
/** 채움 색. 기본 primary. */
|
|
65
|
+
tone?: ProgressTone;
|
|
66
|
+
/** 막대 아래 한 줄 설명. `role="status"` 라 바뀌면 스크린리더가 읽어 준다. */
|
|
67
|
+
status?: ReactNode;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function Progress({
|
|
71
|
+
label,
|
|
72
|
+
labelHidden,
|
|
73
|
+
value,
|
|
74
|
+
min = 0,
|
|
75
|
+
max = 100,
|
|
76
|
+
valueText,
|
|
77
|
+
showValue = true,
|
|
78
|
+
size = 'medium',
|
|
79
|
+
tone = 'primary',
|
|
80
|
+
status,
|
|
81
|
+
className,
|
|
82
|
+
...rest
|
|
83
|
+
}: ProgressProps) {
|
|
84
|
+
const span = max - min;
|
|
85
|
+
const indeterminate = value === undefined || !Number.isFinite(value);
|
|
86
|
+
|
|
87
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
88
|
+
if (span <= 0) {
|
|
89
|
+
console.warn(`[Progress] max(${max})가 min(${min})보다 커야 한다. 0% 로 그린다.`);
|
|
90
|
+
} else if (!indeterminate && (value! < min || value! > max)) {
|
|
91
|
+
console.warn(
|
|
92
|
+
`[Progress] value(${value})가 ${min}~${max} 밖이다. 잘라서 그리지만 보이는 값과 계산이 어긋난다.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 폭은 자르지 않은 소수 그대로, 글자는 반올림. 「보이는 폭 ≠ 읽히는 값」을 만들지 않는다.
|
|
98
|
+
const ratio = indeterminate || span <= 0 ? 0 : Math.min(1, Math.max(0, (value! - min) / span));
|
|
99
|
+
const percent = ratio * 100;
|
|
100
|
+
const readout = valueText ?? `${Math.round(percent)}%`;
|
|
101
|
+
const showReadout = showValue && !indeterminate;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className={cx('w-full', className)} {...rest}>
|
|
105
|
+
{(!labelHidden || showReadout) && (
|
|
106
|
+
// 이름과 값은 ARIA 로 이미 준다 — 여기 글자는 눈으로 볼 몫이라 가린다.
|
|
107
|
+
<div className="mb-2 flex items-baseline justify-between gap-2" aria-hidden="true">
|
|
108
|
+
{labelHidden ? <span /> : <span className="text-sm text-fg-subtle">{label}</span>}
|
|
109
|
+
{showReadout && <span className="text-sm font-bold text-fg tabular-nums">{readout}</span>}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
<div
|
|
114
|
+
role="progressbar"
|
|
115
|
+
aria-label={label}
|
|
116
|
+
aria-valuemin={min}
|
|
117
|
+
aria-valuemax={max}
|
|
118
|
+
// 불확정은 valuenow 를 「빼는 것」이 신호다. 0 을 넣으면 멈춘 0% 가 된다.
|
|
119
|
+
aria-valuenow={indeterminate ? undefined : Math.min(max, Math.max(min, value!))}
|
|
120
|
+
aria-valuetext={indeterminate ? undefined : valueText}
|
|
121
|
+
aria-busy={indeterminate || undefined}
|
|
122
|
+
className={cx(TRACK_CLASS, SIZE_CLASS[size])}
|
|
123
|
+
>
|
|
124
|
+
{indeterminate ? (
|
|
125
|
+
// 가운데 3분의 1. 움직임을 끈 사용자에게 「가득 찬 채 멈춘 막대」를 남기지 않는다.
|
|
126
|
+
<div className={cx('mx-auto h-full w-1/3 animate-pulse rounded-xs', TONE_CLASS[tone])} />
|
|
127
|
+
) : (
|
|
128
|
+
<div
|
|
129
|
+
className={cx('h-full rounded-xs transition-[width]', TONE_CLASS[tone])}
|
|
130
|
+
style={{ width: `${percent}%` }}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{status !== undefined && status !== null && (
|
|
136
|
+
<p className="mt-2 text-sm text-fg-subtle" role="status">
|
|
137
|
+
{status}
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 비율 막대 — `role="meter"`. **진행이 아니라 「지금 얼마」다.**
|
|
146
|
+
*
|
|
147
|
+
* 한도 사용량·준수율처럼 오르내릴 수 있고 끝나지 않는 값에 쓴다. 불확정은 없다(`value` 필수).
|
|
148
|
+
* `role="meter"` 는 스크린리더 지원이 고르지 않으니 `label` 을 값 없이도 뜻이 서는 말로 짓고
|
|
149
|
+
* 정확한 수치는 `valueText` 로 준다.
|
|
150
|
+
*/
|
|
151
|
+
export type MeterProps = Omit<ProgressProps, 'value' | 'showValue' | 'status'> & {
|
|
152
|
+
value: number;
|
|
153
|
+
showValue?: boolean;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export function Meter({
|
|
157
|
+
label,
|
|
158
|
+
labelHidden,
|
|
159
|
+
value,
|
|
160
|
+
min = 0,
|
|
161
|
+
max = 100,
|
|
162
|
+
valueText,
|
|
163
|
+
showValue = true,
|
|
164
|
+
size = 'medium',
|
|
165
|
+
tone = 'primary',
|
|
166
|
+
className,
|
|
167
|
+
...rest
|
|
168
|
+
}: MeterProps) {
|
|
169
|
+
const span = max - min;
|
|
170
|
+
const ratio = span <= 0 ? 0 : Math.min(1, Math.max(0, (value - min) / span));
|
|
171
|
+
const percent = ratio * 100;
|
|
172
|
+
const readout = valueText ?? `${Math.round(percent)}%`;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className={cx('w-full', className)} {...rest}>
|
|
176
|
+
{(!labelHidden || showValue) && (
|
|
177
|
+
<div className="mb-2 flex items-baseline justify-between gap-2" aria-hidden="true">
|
|
178
|
+
{labelHidden ? <span /> : <span className="text-sm text-fg-subtle">{label}</span>}
|
|
179
|
+
{showValue && <span className="text-sm font-bold text-fg tabular-nums">{readout}</span>}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
<div
|
|
183
|
+
role="meter"
|
|
184
|
+
aria-label={label}
|
|
185
|
+
aria-valuemin={min}
|
|
186
|
+
aria-valuemax={max}
|
|
187
|
+
aria-valuenow={Math.min(max, Math.max(min, value))}
|
|
188
|
+
aria-valuetext={valueText}
|
|
189
|
+
className={cx(TRACK_CLASS, SIZE_CLASS[size])}
|
|
190
|
+
>
|
|
191
|
+
<div
|
|
192
|
+
className={cx('h-full rounded-xs transition-[width]', TONE_CLASS[tone])}
|
|
193
|
+
style={{ width: `${percent}%` }}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import type { ChangeEvent, ComponentPropsWithRef, ReactNode } from 'react';
|
|
2
|
+
import { cx, type KrdsSize } from '../lib/krds';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 라디오 버튼 — KRDS `krds-form-check` · `krds-form-chip`.
|
|
6
|
+
*
|
|
7
|
+
* `name` 을 공유하는 것끼리 한 묶음이 되고 방향키 이동은 브라우저가 한다.
|
|
8
|
+
* **`<label>` 안에 `<input>` 을 넣으면 동그라미가 아예 안 그려진다** — 이 부품이 그 골격을 강제한다.
|
|
9
|
+
* 보통은 {@link RadioGroup} 에 `options` 를 넘겨 한 번에 그린다(그래야 `<fieldset>`/`<legend>` 가 붙는다).
|
|
10
|
+
* 여러 값을 고르는 자리는 `ui/checkbox` 다.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* <RadioGroup label="공개 범위" name="scope" options={[
|
|
14
|
+
* { value: 'all', label: '전체 공개' },
|
|
15
|
+
* { value: 'org', label: '기관 내부', description: '소속 기관 승인 뒤 열립니다.' },
|
|
16
|
+
* ]} />
|
|
17
|
+
* <RadioGroup label="동의" name="agree" chip equalWidth options={[
|
|
18
|
+
* { value: 'y', label: '예' }, { value: 'n', label: '아니오' },
|
|
19
|
+
* ]} />
|
|
20
|
+
*
|
|
21
|
+
* 자세히: docs/krds/09-부품-노트.md#라디오
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 항목 크기. 킷에 있는 둘뿐이다 — 동그라미 20px / 24px, 글자 17px / 19px. */
|
|
25
|
+
export type RadioSize = Extract<KrdsSize, 'medium' | 'large'>;
|
|
26
|
+
|
|
27
|
+
/** 칩 크기. 높이 40 / 48 / 56px — 버튼·입력칸과 같은 눈금이라 한 줄에 섞어도 어긋나지 않는다. */
|
|
28
|
+
export type RadioChipSize = Extract<KrdsSize, 'small' | 'medium' | 'large'>;
|
|
29
|
+
|
|
30
|
+
/** id 를 `name`+`value` 로 만든다. `useId()` 를 쓰면 부품이 클라이언트로 내려가는데 그럴 이유가 없다. */
|
|
31
|
+
function radioId(name: string, value: string): string {
|
|
32
|
+
return `${name}-${value}`.replace(/\s+/g, '-');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
36
|
+
|
|
37
|
+
export type RadioProps = Omit<
|
|
38
|
+
ComponentPropsWithRef<'input'>,
|
|
39
|
+
'type' | 'size' | 'value' | 'className'
|
|
40
|
+
> & {
|
|
41
|
+
/** 그룹 이름. 같은 값을 가진 라디오끼리 한 묶음이 되고 방향키가 그 안에서 돈다. */
|
|
42
|
+
name: string;
|
|
43
|
+
/** 값. 같은 `name` 안에서 유일해야 한다 — `id` 를 이 값으로 만든다. */
|
|
44
|
+
value: string;
|
|
45
|
+
/** `<label>` 에 들어갈 글. */
|
|
46
|
+
label: ReactNode;
|
|
47
|
+
/** 부가 설명. 세로 배치와 함께 쓴다. `aria-describedby` 로 이어진다. */
|
|
48
|
+
description?: ReactNode;
|
|
49
|
+
/** 크기. 기본 medium. */
|
|
50
|
+
size?: RadioSize;
|
|
51
|
+
/** 감싸개 `div.krds-form-check` 에 붙는다(여백 조절은 여기서). */
|
|
52
|
+
className?: string;
|
|
53
|
+
/** `<input>` 에 붙는다. */
|
|
54
|
+
inputClassName?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** 라디오 한 개. `id` 를 주지 않으면 `name`-`value` 로 만든다. 묶음은 {@link RadioGroup} 이다. */
|
|
58
|
+
export function Radio({
|
|
59
|
+
name,
|
|
60
|
+
value,
|
|
61
|
+
label,
|
|
62
|
+
description,
|
|
63
|
+
size = 'medium',
|
|
64
|
+
className,
|
|
65
|
+
inputClassName,
|
|
66
|
+
id,
|
|
67
|
+
'aria-describedby': describedBy,
|
|
68
|
+
...rest
|
|
69
|
+
}: RadioProps) {
|
|
70
|
+
const inputId = id ?? radioId(name, value);
|
|
71
|
+
const descId = description ? `${inputId}-desc` : undefined;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={cx('krds-form-check', size, className)}>
|
|
75
|
+
<input
|
|
76
|
+
type="radio"
|
|
77
|
+
id={inputId}
|
|
78
|
+
name={name}
|
|
79
|
+
value={value}
|
|
80
|
+
className={inputClassName}
|
|
81
|
+
aria-describedby={cx(describedBy, descId) || undefined}
|
|
82
|
+
{...rest}
|
|
83
|
+
/>
|
|
84
|
+
<label htmlFor={inputId}>{label}</label>
|
|
85
|
+
{description ? (
|
|
86
|
+
// 킷 규칙이 `label + .krds-form-check-cnt` 라 label 바로 다음 형제여야 왼쪽 들여쓰기가 맞는다.
|
|
87
|
+
<div className="krds-form-check-cnt">
|
|
88
|
+
<p className="krds-form-check-p" id={descId}>
|
|
89
|
+
{description}
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 칩 포커스 테두리. 킷은 JS 가 붙이는 `.focus` 클래스를 전제하는데 그 JS 가 없어
|
|
101
|
+
* CSS 형제 선택(`peer` / `peer-focus`)으로 같은 그림을 만든다. 값은 킷 토큰 그대로다.
|
|
102
|
+
*/
|
|
103
|
+
const CHIP_FOCUS_RING = 'peer-focus:[box-shadow:var(--krds-box-shadow-outline-inset)]';
|
|
104
|
+
|
|
105
|
+
export type RadioChipProps = Omit<
|
|
106
|
+
ComponentPropsWithRef<'input'>,
|
|
107
|
+
'type' | 'size' | 'value' | 'className'
|
|
108
|
+
> & {
|
|
109
|
+
/** 그룹 이름. */
|
|
110
|
+
name: string;
|
|
111
|
+
/** 값. 같은 `name` 안에서 유일해야 한다. */
|
|
112
|
+
value: string;
|
|
113
|
+
/** 칩에 보이는 글. */
|
|
114
|
+
label: ReactNode;
|
|
115
|
+
/** 크기. 기본 medium(48px). */
|
|
116
|
+
size?: RadioChipSize;
|
|
117
|
+
/** 감싸개 `div.krds-form-chip` 에 붙는다. */
|
|
118
|
+
className?: string;
|
|
119
|
+
/** `<input>` 에 붙는다. */
|
|
120
|
+
inputClassName?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 칩 라디오 — 테두리 알약을 눌러 고른다. 예/아니오처럼 선택지가 짧을 때 쓴다.
|
|
125
|
+
* 체크 아이콘은 킷이 알아서 켜고 끈다.
|
|
126
|
+
*/
|
|
127
|
+
export function RadioChip({
|
|
128
|
+
name,
|
|
129
|
+
value,
|
|
130
|
+
label,
|
|
131
|
+
size = 'medium',
|
|
132
|
+
className,
|
|
133
|
+
inputClassName,
|
|
134
|
+
id,
|
|
135
|
+
...rest
|
|
136
|
+
}: RadioChipProps) {
|
|
137
|
+
const inputId = id ?? radioId(name, value);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className={cx('krds-form-chip', size, className)}>
|
|
141
|
+
<input
|
|
142
|
+
type="radio"
|
|
143
|
+
// `radio` 는 킷 샘플이 붙이는 클래스다(chip-wrap 안에서 선택자로 쓰인다).
|
|
144
|
+
className={cx('radio peer', inputClassName)}
|
|
145
|
+
id={inputId}
|
|
146
|
+
name={name}
|
|
147
|
+
value={value}
|
|
148
|
+
{...rest}
|
|
149
|
+
/>
|
|
150
|
+
<label className={cx('krds-form-chip-outline', CHIP_FOCUS_RING)} htmlFor={inputId}>
|
|
151
|
+
{label}
|
|
152
|
+
</label>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* ────────────────────────────────────────────────────────────────────────── */
|
|
158
|
+
|
|
159
|
+
/** `RadioGroup` 이 직접 그릴 항목. */
|
|
160
|
+
export type RadioOption<T extends string = string> = {
|
|
161
|
+
/** 값. */
|
|
162
|
+
value: T;
|
|
163
|
+
/** 화면에 보이는 글. */
|
|
164
|
+
label: ReactNode;
|
|
165
|
+
/** 부가 설명. 하나라도 있으면 세로 배치가 맞는다. */
|
|
166
|
+
description?: ReactNode;
|
|
167
|
+
/** 비활성. */
|
|
168
|
+
disabled?: boolean;
|
|
169
|
+
/** 기본값은 `name`-`value`. 한 페이지에 같은 `name` 을 두 번 쓸 때만 손으로 준다. */
|
|
170
|
+
id?: string;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export type RadioGroupProps<T extends string = string> = Omit<
|
|
174
|
+
ComponentPropsWithRef<'fieldset'>,
|
|
175
|
+
'onChange' | 'children'
|
|
176
|
+
> & {
|
|
177
|
+
/** 묶음 이름. `<legend>` 로 들어간다 — 킷이 화면에서 감추지만 스크린리더는 읽는다. */
|
|
178
|
+
label: ReactNode;
|
|
179
|
+
/** 소제목을 화면에도 보이게 한다. 사본이라 낭독에서는 빠진다. */
|
|
180
|
+
labelVisible?: boolean;
|
|
181
|
+
/** 그룹 이름. 방향키 이동이 이 값을 공유하는 라디오 안에서 돈다. */
|
|
182
|
+
name: string;
|
|
183
|
+
/** 항목. 주면 `RadioGroup` 이 직접 그린다. 생략하고 `children` 으로 손수 넣어도 된다. */
|
|
184
|
+
options?: readonly RadioOption<T>[];
|
|
185
|
+
/** 통제 컴포넌트로 쓸 때의 현재 값. */
|
|
186
|
+
value?: T;
|
|
187
|
+
/** 비통제로 쓸 때의 처음 값. 정확한 값이 중요하면 **기본 선택값을 주지 않는다**(규격). */
|
|
188
|
+
defaultValue?: T;
|
|
189
|
+
/** 값이 바뀔 때. */
|
|
190
|
+
onValueChange?: (value: T) => void;
|
|
191
|
+
/** 항목 크기. 기본 medium. */
|
|
192
|
+
size?: RadioSize;
|
|
193
|
+
/** 칩 크기. 기본 medium. */
|
|
194
|
+
chipSize?: RadioChipSize;
|
|
195
|
+
/** 칩 형태로 그린다. */
|
|
196
|
+
chip?: boolean;
|
|
197
|
+
/** 배치. 기본은 세로다(규격). 칩은 성질상 가로가 기본. */
|
|
198
|
+
orientation?: 'vertical' | 'horizontal';
|
|
199
|
+
/** 칩을 같은 너비로 늘린다(킷 `chip-wrap`). */
|
|
200
|
+
equalWidth?: boolean;
|
|
201
|
+
/** `div.krds-check-area` 에 붙는다. `className` 은 바깥 `<fieldset>` 에 붙는다. */
|
|
202
|
+
areaClassName?: string;
|
|
203
|
+
children?: ReactNode;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 라디오 묶음 — `fieldset > legend + div.krds-check-area`. 규격이 요구하는 골격이다.
|
|
208
|
+
*
|
|
209
|
+
* `disabled` 를 주면 안에 든 라디오가 전부 잠긴다(브라우저 기본 동작).
|
|
210
|
+
*/
|
|
211
|
+
export function RadioGroup<T extends string = string>({
|
|
212
|
+
label,
|
|
213
|
+
labelVisible,
|
|
214
|
+
name,
|
|
215
|
+
options,
|
|
216
|
+
value,
|
|
217
|
+
defaultValue,
|
|
218
|
+
onValueChange,
|
|
219
|
+
size = 'medium',
|
|
220
|
+
chipSize = 'medium',
|
|
221
|
+
chip,
|
|
222
|
+
orientation,
|
|
223
|
+
equalWidth,
|
|
224
|
+
className,
|
|
225
|
+
areaClassName,
|
|
226
|
+
children,
|
|
227
|
+
...rest
|
|
228
|
+
}: RadioGroupProps<T>) {
|
|
229
|
+
const vertical = (orientation ?? (chip ? 'horizontal' : 'vertical')) === 'vertical';
|
|
230
|
+
const controlled = value !== undefined;
|
|
231
|
+
|
|
232
|
+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
233
|
+
onValueChange?.(e.currentTarget.value as T);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<fieldset
|
|
238
|
+
// 브라우저 기본 테두리를 지우고, flex/grid 안에서 줄어들지 못하는 기본값을 푼다.
|
|
239
|
+
className={cx('border-0 min-w-0', className)}
|
|
240
|
+
{...rest}
|
|
241
|
+
>
|
|
242
|
+
<legend>{label}</legend>
|
|
243
|
+
{labelVisible ? (
|
|
244
|
+
// `<legend>` 가 이미 읽히므로 사본은 낭독에서 뺀다.
|
|
245
|
+
<p className="form-label mb-3" aria-hidden="true">
|
|
246
|
+
{label}
|
|
247
|
+
</p>
|
|
248
|
+
) : null}
|
|
249
|
+
|
|
250
|
+
<div
|
|
251
|
+
className={cx(
|
|
252
|
+
'krds-check-area',
|
|
253
|
+
vertical && 'chk-column',
|
|
254
|
+
equalWidth && 'chip-wrap',
|
|
255
|
+
areaClassName,
|
|
256
|
+
)}
|
|
257
|
+
>
|
|
258
|
+
{options?.map((o) => {
|
|
259
|
+
const common = {
|
|
260
|
+
name,
|
|
261
|
+
value: o.value,
|
|
262
|
+
label: o.label,
|
|
263
|
+
id: o.id,
|
|
264
|
+
disabled: o.disabled,
|
|
265
|
+
onChange: handleChange,
|
|
266
|
+
...(controlled
|
|
267
|
+
? { checked: o.value === value }
|
|
268
|
+
: defaultValue !== undefined
|
|
269
|
+
? { defaultChecked: o.value === defaultValue }
|
|
270
|
+
: null),
|
|
271
|
+
};
|
|
272
|
+
// key 는 spread 밖에 둔다 — props 객체에 섞으면 React 가 경고한다.
|
|
273
|
+
return chip ? (
|
|
274
|
+
<RadioChip key={o.value} {...common} size={chipSize} />
|
|
275
|
+
) : (
|
|
276
|
+
<Radio key={o.value} {...common} size={size} description={o.description} />
|
|
277
|
+
);
|
|
278
|
+
})}
|
|
279
|
+
{children}
|
|
280
|
+
</div>
|
|
281
|
+
</fieldset>
|
|
282
|
+
);
|
|
283
|
+
}
|