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,492 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ useId,
6
+ useRef,
7
+ useState,
8
+ type ChangeEventHandler,
9
+ type ComponentPropsWithRef,
10
+ type DragEvent,
11
+ type ReactNode,
12
+ type Ref,
13
+ } from 'react';
14
+ import { cx, type KrdsSize } from '../lib/krds';
15
+
16
+ /**
17
+ * 파일 업로드 — KRDS `krds-file-upload`.
18
+ *
19
+ * 바깥틀(`FileUpload`) · 드롭 영역(`FileDropzone`) · 목록(`FileList` + `FileItem`)으로 나뉜다.
20
+ * 드래그 앤 드롭은 **반드시 파일 선택 버튼과 함께** 제공한다(`FileDropzone` 이 둘을 늘 같이 그린다).
21
+ * 유형·크기·개수 검사는 `onFiles` 에서 직접 한다 — 드롭으로 들어온 파일은 `accept` 로 걸러지지 않는다.
22
+ * 파일 이름 표기는 {@link formatFileLabel} 이 정한 `이름 [유형, 크기]` 한 가지다.
23
+ * 글 보기 화면의 정적 첨부 목록에는 `FileItem` 만 써도 된다.
24
+ *
25
+ * @example
26
+ * <FileUpload bordered title="첨부파일" description="hwp·pdf, 10MB 이하, 5개까지" descriptionId="up-desc">
27
+ * <FileDropzone multiple accept=".hwp,.pdf" describedBy="up-desc" onFiles={add} clearAfterSelect />
28
+ * <FileList count={files.length} max={5} onDeleteAll={clear}>
29
+ * {files.map((f) => (
30
+ * <FileItem key={f.id} name={f.name} size={f.size} onDelete={() => remove(f.id)} />
31
+ * ))}
32
+ * </FileList>
33
+ * </FileUpload>
34
+ *
35
+ * 자세히: docs/krds/09-부품-노트.md#파일첨부
36
+ */
37
+
38
+ /* ────────────────────────────── 표기 도우미 ────────────────────────────── */
39
+
40
+ /** 넘겨받은 ref 에 노드를 꽂는다. 컴포넌트 본문에서 prop 을 직접 대입하면 컴파일러 규칙에 걸려 밖에 둔다. */
41
+ function assignRef<T>(ref: Ref<T> | undefined, node: T | null): void {
42
+ if (typeof ref === 'function') ref(node);
43
+ else if (ref) (ref as { current: T | null }).current = node;
44
+ }
45
+
46
+ /** `useId()` 는 `«r0»` 처럼 특수문자를 담는다 — id 조각으로 쓸 수 있게 깎는다. */
47
+ function idBase(raw: string): string {
48
+ return raw.replace(/[^a-zA-Z0-9_-]/g, '') || 'file';
49
+ }
50
+
51
+ /** 이름 끝의 확장자를 뽑는다. 없으면 `undefined`. `.gitignore` 처럼 앞에 이름이 없으면 확장자로 보지 않는다. */
52
+ function extensionOf(name: string): string | undefined {
53
+ const m = /.\.([A-Za-z0-9]{1,10})$/.exec(name);
54
+ return m ? m[1].toLowerCase() : undefined;
55
+ }
56
+
57
+ /**
58
+ * 파일 크기 표기 — `17KB` · `1.2MB` 꼴(공백 없음). 1KB 미만도 `1KB` 로 올린다.
59
+ *
60
+ * @example
61
+ * formatFileSize(17408) // '17KB'
62
+ */
63
+ export function formatFileSize(bytes: number): string {
64
+ if (!Number.isFinite(bytes) || bytes < 0) return '';
65
+ if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`;
66
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
67
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)}GB`;
68
+ }
69
+
70
+ /**
71
+ * KRDS 파일 표기 — `이름 [유형, 크기]`. `type` 을 생략하면 이름 끝의 확장자에서 뽑아 이름에서 뗀다.
72
+ *
73
+ * @example
74
+ * formatFileLabel({ name: '위임장.hwp', size: 17408 }) // '위임장 [hwp, 17KB]'
75
+ */
76
+ export function formatFileLabel(file: { name: string; type?: string; size?: number }): string {
77
+ const { name, type, size } = file;
78
+ const auto = type ? undefined : extensionOf(name);
79
+ const shown = auto ? name.slice(0, name.length - auto.length - 1) : name;
80
+ const meta = [type ?? auto, typeof size === 'number' ? formatFileSize(size) : ''].filter(Boolean);
81
+ return meta.length > 0 ? `${shown} [${meta.join(', ')}]` : shown;
82
+ }
83
+
84
+ /* ────────────────────────────── 바깥 틀 ────────────────────────────── */
85
+
86
+ export type FileUploadProps = Omit<ComponentPropsWithRef<'div'>, 'title'> & {
87
+ /** 테두리형. 다른 폼과 구분해 한 덩어리로 보일 때. */
88
+ bordered?: boolean;
89
+ /** 제목. */
90
+ title?: ReactNode;
91
+ /** 제목 태그. 기본 `h3` — 화면의 제목 위계에 맞춘다. */
92
+ titleAs?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
93
+ /** 제목 아래 설명. **파일 유형·크기·개수 제한을 여기에 적는다**(가이드 요구). */
94
+ description?: ReactNode;
95
+ /** 설명 영역의 `id`. {@link FileDropzone} 의 `describedBy` 와 짝을 맞춘다. */
96
+ descriptionId?: string;
97
+ };
98
+
99
+ export function FileUpload({
100
+ bordered,
101
+ title,
102
+ titleAs: Tit = 'h3',
103
+ description,
104
+ descriptionId,
105
+ className,
106
+ children,
107
+ ...rest
108
+ }: FileUploadProps) {
109
+ return (
110
+ <div className={cx('krds-file-upload', bordered && 'line', className)} {...rest}>
111
+ {(title || description) && (
112
+ <div className="file-head">
113
+ {title && <Tit className="tit">{title}</Tit>}
114
+ {description && (
115
+ <div id={descriptionId}>
116
+ {typeof description === 'string' ? <p>{description}</p> : description}
117
+ </div>
118
+ )}
119
+ </div>
120
+ )}
121
+ {children}
122
+ </div>
123
+ );
124
+ }
125
+
126
+ /* ────────────────────────── 드롭 영역 + 파일 선택 ────────────────────────── */
127
+
128
+ export type FileDropzoneProps = Omit<
129
+ ComponentPropsWithRef<'div'>,
130
+ 'onChange' | 'onDrop' | 'onDragEnter' | 'onDragLeave' | 'onDragOver'
131
+ > & {
132
+ /** `<input type="file">` 의 id. 생략하면 `useId` 로 만든다. */
133
+ id?: string;
134
+ /** 폼 전송에 쓸 이름. */
135
+ name?: string;
136
+ /** 파일 선택창의 필터. **드롭으로 들어온 파일은 이걸로 걸러지지 않는다.** */
137
+ accept?: string;
138
+ /** 여러 개를 고를 수 있다. */
139
+ multiple?: boolean;
140
+ /** 비활성. 드롭도 받지 않는다. */
141
+ disabled?: boolean;
142
+ /** 필수 입력. */
143
+ required?: boolean;
144
+ /** 파일칸을 직접 다뤄야 할 때(값 비우기 등). */
145
+ inputRef?: Ref<HTMLInputElement>;
146
+ /** 파일칸에 걸 `aria-describedby`. {@link FileUpload} 의 `descriptionId` 와 짝이다. */
147
+ describedBy?: string;
148
+ /** 드롭 영역 안내 문구. 기본값은 킷 샘플 문구 그대로. */
149
+ text?: ReactNode;
150
+ /** 파일 선택 버튼 글자. */
151
+ buttonLabel?: ReactNode;
152
+ /** 기본 medium(48px). 같은 줄의 폼 요소와 같은 값을 준다. */
153
+ buttonSize?: KrdsSize;
154
+ /** 파일 선택 버튼의 위계. `primary` 는 제출 버튼과 강조가 충돌해 두지 않았다. */
155
+ buttonVariant?: 'secondary' | 'tertiary' | 'text';
156
+ /** 선택·드롭 공통 진입점. `multiple` 이 아니면 첫 파일 하나만 넘긴다. */
157
+ onFiles?: (files: File[]) => void;
158
+ /** 원래의 `change` 도 필요하면 받는다. `onFiles` 보다 먼저 불린다. */
159
+ onChange?: ChangeEventHandler<HTMLInputElement>;
160
+ /** 넘긴 뒤 파일칸을 비워 같은 파일 재선택을 살린다. **폼 제출로 파일을 보낸다면 켜지 마라.** */
161
+ clearAfterSelect?: boolean;
162
+ };
163
+
164
+ export function FileDropzone({
165
+ id,
166
+ name,
167
+ accept,
168
+ multiple,
169
+ disabled,
170
+ required,
171
+ inputRef,
172
+ describedBy,
173
+ text = '첨부할 파일을 여기에 끌어다 놓거나, 파일 선택 버튼을 눌러 파일을 직접 선택해주세요.',
174
+ buttonLabel = '파일선택',
175
+ buttonSize = 'medium',
176
+ buttonVariant,
177
+ onFiles,
178
+ onChange,
179
+ clearAfterSelect,
180
+ className,
181
+ children,
182
+ ...rest
183
+ }: FileDropzoneProps) {
184
+ const uid = idBase(useId());
185
+ const inputId = id ?? `file-upload-${uid}`;
186
+ const textId = `file-upload-txt-${uid}`;
187
+ const input = useRef<HTMLInputElement | null>(null);
188
+ const [dragging, setDragging] = useState(false);
189
+ // dragenter/dragleave 는 자식 요소를 지날 때마다 다시 뜬다. 깊이를 세어야 테두리가 깜빡이지 않는다.
190
+ const depth = useRef(0);
191
+
192
+ const setInput = useCallback(
193
+ (node: HTMLInputElement | null) => {
194
+ input.current = node;
195
+ assignRef(inputRef, node);
196
+ },
197
+ [inputRef],
198
+ );
199
+
200
+ const emit = (list: FileList | null | undefined) => {
201
+ if (!list || list.length === 0) return;
202
+ const files = Array.from(list);
203
+ onFiles?.(multiple ? files : files.slice(0, 1));
204
+ };
205
+
206
+ const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
207
+ onChange?.(e);
208
+ emit(e.currentTarget.files);
209
+ if (clearAfterSelect) e.currentTarget.value = '';
210
+ };
211
+
212
+ const enter = (e: DragEvent<HTMLDivElement>) => {
213
+ if (disabled) return;
214
+ e.preventDefault();
215
+ depth.current += 1;
216
+ setDragging(true);
217
+ };
218
+ const over = (e: DragEvent<HTMLDivElement>) => {
219
+ if (disabled) return;
220
+ // 이 preventDefault 를 빼면 브라우저가 기본 동작(파일 열기)을 해 버려 drop 이 아예 오지 않는다.
221
+ e.preventDefault();
222
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
223
+ };
224
+ const leave = () => {
225
+ if (disabled) return;
226
+ depth.current = Math.max(0, depth.current - 1);
227
+ if (depth.current === 0) setDragging(false);
228
+ };
229
+ const drop = (e: DragEvent<HTMLDivElement>) => {
230
+ if (disabled) return;
231
+ e.preventDefault();
232
+ depth.current = 0;
233
+ setDragging(false);
234
+ emit(e.dataTransfer?.files);
235
+ };
236
+
237
+ return (
238
+ <div
239
+ className={cx('file-upload', dragging && 'active', className)}
240
+ {...rest}
241
+ onDragEnter={enter}
242
+ onDragOver={over}
243
+ onDragLeave={leave}
244
+ onDrop={drop}
245
+ >
246
+ <p className="txt" id={textId}>
247
+ {text}
248
+ </p>
249
+ <div className="file-upload-btn-wrap">
250
+ {/* hidden 을 붙이지 않는다 — 킷 CSS 가 이미 감추고, hidden 은 초점까지 막아 제출이 조용히 멈춘다. */}
251
+ <input
252
+ type="file"
253
+ id={inputId}
254
+ name={name}
255
+ accept={accept}
256
+ multiple={multiple}
257
+ disabled={disabled}
258
+ required={required}
259
+ tabIndex={-1}
260
+ aria-describedby={cx(textId, describedBy)}
261
+ ref={setInput}
262
+ onChange={handleChange}
263
+ />
264
+ <label htmlFor={inputId}>
265
+ <button
266
+ type="button"
267
+ className={cx('krds-btn', buttonSize, buttonVariant)}
268
+ disabled={disabled}
269
+ // `<label>` 안의 `<button>` 은 라벨 활성화를 일으키지 못해 직접 부른다.
270
+ onClick={() => input.current?.click()}
271
+ >
272
+ <i className="svg-icon ico-upload" aria-hidden="true" />
273
+ {buttonLabel}
274
+ </button>
275
+ </label>
276
+ </div>
277
+ {children}
278
+ </div>
279
+ );
280
+ }
281
+
282
+ /* ────────────────────────────── 파일 목록 ────────────────────────────── */
283
+
284
+ type FileListBaseProps = ComponentPropsWithRef<'ul'> & {
285
+ /** 올릴 수 있는 최대 파일 수. */
286
+ max?: number;
287
+ /** 세는 단위. 기본 `개`. */
288
+ unit?: string;
289
+ /** 전체 삭제 버튼 글자. */
290
+ deleteAllLabel?: ReactNode;
291
+ /** `.file-list` 에 붙일 클래스. `className` 은 `<ul class="upload-list">` 로 간다. */
292
+ wrapClassName?: string;
293
+ };
294
+
295
+ /** 카운터를 그리는 두 방법. `count`(부품이 `3개 / 10개` 를 그린다) 또는 `counter`(직접 그린다). */
296
+ type FileListCounterProps =
297
+ | { count: number; counter?: ReactNode }
298
+ // `counter={null}` 로 빠져나가지 못하게 null·undefined 를 뺀다 — 그러면 `.total` 이 안 그려진다.
299
+ | { count?: number; counter: NonNullable<ReactNode> };
300
+
301
+ /**
302
+ * **전체 삭제 버튼은 카운터 없이 둘 수 없다** — 킷이 그 버튼을 절대 배치해서 카운터가 없으면
303
+ * 목록 첫 항목 위에 겹친다. 타입으로 막아 둔다.
304
+ */
305
+ export type FileListProps = FileListBaseProps &
306
+ (
307
+ | ({
308
+ /** 전체 삭제. 카운터(`count` 또는 `counter`)가 함께 필요하다. 조건부로 `undefined` 도 받는다. */
309
+ onDeleteAll: (() => void) | undefined;
310
+ } & FileListCounterProps)
311
+ | {
312
+ onDeleteAll?: never;
313
+ /** 올라간 파일 수. `max` 와 함께 주면 `3개 / 10개` 카운터를 그린다. */
314
+ count?: number;
315
+ /** 카운터를 직접 그릴 때. 주면 `count`·`max` 는 무시한다. */
316
+ counter?: ReactNode;
317
+ }
318
+ );
319
+
320
+ /** 유니온은 판별자가 없어 그대로 구조 분해가 안 된다 — 넓은 쪽으로 한 번 받아 쓴다. */
321
+ type FileListAllProps = FileListBaseProps & {
322
+ count?: number;
323
+ counter?: ReactNode;
324
+ onDeleteAll?: () => void;
325
+ };
326
+
327
+ export function FileList(props: FileListProps) {
328
+ const {
329
+ count,
330
+ max,
331
+ unit = '개',
332
+ counter,
333
+ onDeleteAll,
334
+ deleteAllLabel = '전체 파일 삭제',
335
+ wrapClassName,
336
+ className,
337
+ children,
338
+ ...rest
339
+ }: FileListAllProps = props;
340
+ const total =
341
+ counter ??
342
+ (typeof count === 'number' ? (
343
+ <>
344
+ <span className="current">
345
+ {count}
346
+ {unit}
347
+ </span>
348
+ {typeof max === 'number' && ` / ${max}${unit}`}
349
+ </>
350
+ ) : null);
351
+
352
+ return (
353
+ <div className={cx('file-list', wrapClassName)}>
354
+ {total && <div className="total">{total}</div>}
355
+ <ul className={cx('upload-list', className)} {...rest}>
356
+ {children}
357
+ </ul>
358
+ {onDeleteAll && (
359
+ <div className="upload-delete-btn">
360
+ <button type="button" className="krds-btn xsmall tertiary" onClick={onDeleteAll}>
361
+ {deleteAllLabel}
362
+ <i className="svg-icon ico-angle right" aria-hidden="true" />
363
+ </button>
364
+ </div>
365
+ )}
366
+ </div>
367
+ );
368
+ }
369
+
370
+ /* ────────────────────────────── 파일 항목 ────────────────────────────── */
371
+
372
+ /** 업로드 상태 아이콘. `uploading` 스피너 · `complete` 완료 표시. */
373
+ export type FileItemStatus = 'uploading' | 'complete';
374
+
375
+ type FileItemBaseProps = Omit<ComponentPropsWithRef<'li'>, 'children'> & {
376
+ /** 파일 이름. `type` 을 안 주면 여기서 확장자를 뽑아 이름에서 뗀다({@link formatFileLabel}). */
377
+ name: string;
378
+ /** 파일 유형. 예: `hwp`. */
379
+ type?: string;
380
+ /** 바이트 수. `328KB` 꼴로 바꿔 이름 옆 대괄호에 넣는다. */
381
+ size?: number;
382
+ /** 삭제 단추를 그린다. */
383
+ onDelete?: () => void;
384
+ /** 삭제 단추 글자. */
385
+ deleteLabel?: ReactNode;
386
+ /** 내려받기 주소. 「다운로드」와 「바로보기」는 규격상 따로 둔다. */
387
+ downloadHref?: string;
388
+ /** 내려받기 단추 글자. */
389
+ downloadLabel?: ReactNode;
390
+ /** 바로보기(미리보기) 주소. */
391
+ viewHref?: string;
392
+ /** 바로보기 단추 글자. */
393
+ viewLabel?: ReactNode;
394
+ /** 항목 버튼 크기. 킷 샘플은 medium. */
395
+ actionSize?: KrdsSize;
396
+ /** 좁은 화면에서 이름과 버튼을 위아래로 놓는다. 버튼이 둘 이상일 때 쓴다. */
397
+ stackOnMobile?: boolean;
398
+ /** 버튼 영역을 통째로 대신 그린다. */
399
+ children?: ReactNode;
400
+ };
401
+
402
+ /** 업로드 상태와 오류는 배타다 — 등록되지 않은 파일에 스피너·완료 아이콘이 같이 뜨면 안 된다. */
403
+ export type FileItemProps = FileItemBaseProps &
404
+ (
405
+ | {
406
+ /** 업로드 진행 표시. 완료 아이콘은 잠시 뒤 삭제 버튼으로 바꾼다. */
407
+ status?: FileItemStatus;
408
+ error?: never;
409
+ }
410
+ | {
411
+ status?: never;
412
+ /** 오류 문구. 「형식이 올바르지 않습니다」처럼 **무엇이 틀렸는지** 밝힌다(가이드 요구). */
413
+ error: ReactNode;
414
+ }
415
+ );
416
+
417
+ /** 유니온은 그대로 구조 분해가 안 된다 — 넓은 쪽으로 한 번 받아 쓴다. */
418
+ type FileItemAllProps = FileItemBaseProps & {
419
+ status?: FileItemStatus;
420
+ error?: ReactNode;
421
+ };
422
+
423
+ export function FileItem(props: FileItemProps) {
424
+ const {
425
+ name,
426
+ type,
427
+ size,
428
+ status,
429
+ error,
430
+ onDelete,
431
+ deleteLabel = '삭제',
432
+ downloadHref,
433
+ downloadLabel = '다운로드',
434
+ viewHref,
435
+ viewLabel = '바로보기',
436
+ actionSize = 'medium',
437
+ stackOnMobile,
438
+ className,
439
+ children,
440
+ ...rest
441
+ }: FileItemAllProps = props;
442
+ const uid = idBase(useId());
443
+ const nameId = `file-name-${uid}`;
444
+ const btn = cx('krds-btn', actionSize, 'text');
445
+ const hasDefaultAction = Boolean(status || downloadHref || viewHref || onDelete);
446
+
447
+ // 이름과 버튼을 `aria-describedby` 로 잇는다(가이드 요구) — 어느 파일의 버튼인지 읽어 준다.
448
+ const actions = children ?? (hasDefaultAction ? (
449
+ <>
450
+ {status === 'uploading' && (
451
+ <span className="krds-spinner" role="status">
452
+ <span className="sr-only">업로드 중</span>
453
+ </span>
454
+ )}
455
+ {status === 'complete' && (
456
+ <span className="ico-invalid complete">
457
+ <em className="sr-only">업로드 완료</em>
458
+ </span>
459
+ )}
460
+ {downloadHref && (
461
+ <a className={btn} href={downloadHref} download aria-describedby={nameId}>
462
+ {downloadLabel}
463
+ <i className="svg-icon ico-down" aria-hidden="true" />
464
+ </a>
465
+ )}
466
+ {viewHref && (
467
+ <a className={btn} href={viewHref} aria-describedby={nameId}>
468
+ {viewLabel}
469
+ <i className="svg-icon ico-angle right" aria-hidden="true" />
470
+ </a>
471
+ )}
472
+ {onDelete && (
473
+ <button type="button" className={btn} onClick={onDelete} aria-describedby={nameId}>
474
+ {deleteLabel}
475
+ <i className="svg-icon ico-delete-fill" aria-hidden="true" />
476
+ </button>
477
+ )}
478
+ </>
479
+ ) : null);
480
+
481
+ return (
482
+ <li className={cx(error ? 'is-error' : undefined, className)} {...rest}>
483
+ <div className={cx('file-info', stackOnMobile && 'm-column')}>
484
+ <div className="file-name" id={nameId}>
485
+ {formatFileLabel({ name, type, size })}
486
+ </div>
487
+ {actions ? <div className="btn-wrap">{actions}</div> : null}
488
+ </div>
489
+ {error ? <p className="file-hint-invalid">{error}</p> : null}
490
+ </li>
491
+ );
492
+ }