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,256 @@
1
+ import { UiLink } from '../lib/link';
2
+ import type { ComponentPropsWithRef, ReactNode } from 'react';
3
+ import { cx, type KrdsIcon, type KrdsTone } from '../lib/krds';
4
+
5
+ /**
6
+ * 빈 상태 상자 — KRDS 에 없다. shadcn `Empty` 형태에 KRDS 토큰을 입힌 보충 부품이다.
7
+ *
8
+ * 「보여 줄 것이 없다」를 알린다 — 등록된 것이 없음 · 조건에 맞는 것이 없음 · 권한 없음 · 불러오기 실패.
9
+ * 「알아 둘 것이 있다」는 `Alert`, 표 안 한 행짜리 빈 칸은 `ui/table` 의 `TableEmpty` 다.
10
+ * **막다른 골목을 만들지 마라** — `hints` 나 `actions` 중 하나는 준다.
11
+ * 검색 결과 없음은 규격이 문구와 대안 4개까지 못 박았으므로 {@link SearchEmptyState} 를 쓴다.
12
+ *
13
+ * @example
14
+ * <EmptyState reason="no-results" actions={<Button onClick={reset}>조건 초기화</Button>} />
15
+ * <EmptyState reason="forbidden" layout="inline" />
16
+ * <SearchEmptyState query={q} count={0} helpHref="/help/search" />
17
+ *
18
+ * 자세히: docs/krds/09-부품-노트.md#빈상태
19
+ */
20
+
21
+ /** 왜 비었는가. 색·아이콘·기본 문구가 여기서 갈린다. */
22
+ export type EmptyStateReason =
23
+ /** 아직 등록된 것이 없다(조건 문제가 아니다) */
24
+ | 'empty'
25
+ /** 검색어·필터에 맞는 것이 없다 */
26
+ | 'no-results'
27
+ /** 볼 권한이 없다 */
28
+ | 'forbidden'
29
+ /** 불러오지 못했다 */
30
+ | 'error';
31
+
32
+ /** 색. `KrdsTone` 에서 이 상자가 쓰는 넷만 좁힌 것이다. */
33
+ export type EmptyStateTone = Extract<KrdsTone, 'gray' | 'information' | 'warning' | 'danger'>;
34
+
35
+ /**
36
+ * 배치. `panel` 은 목록이 있던 넓은 자리(가운데 정렬), `inline` 은 폼 옆·카드 안처럼 좁은 자리다.
37
+ * 테두리는 둘 다 같은 사방 점선이고 정렬과 여백만 다르다.
38
+ */
39
+ export type EmptyStateLayout = 'panel' | 'inline';
40
+
41
+ /** 사유별 기본값. `forbidden` 에 아이콘이 없는 것은 일부러다 — 킷에 자물쇠·경고 아이콘이 없다. */
42
+ const REASON: Record<EmptyStateReason, { tone: EmptyStateTone; icon: KrdsIcon | null; title: string }> = {
43
+ empty: { tone: 'gray', icon: 'file', title: '등록된 자료가 없습니다.' },
44
+ 'no-results': { tone: 'gray', icon: 'sch', title: '조건에 맞는 결과가 없습니다.' },
45
+ forbidden: { tone: 'warning', icon: null, title: '이 내용을 볼 권한이 없습니다.' },
46
+ error: { tone: 'danger', icon: 'error-fill', title: '불러오지 못했습니다.' },
47
+ };
48
+
49
+ /** 색조별 유틸리티. 선 `border-*-line` · 면 `bg-*-surface` · mask 아이콘 색 `tint`. */
50
+ const TONE: Record<EmptyStateTone, { line: string; surface: string; tint: string }> = {
51
+ gray: { line: 'border-line', surface: 'bg-surface-subtler', tint: 'bg-fg-disabled' },
52
+ information: { line: 'border-information-line', surface: 'bg-information-surface', tint: 'bg-information' },
53
+ warning: { line: 'border-warning-line', surface: 'bg-warning-surface', tint: 'bg-warning' },
54
+ danger: { line: 'border-danger-line', surface: 'bg-danger-surface', tint: 'bg-danger' },
55
+ };
56
+
57
+ export type EmptyStateProps = Omit<ComponentPropsWithRef<'div'>, 'title'> & {
58
+ /** 기본 `empty`. 색·아이콘·기본 문구가 여기서 정해진다. */
59
+ reason?: EmptyStateReason;
60
+ /** 사유의 기본 색을 덮는다. 굳이 필요할 때만. */
61
+ tone?: EmptyStateTone;
62
+ /** 기본 `panel`. 좁은 자리에는 `inline`. */
63
+ layout?: EmptyStateLayout;
64
+ /** 킷 아이콘 이름(`ico-` 뺀 것). 사유 기본값을 덮는다. `null` 이면 그리지 않는다. */
65
+ icon?: KrdsIcon | null;
66
+ /** 제목 한 줄. 생략하면 사유의 기본 문구. */
67
+ title?: ReactNode;
68
+ /** 제목 위에 오는 줄. 검색 결과 수처럼 제목보다 먼저 와야 하는 것이 여기 온다. */
69
+ lead?: ReactNode;
70
+ /** 제목 아래 보충 설명. */
71
+ description?: ReactNode;
72
+ /** 대안 행동 목록. 결과 없음에는 반드시 채운다 — 막다른 골목을 만들지 않는다. */
73
+ hints?: ReactNode[];
74
+ /** 아래쪽 단추 자리(`ui/button` 의 Button·LinkButton 을 넣는다). */
75
+ actions?: ReactNode;
76
+ /**
77
+ * `role="status"` 를 붙인다. 클라이언트에서 목록이 바뀌는 화면에서만 켠다 —
78
+ * 이때 상자는 내용이 바뀌기 전부터 DOM 에 있어야 낭독된다.
79
+ */
80
+ live?: boolean;
81
+ children?: ReactNode;
82
+ };
83
+
84
+ export function EmptyState({
85
+ reason = 'empty',
86
+ tone,
87
+ layout = 'panel',
88
+ icon,
89
+ title,
90
+ lead,
91
+ description,
92
+ hints,
93
+ actions,
94
+ live,
95
+ className,
96
+ children,
97
+ ...rest
98
+ }: EmptyStateProps) {
99
+ const preset = REASON[reason];
100
+ const t = TONE[tone ?? preset.tone];
101
+ const panel = layout === 'panel';
102
+
103
+ // `undefined` 는 「기본값을 쓴다」, `null` 은 「아이콘을 그리지 않는다」. 둘을 구분해야 한다.
104
+ const iconName = icon === undefined ? preset.icon : icon;
105
+ // `-fill` 아이콘은 background-image 라 색을 못 바꾼다 — 색 유틸리티를 붙이지 않는다(함정 1).
106
+ const iconNode = iconName ? (
107
+ <i
108
+ className={cx(
109
+ 'svg-icon',
110
+ `ico-${iconName}`,
111
+ panel ? 'size-12' : 'size-6 shrink-0',
112
+ !iconName.endsWith('-fill') && t.tint,
113
+ )}
114
+ aria-hidden="true"
115
+ />
116
+ ) : null;
117
+
118
+ const hintList =
119
+ hints && hints.length > 0 ? (
120
+ // 킷 전역 `ol,ul{list-style:none}` 때문에 목록 의미가 떨어진다 — `ui/text-list` 와 같이 role 을 명시한다.
121
+ <ul role="list" className={cx('krds-info-list dash', panel && 'w-fit text-left')}>
122
+ {hints.map((hint, i) => (
123
+ <li key={i} role="listitem">
124
+ {hint}
125
+ </li>
126
+ ))}
127
+ </ul>
128
+ ) : null;
129
+
130
+ // shadcn `EmptyHeader` 자리 — 아이콘·제목·설명이 한 덩이로 `gap-2`(8px) 씩 벌어진다.
131
+ // panel 은 아이콘이 글 **위**에 서고(shadcn `EmptyMedia`), inline 은 좁은 자리라 글 **왼쪽**에 선다.
132
+ const header = (
133
+ <div className={cx('flex flex-col gap-2', panel && 'items-center text-center')}>
134
+ {panel && iconNode}
135
+ {lead && <p className="m-0 text-sm text-fg-subtle">{lead}</p>}
136
+ <p className={cx('m-0 font-bold text-fg', panel ? 'text-heading-xs' : 'text-sm')}>{title ?? preset.title}</p>
137
+ {description && <p className="m-0 text-sm text-fg-subtle">{description}</p>}
138
+ </div>
139
+ );
140
+
141
+ const body = (
142
+ <>
143
+ {header}
144
+ {hintList}
145
+ {children}
146
+ {/* shadcn `EmptyContent` 자리 — 단추 줄. */}
147
+ {actions && <div className={cx('flex flex-wrap items-center gap-2', panel && 'justify-center')}>{actions}</div>}
148
+ </>
149
+ );
150
+
151
+ return (
152
+ <div
153
+ role={live ? 'status' : undefined}
154
+ className={cx(
155
+ // shadcn `Empty` — 점선 사방 테두리 + 둥글기. `border-solid` 가 아니라 `border-dashed` 라야
156
+ // 선이 그려진다(preflight 가 없어 border-style 기본값이 none 이다. `ui/separator` 함정 2).
157
+ 'rounded-lg border border-dashed',
158
+ panel
159
+ ? // 가운데 정렬 + 세로 흐름 `gap-6`(24px). 위아래 40px 은 킷 `.no-results` 실측(`--krds-padding-10`).
160
+ 'flex flex-col items-center gap-6 px-6 py-10 text-center'
161
+ : // 좁은 자리 — 아이콘을 왼쪽에 세우고 글은 왼쪽 정렬. 여백 24/16px.
162
+ 'flex items-start gap-2 px-6 py-4 text-left',
163
+ t.line,
164
+ t.surface,
165
+ className,
166
+ )}
167
+ {...rest}
168
+ >
169
+ {panel ? (
170
+ body
171
+ ) : (
172
+ <>
173
+ {iconNode}
174
+ <div className="flex min-w-0 flex-1 flex-col gap-4">{body}</div>
175
+ </>
176
+ )}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ /**
182
+ * 검색 결과 없음 — KRDS 가 문구와 대안까지 못 박은 화면이다.
183
+ *
184
+ * 결과 수 0 · 규격 문구 · 대안 4개(철자 확인 · 다른 검색어 · 더 일반적인 검색어 · 검색 도움말)를
185
+ * 기본값으로 담는다. `<SearchEmptyState query={q} />` 만으로 규격을 채운다.
186
+ *
187
+ * **`helpHref` 를 주지 않으면 대안 ④가 갈 곳 없는 문장으로 남는다** —
188
+ * 도움말 문서가 아직 없다면 `hints` 를 직접 넘겨 그 화면에서 할 수 있는 행동으로 바꿔라.
189
+ */
190
+
191
+ /** 규격이 든 안내 문구 그대로. 바꾸지 마라. */
192
+ export const SEARCH_NO_RESULT_MESSAGE = '검색어와 일치하는 결과를 찾을 수 없습니다.';
193
+
194
+ /** 규격 대안 4개 중 링크가 필요 없는 셋. ④는 `helpHref` 에 따라 아래에서 만든다. */
195
+ export const SEARCH_NO_RESULT_HINTS: readonly string[] = [
196
+ '검색어의 철자가 정확한지 확인해 보세요.',
197
+ '다른 검색어로 다시 검색해 보세요.',
198
+ '더 일반적인 검색어를 사용해 보세요.',
199
+ ];
200
+
201
+ export type SearchEmptyStateProps = Omit<EmptyStateProps, 'reason' | 'lead'> & {
202
+ /** 사용자가 넣은 검색어. 결과 수 줄에 굵게 되짚어 준다. */
203
+ query?: string;
204
+ /** 결과 수. 규격상 0을 반드시 표시한다. 화면 위쪽에서 이미 세고 있으면 `null` 로 숨긴다. */
205
+ count?: number | null;
206
+ /** 검색 도움말 문서 주소. 규격 대안 ④가 링크가 되려면 있어야 한다. */
207
+ helpHref?: string;
208
+ /** 도움말 링크 글자. 기본 「검색 도움말」 — 바꾸면 뒤따르는 조사가 어긋날 수 있다. */
209
+ helpLabel?: string;
210
+ };
211
+
212
+ export function SearchEmptyState({
213
+ query,
214
+ count = 0,
215
+ helpHref,
216
+ helpLabel = '검색 도움말',
217
+ hints,
218
+ title = SEARCH_NO_RESULT_MESSAGE,
219
+ ...rest
220
+ }: SearchEmptyStateProps) {
221
+ // 킷에 문장 속 텍스트 링크 클래스가 없어(그쪽은 높이가 박힌 버튼이다) 유틸리티로 보충한다.
222
+ const helpLink = helpHref ? (
223
+ helpHref.startsWith('/') ? (
224
+ <UiLink href={helpHref} className="text-link underline hover:text-link-hover">
225
+ {helpLabel}
226
+ </UiLink>
227
+ ) : (
228
+ <a href={helpHref} className="text-link underline hover:text-link-hover">
229
+ {helpLabel}
230
+ </a>
231
+ )
232
+ ) : null;
233
+
234
+ const specHints: ReactNode[] = [
235
+ ...SEARCH_NO_RESULT_HINTS,
236
+ helpLink ? <>{helpLink}을 참고하세요.</> : `${helpLabel}을 참고하세요.`,
237
+ ];
238
+
239
+ return (
240
+ <EmptyState
241
+ reason="no-results"
242
+ title={title}
243
+ // 규격 순서: 결과 수 → 안내 메시지 → 대안. 그래서 결과 수는 제목 위(`lead`)에 온다.
244
+ lead={
245
+ count === null ? undefined : (
246
+ <>
247
+ {query ? <>「<b className="text-fg">{query}</b>」에 대한 검색 결과 </> : '검색 결과 '}
248
+ <b className="text-fg">{count.toLocaleString('ko-KR')}</b>건
249
+ </>
250
+ )
251
+ }
252
+ hints={hints ?? specHints}
253
+ {...rest}
254
+ />
255
+ );
256
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `process.env.NODE_ENV` 만을 위한 최소 선언.
3
+ *
4
+ * 부품 몇 개가 개발 중에만 콘솔 경고를 낸다(예: `<table>` 에 display 를 바꾸는 클래스가 붙었을 때).
5
+ * 번들러(Next·Vite·webpack)가 이 값을 빌드 때 문자열로 갈아 끼우므로 운영 번들에는 남지 않는다.
6
+ *
7
+ * `@types/node` 를 통째로 끌어오지 않는 이유 — 이것은 브라우저에서 도는 UI 부품 묶음이라
8
+ * Node 전역(`Buffer`·`__dirname` 등)이 타입에 보이면 잘못 쓰기 쉽다.
9
+ */
10
+ declare const process: { env: { NODE_ENV?: string } };
@@ -0,0 +1,307 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from 'react';
2
+ import { cx, stateClass, type KrdsFieldState } from '../lib/krds';
3
+
4
+ /**
5
+ * 폼 필드 골격 — KRDS `fieldset` · `form-group` · `form-tit` · `form-conts` · `form-hint`.
6
+ *
7
+ * 레이블 · 컨트롤 · 도움말 · 상태 메시지를 한 골격으로 묶고 접근성 속성을 이어 준다.
8
+ * 컨트롤은 `TextInput` · `Select` · `Textarea` 가 스스로 그린다 — 여기서 겹쳐 그리지 않는다.
9
+ * **`hint` 는 상태와 무관한 입력 규칙 안내**(회색)이고, 파란 「정보」 메시지는
10
+ * `state="information"` + `message` 다. 둘을 바꿔 쓰면 평범한 안내가 상태 메시지로 보인다.
11
+ * 레이블 달린 텍스트 한 줄이면 `ui/text-input` 의 `TextField` 가 이 조립을 대신한다.
12
+ * 서버 컴포넌트라 `id` 가 필수다.
13
+ *
14
+ * @example
15
+ * <Fieldset>
16
+ * <Field id="user_id" label="아이디" hint="4~30자" required>
17
+ * {(a) => <TextInput {...a} placeholder="아이디" />}
18
+ * </Field>
19
+ * <Field id="user_pw" label="비밀번호" state="error" message="8자 이상이어야 합니다."
20
+ * action={<Button iconOnly label="비밀번호 보기" icon="pw-visible" />}>
21
+ * {(a) => <TextInput {...a} type="password" />}
22
+ * </Field>
23
+ * </Fieldset>
24
+ *
25
+ * 자세히: docs/krds/09-부품-노트.md#폼필드
26
+ */
27
+
28
+ /* ────────────────────────────── 골격 조각 ────────────────────────────── */
29
+
30
+ /**
31
+ * `<div class="fieldset">` — 폼그룹 여럿을 세로로 묶는다.
32
+ * 필드 사이 세로 간격이 여기서 나온다 — `mt-*` 를 손으로 붙이지 마라.
33
+ */
34
+ export function Fieldset({ className, children, ...rest }: ComponentPropsWithRef<'div'>) {
35
+ return (
36
+ <div className={cx('fieldset', className)} {...rest}>
37
+ {children}
38
+ </div>
39
+ );
40
+ }
41
+
42
+ /**
43
+ * `<div class="form-col-group">` — 폼그룹 여럿을 가로로 늘어놓는다(성/이름, 시작일/종료일).
44
+ * 좁은 화면(767px 이하)에서는 킷이 알아서 세로로 접는다.
45
+ *
46
+ * 컨트롤 하나에 딸린 버튼·기호를 묶는 것은 `ui/input-group` 의 `InputGroup` 이다.
47
+ */
48
+ export function FieldRow({ className, children, ...rest }: ComponentPropsWithRef<'div'>) {
49
+ return (
50
+ <div className={cx('form-col-group', className)} {...rest}>
51
+ {children}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ /**
57
+ * `<div class="form-group">` — 필드 하나.
58
+ * 도움말 선택자와 상태 토큰이 전부 여기 매여 있다 — 이 감싸개 없이 조각만 쓰면 모양이 나오지 않는다.
59
+ */
60
+ export function FormGroup({ className, children, ...rest }: ComponentPropsWithRef<'div'>) {
61
+ return (
62
+ <div className={cx('form-group', className)} {...rest}>
63
+ {children}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ export type FieldLabelProps = Omit<ComponentPropsWithRef<'label'>, 'htmlFor'> & {
69
+ /** 이을 컨트롤의 id. `<label for>` 라 필수다. */
70
+ htmlFor: string;
71
+ /** 필수 입력 표시(`*`). 킷에 클래스가 없어 보충한 부분이다. */
72
+ required?: boolean;
73
+ /** 눈에서만 감춘다(`sr-only`). 이름 자체는 남는다 — 검색창처럼 레이블을 못 두는 자리에. */
74
+ srOnly?: boolean;
75
+ /** `.form-tit` 감싸개에 붙는다. `className` 은 `<label>` 로 간다. */
76
+ boxClassName?: string;
77
+ };
78
+
79
+ /**
80
+ * 레이블 — `<div class="form-tit"><label for>…</label></div>`.
81
+ * 라디오·체크박스 묶음의 **소제목**은 이것이 아니라 `.form-conts` 안의 `.form-label` 자리다.
82
+ */
83
+ export function FieldLabel({
84
+ htmlFor,
85
+ required,
86
+ srOnly,
87
+ boxClassName,
88
+ className,
89
+ children,
90
+ ...rest
91
+ }: FieldLabelProps) {
92
+ const label = (
93
+ <label htmlFor={htmlFor} className={cx(srOnly && 'sr-only', className)} {...rest}>
94
+ {children}
95
+ {required && (
96
+ // 킷에 필수 표시 클래스가 없어 보충한 부분. 소리로는 컨트롤의 required/aria-required 가 알린다.
97
+ <em className="not-italic text-danger ml-1" aria-hidden="true">
98
+ *
99
+ </em>
100
+ )}
101
+ </label>
102
+ );
103
+
104
+ // 감춘 레이블은 `.form-tit` 로 감싸지 않는다 — 감싸개만 남아 gap 이 한 칸 더 벌어진다.
105
+ return srOnly ? label : <div className={cx('form-tit', boxClassName)}>{label}</div>;
106
+ }
107
+
108
+ /** `data-*` 속성. React 타입에 자리가 없어 객체 리터럴로 넘기면 `TS2353` 이 나므로 열어 둔다. */
109
+ export type KrdsDataAttrs = {
110
+ [key: `data-${string}`]: string | number | boolean | undefined;
111
+ };
112
+
113
+ export type FieldControlProps = ComponentPropsWithRef<'div'> &
114
+ KrdsDataAttrs & {
115
+ /** 필드 상태. 킷의 `is-*` 클래스가 이 감싸개에 붙는다. */
116
+ state?: KrdsFieldState;
117
+ /**
118
+ * 컨트롤 오른쪽에 겹쳐 놓을 아이콘 버튼(비밀번호 보기, 내용 지우기 등).
119
+ * 킷 클래스 `btn-ico-wrap` 은 부품이 붙인다. 둘 이상이면 `<div class="btn-group">` 으로 묶어 넘긴다.
120
+ */
121
+ action?: ReactNode;
122
+ /**
123
+ * 「내용 지우기」 버튼을 킷 CSS 로 여닫는다. **`placeholder` 가 없으면 영영 보이지 않는다.**
124
+ * 버튼 자체는 `action` 으로 넘겨 입력칸의 형제가 되게 한다.
125
+ */
126
+ deletable?: boolean;
127
+ };
128
+
129
+ /**
130
+ * `<div class="form-conts">` — 컨트롤 감싸개. **상태 클래스가 붙는 자리다.**
131
+ *
132
+ * 킷이 여기 얹어 쓰는 클래스가 더 있다 — 날짜 입력 `calendar-conts`,
133
+ * 검색 키워드 입력 `keyword-sch`. 필요하면 `className` 으로 더한다.
134
+ */
135
+ export function FieldControl({
136
+ state,
137
+ action,
138
+ deletable,
139
+ className,
140
+ children,
141
+ ...rest
142
+ }: FieldControlProps) {
143
+ return (
144
+ <div
145
+ className={cx('form-conts', stateClass(state), action ? 'btn-ico-wrap' : undefined, className)}
146
+ // 킷 선택자가 `.form-conts[data-delete=true]` 라 문자열 "true" 여야 한다.
147
+ data-delete={deletable ? 'true' : undefined}
148
+ {...rest}
149
+ >
150
+ {children}
151
+ {action}
152
+ </div>
153
+ );
154
+ }
155
+
156
+ /** 메시지 클래스 4종. `state` 를 생략하면 중립(`form-hint`, 회색)이다. */
157
+ export const FIELD_HINT_CLASS: Record<KrdsFieldState | 'neutral', string> = {
158
+ neutral: 'form-hint',
159
+ error: 'form-hint-invalid',
160
+ success: 'form-hint-success',
161
+ information: 'form-hint-information',
162
+ };
163
+
164
+ /** 상태 → 메시지 클래스. `undefined` 는 중립이다. */
165
+ export function fieldHintClass(state?: KrdsFieldState): string {
166
+ return FIELD_HINT_CLASS[state ?? 'neutral'];
167
+ }
168
+
169
+ export type FieldHintProps = ComponentPropsWithRef<'p'> & {
170
+ /** 생략하면 중립 도움말(`form-hint`). 상태를 주면 그 상태의 메시지가 된다. */
171
+ state?: KrdsFieldState;
172
+ };
173
+
174
+ /**
175
+ * `<p class="form-hint…">` — 도움말·상태 메시지. `.form-group` 안에 두어야 모양을 받는다.
176
+ * 아이콘은 킷이 `::before` 로 붙인다 — 따로 넣지 마라.
177
+ */
178
+ export function FieldHint({ state, className, children, ...rest }: FieldHintProps) {
179
+ return (
180
+ <p className={cx(fieldHintClass(state), className)} {...rest}>
181
+ {children}
182
+ </p>
183
+ );
184
+ }
185
+
186
+ /* ────────────────────────────── 완성형 ────────────────────────────── */
187
+
188
+ /** 컨트롤에 그대로 펼쳐 넣는 속성. `<input {...control} />` 처럼 쓴다. */
189
+ export type FieldControlAttrs = {
190
+ id: string;
191
+ /** 도움말·상태 메시지의 id 를 잇는다(둘 다 있으면 공백으로 이어 붙인다). */
192
+ 'aria-describedby'?: string;
193
+ 'aria-invalid'?: true;
194
+ 'aria-required'?: true;
195
+ required?: boolean;
196
+ };
197
+
198
+ export type FieldProps = Omit<ComponentPropsWithRef<'div'>, 'children'> & {
199
+ /** 컨트롤의 id. `<label for>` 와 `aria-describedby` 가 여기서 갈라져 나온다. 서버 컴포넌트라 필수다. */
200
+ id: string;
201
+ /** 레이블 글자. */
202
+ label: ReactNode;
203
+ /** 레이블을 눈에서만 감춘다. 이름은 남는다. */
204
+ hideLabel?: boolean;
205
+ /** 중립 도움말(회색). 상태와 무관한 입력 규칙 안내는 여기에 쓴다. */
206
+ hint?: ReactNode;
207
+ /** 필드 상태. `is-*` 는 `.form-conts` 에, 색은 `message` 에 걸린다. */
208
+ state?: KrdsFieldState;
209
+ /** 상태 메시지. `state` 와 짝으로 쓴다. */
210
+ message?: ReactNode;
211
+ /** 오류 문구 지름길. `state="error"` + `message` 와 같다(직접 준 쪽이 이긴다). */
212
+ error?: ReactNode;
213
+ /** 필수 입력. 레이블에 `*`, 컨트롤에 `required`·`aria-required` 를 함께 붙인다. */
214
+ required?: boolean;
215
+ /** 이미 있는 설명 요소를 더 잇고 싶을 때(공백으로 이어진 id 목록). */
216
+ describedBy?: string;
217
+ /** 컨트롤 오른쪽에 겹쳐 놓을 아이콘 버튼. {@link FieldControlProps.action} 참고. */
218
+ action?: ReactNode;
219
+ /** 「내용 지우기」 버튼을 연다. {@link FieldControlProps.deletable} 참고. */
220
+ deletable?: boolean;
221
+ /** `.form-conts` 에 그대로 흘릴 속성(`className="calendar-conts"` · `data-*` 등). */
222
+ contentProps?: Omit<ComponentPropsWithRef<'div'>, 'children'> & KrdsDataAttrs;
223
+ /** 이 필드 하나만 놓일 때 `<div class="fieldset">` 로 감싼다. 여럿이면 바깥에서 한 번만 감싼다. */
224
+ standalone?: boolean;
225
+ /**
226
+ * 컨트롤. 함수로 주면 접근성 속성을 받는다 — **반드시 펼쳐 넣어야** 레이블·도움말이 이어진다.
227
+ * 킷 클래스는 여기서 넘기지 않는다(컨트롤 부품이 스스로 붙인다).
228
+ */
229
+ children: ReactNode | ((control: FieldControlAttrs) => ReactNode);
230
+ };
231
+
232
+ /**
233
+ * 레이블 · 컨트롤 · 도움말 · 상태 메시지를 KRDS 골격 하나로 묶는다.
234
+ *
235
+ * 레이블 달린 텍스트 한 줄이면 `ui/text-input` 의 `TextField` 가 이 조립을 대신한다.
236
+ *
237
+ * @example
238
+ * <Field id="user_id" label="아이디" hint="4~30자" required>
239
+ * {(a) => <TextInput {...a} placeholder="아이디" />}
240
+ * </Field>
241
+ */
242
+ export function Field({
243
+ id,
244
+ label,
245
+ hideLabel,
246
+ hint,
247
+ state,
248
+ message,
249
+ error,
250
+ required,
251
+ describedBy,
252
+ action,
253
+ deletable,
254
+ contentProps,
255
+ standalone,
256
+ className,
257
+ children,
258
+ ...rest
259
+ }: FieldProps) {
260
+ // `error` 지름길. 직접 준 state·message 가 이긴다.
261
+ // 빈 값(undefined·null·'' ·false)은 「메시지 없음」으로 본다 — 빈 <p> 가 남으면 gap 만 벌어진다.
262
+ const finalState: KrdsFieldState | undefined = state ?? (error ? 'error' : undefined);
263
+ const finalMessage = message || error;
264
+
265
+ const hintId = hint ? `${id}-hint` : undefined;
266
+ const messageId = finalMessage ? `${id}-message` : undefined;
267
+
268
+ const control: FieldControlAttrs = { id };
269
+ // KRDS 는 규정하지 않지만 WAI-ARIA 상 옳다 — 초과 준수로 붙인다.
270
+ const described = cx(describedBy, hintId, messageId);
271
+ if (described) control['aria-describedby'] = described;
272
+ if (finalState === 'error') control['aria-invalid'] = true;
273
+ if (required) {
274
+ control.required = true;
275
+ control['aria-required'] = true;
276
+ }
277
+
278
+ const { className: contentClassName, ...contentRest } = contentProps ?? {};
279
+
280
+ const group = (
281
+ <FormGroup className={className} {...rest}>
282
+ <FieldLabel htmlFor={id} required={required} srOnly={hideLabel}>
283
+ {label}
284
+ </FieldLabel>
285
+
286
+ <FieldControl
287
+ state={finalState}
288
+ action={action}
289
+ deletable={deletable}
290
+ className={contentClassName}
291
+ {...contentRest}
292
+ >
293
+ {typeof children === 'function' ? children(control) : children}
294
+ </FieldControl>
295
+
296
+ {/* 중립 도움말. 상태 메시지와 **다른 줄**이다 — 규칙 안내는 오류가 떠도 그대로 남는다. */}
297
+ {hint && <FieldHint id={hintId}>{hint}</FieldHint>}
298
+ {finalMessage && (
299
+ <FieldHint id={messageId} state={finalState}>
300
+ {finalMessage}
301
+ </FieldHint>
302
+ )}
303
+ </FormGroup>
304
+ );
305
+
306
+ return standalone ? <Fieldset>{group}</Fieldset> : group;
307
+ }