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.
Files changed (247) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/dist/accordion/index.d.ts +63 -0
  4. package/dist/accordion/index.d.ts.map +1 -0
  5. package/dist/accordion/index.js +145 -0
  6. package/dist/accordion/index.js.map +1 -0
  7. package/dist/alert/index.d.ts +44 -0
  8. package/dist/alert/index.d.ts.map +1 -0
  9. package/dist/alert/index.js +66 -0
  10. package/dist/alert/index.js.map +1 -0
  11. package/dist/avatar/index.d.ts +102 -0
  12. package/dist/avatar/index.d.ts.map +1 -0
  13. package/dist/avatar/index.js +146 -0
  14. package/dist/avatar/index.js.map +1 -0
  15. package/dist/badge/index.d.ts +49 -0
  16. package/dist/badge/index.d.ts.map +1 -0
  17. package/dist/badge/index.js +41 -0
  18. package/dist/badge/index.js.map +1 -0
  19. package/dist/breadcrumb/index.d.ts +43 -0
  20. package/dist/breadcrumb/index.d.ts.map +1 -0
  21. package/dist/breadcrumb/index.js +43 -0
  22. package/dist/breadcrumb/index.js.map +1 -0
  23. package/dist/button/index.d.ts +78 -0
  24. package/dist/button/index.d.ts.map +1 -0
  25. package/dist/button/index.js +83 -0
  26. package/dist/button/index.js.map +1 -0
  27. package/dist/checkbox/index.d.ts +97 -0
  28. package/dist/checkbox/index.d.ts.map +1 -0
  29. package/dist/checkbox/index.js +110 -0
  30. package/dist/checkbox/index.js.map +1 -0
  31. package/dist/combobox/index.d.ts +135 -0
  32. package/dist/combobox/index.d.ts.map +1 -0
  33. package/dist/combobox/index.js +416 -0
  34. package/dist/combobox/index.js.map +1 -0
  35. package/dist/context-menu/index.d.ts +94 -0
  36. package/dist/context-menu/index.d.ts.map +1 -0
  37. package/dist/context-menu/index.js +399 -0
  38. package/dist/context-menu/index.js.map +1 -0
  39. package/dist/critical-alert/index.d.ts +39 -0
  40. package/dist/critical-alert/index.d.ts.map +1 -0
  41. package/dist/critical-alert/index.js +50 -0
  42. package/dist/critical-alert/index.js.map +1 -0
  43. package/dist/data-table/index.d.ts +94 -0
  44. package/dist/data-table/index.d.ts.map +1 -0
  45. package/dist/data-table/index.js +128 -0
  46. package/dist/data-table/index.js.map +1 -0
  47. package/dist/disclosure/index.d.ts +35 -0
  48. package/dist/disclosure/index.d.ts.map +1 -0
  49. package/dist/disclosure/index.js +54 -0
  50. package/dist/disclosure/index.js.map +1 -0
  51. package/dist/drawer/index.d.ts +106 -0
  52. package/dist/drawer/index.d.ts.map +1 -0
  53. package/dist/drawer/index.js +352 -0
  54. package/dist/drawer/index.js.map +1 -0
  55. package/dist/empty-state/index.d.ts +86 -0
  56. package/dist/empty-state/index.d.ts.map +1 -0
  57. package/dist/empty-state/index.js +138 -0
  58. package/dist/empty-state/index.js.map +1 -0
  59. package/dist/env.d.js +1 -0
  60. package/dist/env.d.js.map +1 -0
  61. package/dist/field/index.d.ts +149 -0
  62. package/dist/field/index.d.ts.map +1 -0
  63. package/dist/field/index.js +121 -0
  64. package/dist/field/index.js.map +1 -0
  65. package/dist/file-upload/index.d.ts +140 -0
  66. package/dist/file-upload/index.d.ts.map +1 -0
  67. package/dist/file-upload/index.js +249 -0
  68. package/dist/file-upload/index.js.map +1 -0
  69. package/dist/footer/index.d.ts +155 -0
  70. package/dist/footer/index.d.ts.map +1 -0
  71. package/dist/footer/index.js +186 -0
  72. package/dist/footer/index.js.map +1 -0
  73. package/dist/header/index.d.ts +170 -0
  74. package/dist/header/index.d.ts.map +1 -0
  75. package/dist/header/index.js +258 -0
  76. package/dist/header/index.js.map +1 -0
  77. package/dist/identifier/index.d.ts +38 -0
  78. package/dist/identifier/index.d.ts.map +1 -0
  79. package/dist/identifier/index.js +55 -0
  80. package/dist/identifier/index.js.map +1 -0
  81. package/dist/in-page-navigation/index.d.ts +71 -0
  82. package/dist/in-page-navigation/index.d.ts.map +1 -0
  83. package/dist/in-page-navigation/index.js +137 -0
  84. package/dist/in-page-navigation/index.js.map +1 -0
  85. package/dist/index.d.ts +57 -0
  86. package/dist/index.d.ts.map +1 -0
  87. package/dist/index.js +47 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/input-group/index.d.ts +66 -0
  90. package/dist/input-group/index.d.ts.map +1 -0
  91. package/dist/input-group/index.js +85 -0
  92. package/dist/input-group/index.js.map +1 -0
  93. package/dist/lib/krds.d.ts +45 -0
  94. package/dist/lib/krds.d.ts.map +1 -0
  95. package/dist/lib/krds.js +11 -0
  96. package/dist/lib/krds.js.map +1 -0
  97. package/dist/lib/link.d.ts +34 -0
  98. package/dist/lib/link.d.ts.map +1 -0
  99. package/dist/lib/link.js +8 -0
  100. package/dist/lib/link.js.map +1 -0
  101. package/dist/main-menu/index.d.ts +123 -0
  102. package/dist/main-menu/index.d.ts.map +1 -0
  103. package/dist/main-menu/index.js +613 -0
  104. package/dist/main-menu/index.js.map +1 -0
  105. package/dist/masthead/index.d.ts +25 -0
  106. package/dist/masthead/index.d.ts.map +1 -0
  107. package/dist/masthead/index.js +21 -0
  108. package/dist/masthead/index.js.map +1 -0
  109. package/dist/modal/index.d.ts +105 -0
  110. package/dist/modal/index.d.ts.map +1 -0
  111. package/dist/modal/index.js +285 -0
  112. package/dist/modal/index.js.map +1 -0
  113. package/dist/pagination/index.d.ts +58 -0
  114. package/dist/pagination/index.d.ts.map +1 -0
  115. package/dist/pagination/index.js +82 -0
  116. package/dist/pagination/index.js.map +1 -0
  117. package/dist/progress/index.d.ts +58 -0
  118. package/dist/progress/index.d.ts.map +1 -0
  119. package/dist/progress/index.js +125 -0
  120. package/dist/progress/index.js.map +1 -0
  121. package/dist/radio/index.d.ts +111 -0
  122. package/dist/radio/index.d.ts.map +1 -0
  123. package/dist/radio/index.js +136 -0
  124. package/dist/radio/index.js.map +1 -0
  125. package/dist/resize/index.d.ts +118 -0
  126. package/dist/resize/index.d.ts.map +1 -0
  127. package/dist/resize/index.js +342 -0
  128. package/dist/resize/index.js.map +1 -0
  129. package/dist/scroll-area/index.d.ts +40 -0
  130. package/dist/scroll-area/index.d.ts.map +1 -0
  131. package/dist/scroll-area/index.js +52 -0
  132. package/dist/scroll-area/index.js.map +1 -0
  133. package/dist/select/index.d.ts +67 -0
  134. package/dist/select/index.d.ts.map +1 -0
  135. package/dist/select/index.js +61 -0
  136. package/dist/select/index.js.map +1 -0
  137. package/dist/separator/index.d.ts +47 -0
  138. package/dist/separator/index.d.ts.map +1 -0
  139. package/dist/separator/index.js +61 -0
  140. package/dist/separator/index.js.map +1 -0
  141. package/dist/side-navigation/index.d.ts +78 -0
  142. package/dist/side-navigation/index.d.ts.map +1 -0
  143. package/dist/side-navigation/index.js +188 -0
  144. package/dist/side-navigation/index.js.map +1 -0
  145. package/dist/skeleton/index.d.ts +76 -0
  146. package/dist/skeleton/index.d.ts.map +1 -0
  147. package/dist/skeleton/index.js +78 -0
  148. package/dist/skeleton/index.js.map +1 -0
  149. package/dist/skip-link/index.d.ts +46 -0
  150. package/dist/skip-link/index.d.ts.map +1 -0
  151. package/dist/skip-link/index.js +37 -0
  152. package/dist/skip-link/index.js.map +1 -0
  153. package/dist/spinner/index.d.ts +58 -0
  154. package/dist/spinner/index.d.ts.map +1 -0
  155. package/dist/spinner/index.js +36 -0
  156. package/dist/spinner/index.js.map +1 -0
  157. package/dist/step-indicator/index.d.ts +41 -0
  158. package/dist/step-indicator/index.d.ts.map +1 -0
  159. package/dist/step-indicator/index.js +63 -0
  160. package/dist/step-indicator/index.js.map +1 -0
  161. package/dist/structured-list/index.d.ts +150 -0
  162. package/dist/structured-list/index.d.ts.map +1 -0
  163. package/dist/structured-list/index.js +194 -0
  164. package/dist/structured-list/index.js.map +1 -0
  165. package/dist/tab/index.d.ts +108 -0
  166. package/dist/tab/index.d.ts.map +1 -0
  167. package/dist/tab/index.js +198 -0
  168. package/dist/tab/index.js.map +1 -0
  169. package/dist/table/index.d.ts +75 -0
  170. package/dist/table/index.d.ts.map +1 -0
  171. package/dist/table/index.js +91 -0
  172. package/dist/table/index.js.map +1 -0
  173. package/dist/tag/index.d.ts +71 -0
  174. package/dist/tag/index.d.ts.map +1 -0
  175. package/dist/tag/index.js +45 -0
  176. package/dist/tag/index.js.map +1 -0
  177. package/dist/text-input/index.d.ts +69 -0
  178. package/dist/text-input/index.d.ts.map +1 -0
  179. package/dist/text-input/index.js +70 -0
  180. package/dist/text-input/index.js.map +1 -0
  181. package/dist/text-list/index.d.ts +71 -0
  182. package/dist/text-list/index.d.ts.map +1 -0
  183. package/dist/text-list/index.js +79 -0
  184. package/dist/text-list/index.js.map +1 -0
  185. package/dist/textarea/index.d.ts +29 -0
  186. package/dist/textarea/index.d.ts.map +1 -0
  187. package/dist/textarea/index.js +69 -0
  188. package/dist/textarea/index.js.map +1 -0
  189. package/dist/toast/index.d.ts +76 -0
  190. package/dist/toast/index.d.ts.map +1 -0
  191. package/dist/toast/index.js +183 -0
  192. package/dist/toast/index.js.map +1 -0
  193. package/dist/tooltip/index.d.ts +84 -0
  194. package/dist/tooltip/index.d.ts.map +1 -0
  195. package/dist/tooltip/index.js +329 -0
  196. package/dist/tooltip/index.js.map +1 -0
  197. package/package.json +57 -0
  198. package/src/accordion/index.tsx +251 -0
  199. package/src/alert/index.tsx +111 -0
  200. package/src/avatar/index.tsx +272 -0
  201. package/src/badge/index.tsx +91 -0
  202. package/src/breadcrumb/index.tsx +125 -0
  203. package/src/button/index.tsx +190 -0
  204. package/src/checkbox/index.tsx +247 -0
  205. package/src/combobox/index.tsx +700 -0
  206. package/src/context-menu/index.tsx +623 -0
  207. package/src/critical-alert/index.tsx +102 -0
  208. package/src/data-table/index.tsx +289 -0
  209. package/src/disclosure/index.tsx +87 -0
  210. package/src/drawer/index.tsx +516 -0
  211. package/src/empty-state/index.tsx +256 -0
  212. package/src/env.d.ts +10 -0
  213. package/src/field/index.tsx +307 -0
  214. package/src/file-upload/index.tsx +492 -0
  215. package/src/footer/index.tsx +393 -0
  216. package/src/header/index.tsx +498 -0
  217. package/src/identifier/index.tsx +96 -0
  218. package/src/in-page-navigation/index.tsx +258 -0
  219. package/src/index.ts +72 -0
  220. package/src/input-group/index.tsx +197 -0
  221. package/src/lib/krds.ts +75 -0
  222. package/src/lib/link.tsx +35 -0
  223. package/src/main-menu/index.tsx +912 -0
  224. package/src/masthead/index.tsx +50 -0
  225. package/src/modal/index.tsx +457 -0
  226. package/src/pagination/index.tsx +198 -0
  227. package/src/progress/index.tsx +198 -0
  228. package/src/radio/index.tsx +283 -0
  229. package/src/resize/index.tsx +541 -0
  230. package/src/scroll-area/index.tsx +96 -0
  231. package/src/select/index.tsx +136 -0
  232. package/src/separator/index.tsx +107 -0
  233. package/src/side-navigation/index.tsx +336 -0
  234. package/src/skeleton/index.tsx +172 -0
  235. package/src/skip-link/index.tsx +89 -0
  236. package/src/spinner/index.tsx +98 -0
  237. package/src/step-indicator/index.tsx +122 -0
  238. package/src/structured-list/index.tsx +395 -0
  239. package/src/tab/index.tsx +348 -0
  240. package/src/table/index.tsx +186 -0
  241. package/src/tag/index.tsx +127 -0
  242. package/src/text-input/index.tsx +156 -0
  243. package/src/text-list/index.tsx +177 -0
  244. package/src/textarea/index.tsx +113 -0
  245. package/src/toast/index.tsx +311 -0
  246. package/src/tooltip/index.tsx +496 -0
  247. package/styles/krds-tailwind.css +335 -0
@@ -0,0 +1,102 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react';
2
+ import { cx } from '../lib/krds';
3
+
4
+ /**
5
+ * 긴급 공지 — KRDS `krds-critical-alerts`.
6
+ *
7
+ * **재난·재해, 예고 없는 장애, 점검 사전 예고에만 쓴다.** 규격이 피드백 메시지·일반 공지·홍보에
8
+ * 쓰는 것을 금지한다 — 본문 안내는 `Alert`, 폼 오류는 `Field` 의 `error` 다.
9
+ * 한 화면에 하나, 링크도 하나, 본문은 한 줄이다(킷이 두 줄에서 잘라낸다).
10
+ * 닫기 단추는 규격이 금지해 prop 자체가 없다. 자리는 주메뉴와 히어로 사이, 본문의 첫 요소다.
11
+ *
12
+ * @example
13
+ * <CriticalAlert level="danger" href="/notice/1024" linkTitle="집중호우 대응 안내">
14
+ * 집중호우로 일부 민원 처리가 지연되고 있습니다.
15
+ * </CriticalAlert>
16
+ *
17
+ * 자세히: docs/krds/09-부품-노트.md#긴급공지
18
+ */
19
+
20
+ /**
21
+ * 긴급도. `danger` 재난·급작스러운 장애(「긴급」) · `ok` 서비스 중단 사전 예고(「안전」) ·
22
+ * `info` 대부분의 사용자가 알아야 하는 상황(「안내」).
23
+ */
24
+ export type CriticalAlertLevel = 'danger' | 'ok' | 'info';
25
+
26
+ /** 긴급도별 기본 레이블. 킷 샘플에 적힌 글자 그대로다. */
27
+ const DEFAULT_LABEL: Record<CriticalAlertLevel, string> = {
28
+ danger: '긴급',
29
+ ok: '안전',
30
+ info: '안내',
31
+ };
32
+
33
+ export type CriticalAlertProps = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
34
+ /** 긴급도. 기본값 `danger` — 이 부품을 쓸 상황 자체가 긴급 상황이다. */
35
+ level?: CriticalAlertLevel;
36
+ /** 배지 글자. 생략하면 긴급도별 기본값(긴급/안전/안내). 감추지 마라 — 아이콘만으로는 안 읽힌다. */
37
+ label?: string;
38
+ /** 공지 내용. 한 줄로 쓴다 — 킷이 두 줄에서 잘라낸다. */
39
+ children: ReactNode;
40
+ /** 상세 화면 링크. 규격상 하나만 둘 수 있다. */
41
+ href?: string;
42
+ /** 링크 글자. 기본 「자세히 보기」. 작은 화면(<768px)에서는 감춰지고 화살표만 남는다. */
43
+ linkLabel?: ReactNode;
44
+ /** 링크의 목적지 설명(`title`). 「자세히 보기」만으로는 목적지를 알 수 없으니 문서 제목을 넣는다. */
45
+ linkTitle?: string;
46
+ /** 링크 target. `_blank` 면 `title` 에 「새 창 열림」을 덧붙인다. */
47
+ linkTarget?: ComponentPropsWithRef<'a'>['target'];
48
+ };
49
+
50
+ export function CriticalAlert({
51
+ level = 'danger',
52
+ label,
53
+ children,
54
+ href,
55
+ linkLabel = '자세히 보기',
56
+ linkTitle,
57
+ linkTarget,
58
+ className,
59
+ // 접근성 가이드: 「긴급 공지 섹션에 role="banner" 를 사용하여 배너 역할을 부여」.
60
+ // 킷 샘플에는 없고 가이드가 요구한 것이다. 페이지에 <header> 가 이미 있으면 banner
61
+ // 랜드마크가 둘이 되므로 이름을 붙여 구분한다(aria-label 은 우리가 덧댄 것).
62
+ role = 'banner',
63
+ 'aria-label': ariaLabel = '긴급 공지',
64
+ ...rest
65
+ }: CriticalAlertProps) {
66
+ const newWindow = linkTarget === '_blank';
67
+ const title = linkTitle
68
+ ? newWindow
69
+ ? `${linkTitle} (새 창 열림)`
70
+ : linkTitle
71
+ : newWindow
72
+ ? '새 창 열림'
73
+ : undefined;
74
+
75
+ return (
76
+ // `.main-urgent-wrap` 은 킷 CSS 에 없다(위 주석 참고) — 전폭만 Tailwind 로 준다.
77
+ <div className={cx('w-full', className)} role={role} aria-label={ariaLabel} {...rest}>
78
+ <ul className="krds-critical-alerts">
79
+ {/* 규격상 항목은 하나뿐이다. 늘리지 마라. */}
80
+ <li>
81
+ <div className="critical-ban">
82
+ {/* 아이콘은 이 배지의 ::before 다. 안에 <i> 나 sr-only 를 넣지 마라. */}
83
+ <span className={cx('critical-badge', level)}>{label ?? DEFAULT_LABEL[level]}</span>
84
+ <p className="critical-txt">{children}</p>
85
+ {href && (
86
+ <a
87
+ href={href}
88
+ className="krds-btn medium link basic"
89
+ target={linkTarget}
90
+ title={title}
91
+ >
92
+ {/* 킷의 `m-hide`(모바일 숨김)가 CSS 에 없어 Tailwind 로 대신한다. */}
93
+ <span className="hidden md:inline">{linkLabel}</span>
94
+ <i className="svg-icon ico-angle right" aria-hidden="true" />
95
+ </a>
96
+ )}
97
+ </div>
98
+ </li>
99
+ </ul>
100
+ </div>
101
+ );
102
+ }
@@ -0,0 +1,289 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react';
2
+ import { cx, type KrdsTone } from '../lib/krds';
3
+
4
+ /**
5
+ * 데이터 표 — 킷 `krds-table-wrap` + `tbl data` 위에 정렬·고정 헤더·빈 상태를 얹은 것.
6
+ *
7
+ * 열을 `columns` 로 넘겨 목록을 그린다. 표를 손으로 짤 때는 `ui/table` 의 조각을 쓴다.
8
+ * `caption` 은 **화면에 보이지 않는 보조기기 설명**이다 — 보이는 제목은 `heading` 이다.
9
+ * 정렬 버튼은 `onSortChange` 를 주었을 때만 그려지고, 행 강조는 `rowTone` 으로만 준다
10
+ * (`<tr>` 배경은 킷 셀 배경에 덮인다). 빈 셀에는 `-` 가 자동으로 들어간다.
11
+ *
12
+ * @example
13
+ * <DataTable
14
+ * caption="공지사항 목록 표로 번호, 제목, 작성자, 등록일 순으로 구성되어 있다."
15
+ * heading="공지사항"
16
+ * columns={[
17
+ * { key: 'title', header: '제목', cell: (r) => r.title, sortable: true },
18
+ * { key: 'hit', header: '조회', cell: (r) => r.hit, numeric: true },
19
+ * ]}
20
+ * rows={rows}
21
+ * rowKey={(r) => r.id}
22
+ * sort={sort}
23
+ * onSortChange={setSort}
24
+ * />
25
+ *
26
+ * 자세히: docs/krds/09-부품-노트.md#데이터표
27
+ */
28
+
29
+ /** 정렬 방향. */
30
+ export type SortDirection = 'asc' | 'desc';
31
+
32
+ /** 지금 어느 열을 어느 방향으로 정렬하고 있는가. */
33
+ export type SortState = {
34
+ /** 열의 `sortKey`(없으면 `key`). */
35
+ key: string;
36
+ direction: SortDirection;
37
+ };
38
+
39
+ export type DataColumn<T> = {
40
+ /** React key 이자 기본 정렬 키. */
41
+ key: string;
42
+ /** 열 제목. `<th scope="col">` 안에 들어간다. */
43
+ header: ReactNode;
44
+ /** 한 칸의 내용. */
45
+ cell: (row: T, index: number) => ReactNode;
46
+ /** `cell` 이 빈 값을 돌려줬을 때 넣을 것. 기본 `-`. 정말 비우려면 `null` 을 준다. */
47
+ empty?: ReactNode;
48
+ /** 양적 데이터(개수·퍼센트·용량). 오른쪽 정렬 + 자릿수 고정. 날짜·전화번호에는 주지 마라. */
49
+ numeric?: boolean;
50
+ /** `<colgroup><col style="width:…">` 로 나간다. */
51
+ width?: number | string;
52
+ /** 이 열로 정렬할 수 있는가. `onSortChange` 가 없으면 무시한다. */
53
+ sortable?: boolean;
54
+ /** 정렬 키를 열 `key` 와 다르게 둘 때(예: 화면은 `author`, 질의는 `author_name`). */
55
+ sortKey?: string;
56
+ /** 이 열을 행 머리글(`<th scope="row">`)로 낸다. 표 단위 `rowHeader` 보다 우선한다. */
57
+ rowHeader?: boolean;
58
+ /** 열 제목 칸에만 더할 클래스. */
59
+ headerClassName?: string;
60
+ /** 이 열의 데이터 칸에 더할 클래스. */
61
+ cellClassName?: string;
62
+ };
63
+
64
+ export type DataTableProps<T> = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
65
+ /** 보조기기가 읽을 표 설명. 화면에는 보이지 않는다 — 열 이름을 모두 담아 한 문장으로 적는다. */
66
+ caption: string;
67
+ /** 화면에 보이는 제목. 표 바깥에 그린다. */
68
+ heading?: ReactNode;
69
+ /** 제목을 어떤 태그로 낼지. 문서 목차에 넣으려면 `h2`~`h5` 를 준다. */
70
+ headingAs?: 'h2' | 'h3' | 'h4' | 'h5' | 'p' | 'div';
71
+ columns: DataColumn<T>[];
72
+ rows: T[];
73
+ rowKey: (row: T, index: number) => string | number;
74
+ /** 행이 하나도 없을 때 표 안에 그릴 것. 셀 하나가 빈 것은 `column.empty` 가 맡는다. */
75
+ empty?: ReactNode;
76
+ /** 첫 열을 행 머리글(`<th scope="row">`)로 낸다. 행마다 이름이 있는 표에 쓴다. */
77
+ rowHeader?: boolean;
78
+ /** 행 강조색. 「편집 중」·「실패」처럼 뜻이 있는 강조에만 쓴다. 셀마다 칠한다. */
79
+ rowTone?: (row: T, index: number) => KrdsTone | undefined;
80
+ /** `<tr>` 에 더할 클래스. 배경색은 여기 주지 마라 — 킷 셀 배경에 덮인다(`rowTone` 을 쓴다). */
81
+ rowClassName?: (row: T, index: number) => string | undefined;
82
+ /** 지금 정렬 상태. `null`/생략이면 정렬 안 함. */
83
+ sort?: SortState | null;
84
+ /**
85
+ * 정렬 헤더를 눌렀을 때. **이 콜백이 있어야 정렬 버튼이 그려진다.**
86
+ * 정렬 뒤에는 `rows` 만 갈아 끼운다 — 표를 통째로 다시 마운트하면 초점이 날아간다.
87
+ */
88
+ onSortChange?: (next: SortState) => void;
89
+ /** 열 제목 줄을 위에 붙박는다. `maxHeight` 와 같이 줘야 표 안에서 스크롤된다. */
90
+ stickyHeader?: boolean;
91
+ /** 표 영역 최대 높이. 주면 세로 스크롤이 생긴다. */
92
+ maxHeight?: number | string;
93
+ /** 좁은 화면에서 셀을 줄바꿈하지 않고 가로로 스크롤(킷 `mob-scroll`). 기본 참. */
94
+ mobileScroll?: boolean;
95
+ /** 넓은 화면에서도 가로 스크롤(킷 `scroll`). `maxHeight` 와 같이 쓰면 무시한다. */
96
+ horizontalScroll?: boolean;
97
+ };
98
+
99
+ /** 행 강조색 → 유틸리티. 넓은 면이라 `surface` 축이고, gray 만 짝이 없어 중립 면 토큰을 쓴다. */
100
+ const ROW_TONE: Record<KrdsTone, string> = {
101
+ primary: 'bg-primary-surface',
102
+ secondary: 'bg-secondary-surface',
103
+ gray: 'bg-surface-subtler',
104
+ point: 'bg-point-surface',
105
+ danger: 'bg-danger-surface',
106
+ warning: 'bg-warning-surface',
107
+ success: 'bg-success-surface',
108
+ information: 'bg-information-surface',
109
+ };
110
+
111
+ /** `-` 로 바꿔야 하는 빈 값인가. `0` 과 `false` 는 값이다. `ui/table` 의 `Td` 와 같은 판정이다. */
112
+ function isBlank(value: ReactNode): boolean {
113
+ if (value === null || value === undefined || value === '') return true;
114
+ return Array.isArray(value) && value.length === 0;
115
+ }
116
+
117
+ /** 빈 셀의 대시 플레이스홀더. `empty` 를 `null` 로 주면 정말 비운다. */
118
+ function withPlaceholder(value: ReactNode, empty: ReactNode): ReactNode {
119
+ if (!isBlank(value)) return value;
120
+ return empty === undefined ? '-' : empty;
121
+ }
122
+
123
+ /** 정렬 버튼이 스크린리더에 읽어 줄 현재 상태와 다음 동작. */
124
+ function sortHint(direction: SortDirection | undefined): string {
125
+ if (direction === 'asc') return '오름차순 정렬됨, 누르면 내림차순 정렬';
126
+ if (direction === 'desc') return '내림차순 정렬됨, 누르면 오름차순 정렬';
127
+ return '정렬 안 함, 누르면 오름차순 정렬';
128
+ }
129
+
130
+ export function DataTable<T>({
131
+ caption,
132
+ heading,
133
+ headingAs: Heading = 'div',
134
+ columns,
135
+ rows,
136
+ rowKey,
137
+ empty = '자료가 없습니다.',
138
+ rowHeader = false,
139
+ rowTone,
140
+ rowClassName,
141
+ sort,
142
+ onSortChange,
143
+ stickyHeader = false,
144
+ maxHeight,
145
+ mobileScroll = true,
146
+ horizontalScroll = false,
147
+ className,
148
+ ...rest
149
+ }: DataTableProps<T>) {
150
+ const hasWidth = columns.some((c) => c.width !== undefined);
151
+ const scrolls = maxHeight !== undefined;
152
+
153
+ return (
154
+ <div className={cx('flex flex-col gap-2', className)} {...rest}>
155
+ {heading != null && (
156
+ <Heading className="text-heading-xxs font-bold text-fg-bolder">{heading}</Heading>
157
+ )}
158
+
159
+ <div
160
+ className={cx(
161
+ 'krds-table-wrap',
162
+ mobileScroll && 'mob-scroll',
163
+ // 킷 `.scroll` 은 overflow-y 를 hidden 으로 잠근다 — 세로 스크롤과 같이 쓸 수 없다.
164
+ horizontalScroll && !scrolls && 'scroll',
165
+ scrolls && 'overflow-auto',
166
+ )}
167
+ style={scrolls ? { maxHeight } : undefined}
168
+ >
169
+ {/* 가로로 스크롤되는 표는 열 폭을 내용에 맞춘다.
170
+ 킷이 `table{table-layout:fixed}` 를 전역으로 못 박아 두어, 폭을 안 주면 열이 전부 같은 너비로 쪼개진다.
171
+ 그 상태에서 `whitespace-nowrap` 을 준 칸(날짜·조작 단추)은 잘리지 않고 **옆 칸 위로 흘러넘쳐** 글자가 겹쳤다
172
+ (관리자 콘텐츠 목록에서 게시일 141px 이 108px 칸을 넘어 출력기간과 포개졌다).
173
+ 스크롤 상자 안에서는 표가 넓어져도 되므로 `auto` 가 맞다(`mob-scroll` 도 데스크톱에서 overflow-x:auto 다 — 실측).
174
+ 스크롤이 아예 없는 표만 `fixed` 로 둔다 — 거기서는 한 열이 길어지면 나머지를 밀어내므로 균등 분배가 낫다.
175
+ 폭을 손으로 준 표(`hasWidth`)도 건드리지 않는다. */}
176
+ <table className="tbl col data" style={(horizontalScroll || mobileScroll) && !hasWidth ? { tableLayout: 'auto' } : undefined}>
177
+ {/* 킷이 !important 로 숨긴다 — 보조기기 전용이다. 보이는 제목은 위 `heading`. */}
178
+ <caption>{caption}</caption>
179
+
180
+ {hasWidth && (
181
+ <colgroup>
182
+ {columns.map((col) => (
183
+ <col key={col.key} style={col.width !== undefined ? { width: col.width } : undefined} />
184
+ ))}
185
+ </colgroup>
186
+ )}
187
+
188
+ <thead>
189
+ <tr>
190
+ {columns.map((col) => {
191
+ const key = col.sortKey ?? col.key;
192
+ const direction = sort && sort.key === key ? sort.direction : undefined;
193
+ const active = direction !== undefined;
194
+ const canSort = col.sortable === true && onSortChange !== undefined;
195
+
196
+ return (
197
+ <th
198
+ key={col.key}
199
+ scope="col"
200
+ // 정렬 가능한 열은 정렬 중이 아니어도 `none` 을 달아야 「정렬할 수 있는 열」임이 전달된다.
201
+ aria-sort={
202
+ canSort ? (active ? (direction === 'asc' ? 'ascending' : 'descending') : 'none') : undefined
203
+ }
204
+ className={
205
+ cx(
206
+ col.numeric && 'text-right',
207
+ // 킷 thead 배경(`--krds-table--data-thead-surface`)이 불투명이라 그대로 붙박아도 비치지 않는다.
208
+ stickyHeader && 'sticky top-0 z-10',
209
+ col.headerClassName,
210
+ ) || undefined
211
+ }
212
+ >
213
+ {canSort ? (
214
+ <button
215
+ type="button"
216
+ // 킷이 button 을 `font:inherit;color:inherit;background:transparent` 로 되돌려 주므로
217
+ // 열 제목의 굵기·크기·색을 그대로 물려받는다. 배치만 잡으면 된다.
218
+ className={cx(
219
+ 'inline-flex items-center gap-1 text-left',
220
+ col.numeric && 'w-full justify-end',
221
+ )}
222
+ onClick={() =>
223
+ onSortChange?.({ key, direction: direction === 'asc' ? 'desc' : 'asc' })
224
+ }
225
+ >
226
+ {col.header}
227
+ {/* size-4(16px) = KRDS `--krds-icon--size-small`. 킷 기본값 large(24px)는 15px 제목에 너무 크다. */}
228
+ <i
229
+ className={cx(
230
+ 'svg-icon ico-angle size-4 shrink-0 bg-fg-bolder',
231
+ direction === 'asc' && 'up',
232
+ !active && 'opacity-40',
233
+ )}
234
+ aria-hidden="true"
235
+ />
236
+ <span className="sr-only">{sortHint(direction)}</span>
237
+ </button>
238
+ ) : (
239
+ col.header
240
+ )}
241
+ </th>
242
+ );
243
+ })}
244
+ </tr>
245
+ </thead>
246
+
247
+ <tbody>
248
+ {rows.length === 0 ? (
249
+ <tr>
250
+ {/* KRDS 에 빈 상태 부품이 없다 — 유틸리티로 그린다. py-10 = KRDS padding 40. */}
251
+ <td colSpan={columns.length} className="py-10 text-center text-fg-subtle">
252
+ {empty}
253
+ </td>
254
+ </tr>
255
+ ) : (
256
+ rows.map((row, i) => {
257
+ const tone = rowTone?.(row, i);
258
+ const toneClass = tone && ROW_TONE[tone];
259
+
260
+ return (
261
+ <tr key={rowKey(row, i)} className={rowClassName?.(row, i)}>
262
+ {columns.map((col, j) => {
263
+ const asRowHeader = col.rowHeader ?? (rowHeader && j === 0);
264
+ const cellClass =
265
+ cx(col.numeric && 'text-right tabular-nums', toneClass, col.cellClassName) || undefined;
266
+ // 빈 셀에는 대시(-) — `Td` 와 같은 규칙이다(함정 ⑤).
267
+ const content = withPlaceholder(col.cell(row, i), col.empty);
268
+
269
+ // 킷은 tbody 의 th 도 regular 로 그린다 — 행 머리글은 굵게 되돌린다.
270
+ return asRowHeader ? (
271
+ <th key={col.key} scope="row" className={cx('font-bold', cellClass)}>
272
+ {content}
273
+ </th>
274
+ ) : (
275
+ <td key={col.key} className={cellClass}>
276
+ {content}
277
+ </td>
278
+ );
279
+ })}
280
+ </tr>
281
+ );
282
+ })
283
+ )}
284
+ </tbody>
285
+ </table>
286
+ </div>
287
+ </div>
288
+ );
289
+ }
@@ -0,0 +1,87 @@
1
+ 'use client';
2
+
3
+ import { useId, useState, type ComponentPropsWithRef, type ReactNode } from 'react';
4
+ import { cx } from '../lib/krds';
5
+
6
+ /**
7
+ * 디스클로저(더 보기/접기) — KRDS `krds-disclosure`.
8
+ *
9
+ * 한 덩이의 **부가** 안내를 접었다 편다. 기본은 접힌 상태이고 한 섹션에 하나만 둔다.
10
+ * 여러 덩이를 목록으로 접었다 펴려면 `Accordion`, 내용을 갈아 끼우려면 `Tab` 이다.
11
+ * 오류 문구·중요한 정보·입력칸은 넣지 마라 — 접힌 안쪽은 보이지 않은 채 제출된다.
12
+ * 꺾쇠 아이콘은 킷이 그리므로 `<i class="svg-icon">` 를 따로 넣지 않는다.
13
+ *
14
+ * @example
15
+ * <Disclosure label="신청 서비스안내">
16
+ * <TextList marker="dash">
17
+ * <TextListItem>하나의 아이디로 여러 전자정부 서비스를 이용할 수 있습니다.</TextListItem>
18
+ * </TextList>
19
+ * </Disclosure>
20
+ *
21
+ * 자세히: docs/krds/09-부품-노트.md#상세보기
22
+ */
23
+
24
+ export type DisclosureProps = ComponentPropsWithRef<'div'> & {
25
+ /** 버튼에 보이는 글. 펼칠 내용을 유추할 수 있는 제목이어야 한다. */
26
+ label: ReactNode;
27
+ /** 처음부터 펼쳐 둘까. 기본 false 가 규격이다 — 되도록 바꾸지 마라. */
28
+ defaultOpen?: boolean;
29
+ /** 바깥에서 여닫기를 쥘 때. 주면 통제 모드가 되고 내부 상태는 쓰지 않는다. */
30
+ open?: boolean;
31
+ /** 사용자가 버튼을 눌러 상태가 바뀔 때. 통제 모드에서는 이걸 받아 직접 `open` 을 옮긴다. */
32
+ onOpenChange?: (open: boolean) => void;
33
+ /** 버튼(`btn-conts-expand`)에 덧붙일 클래스. */
34
+ buttonClassName?: string;
35
+ /** 안쪽 내용 상자(`expand-in`)에 덧붙일 클래스. */
36
+ contentClassName?: string;
37
+ children?: ReactNode;
38
+ };
39
+
40
+ export function Disclosure({
41
+ label,
42
+ defaultOpen = false,
43
+ open: openProp,
44
+ onOpenChange,
45
+ buttonClassName,
46
+ contentClassName,
47
+ className,
48
+ children,
49
+ ...rest
50
+ }: DisclosureProps) {
51
+ const [selfOpen, setSelfOpen] = useState(defaultOpen);
52
+ // 통제/비통제 겸용. `open` 을 준 쪽이 이긴다.
53
+ const controlled = openProp !== undefined;
54
+ const open = controlled ? openProp : selfOpen;
55
+
56
+ // 킷 JS 는 `disclosure-<난수>` 를 만들어 붙인다. React 는 useId 로 서버·클라이언트가 같은 값을 낸다.
57
+ const reactId = useId();
58
+ const contentId = `disclosure-${reactId}`;
59
+
60
+ function toggle() {
61
+ const next = !open;
62
+ if (!controlled) setSelfOpen(next);
63
+ onOpenChange?.(next);
64
+ }
65
+
66
+ return (
67
+ // `conts-expand-area` 는 킷 CSS 에 규칙이 없지만 마크업 샘플과 맞추려고 남긴다.
68
+ <div
69
+ className={cx('krds-disclosure', 'conts-expand-area', open && 'active', className)}
70
+ {...rest}
71
+ >
72
+ <button
73
+ type="button"
74
+ className={cx('btn-conts-expand', buttonClassName)}
75
+ aria-expanded={open}
76
+ aria-controls={contentId}
77
+ onClick={toggle}
78
+ >
79
+ {label}
80
+ </button>
81
+ {/* 접힌 동안 `inert` — 킷 JS 와 같다. 초점·클릭·보조기술을 한 번에 막는다. */}
82
+ <div className="expand-wrap" id={contentId} inert={!open}>
83
+ <div className={cx('expand-in', contentClassName)}>{children}</div>
84
+ </div>
85
+ </div>
86
+ );
87
+ }