@uniai-fe/uds-primitives 0.2.4 → 0.2.5
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/dist/styles.css +56 -0
- package/package.json +1 -1
- package/src/components/input/hooks/index.ts +3 -0
- package/src/components/input/hooks/useInputFile.ts +254 -0
- package/src/components/input/hooks/useInputFileContext.ts +244 -0
- package/src/components/input/hooks/useInputFileRHF.ts +105 -0
- package/src/components/input/index.scss +1 -0
- package/src/components/input/markup/file/DragAndDrop.tsx +59 -0
- package/src/components/input/markup/file/UploadButton.tsx +44 -0
- package/src/components/input/markup/file/UploadedChip.tsx +66 -0
- package/src/components/input/markup/file/index.tsx +25 -0
- package/src/components/input/markup/file/list/Body.tsx +55 -0
- package/src/components/input/markup/file/list/Container.tsx +97 -0
- package/src/components/input/markup/file/list/Header.tsx +86 -0
- package/src/components/input/markup/file/list/Item.tsx +35 -0
- package/src/components/input/markup/file/list/Provider.tsx +35 -0
- package/src/components/input/markup/file/list/Remove.tsx +54 -0
- package/src/components/input/markup/file/list/index.tsx +31 -0
- package/src/components/input/markup/foundation/SideSlot.tsx +0 -2
- package/src/components/input/markup/foundation/index.tsx +0 -2
- package/src/components/input/markup/index.tsx +2 -0
- package/src/components/input/markup/text/Search.tsx +0 -2
- package/src/components/input/styles/file.scss +60 -0
- package/src/components/input/types/file.ts +531 -0
- package/src/components/input/types/index.ts +1 -0
- package/src/components/input/utils/file.ts +103 -0
- package/src/components/input/utils/index.tsx +1 -0
package/dist/styles.css
CHANGED
|
@@ -2366,6 +2366,62 @@ figure.chip {
|
|
|
2366
2366
|
width: 100%;
|
|
2367
2367
|
}
|
|
2368
2368
|
|
|
2369
|
+
.input-file-drag-and-drop {
|
|
2370
|
+
width: 100%;
|
|
2371
|
+
border: 1px dashed var(--input-border-color);
|
|
2372
|
+
border-radius: var(--input-default-radius-base);
|
|
2373
|
+
background-color: var(--input-surface-color);
|
|
2374
|
+
transition: border-color 0.2s ease, background-color 0.2s ease;
|
|
2375
|
+
}
|
|
2376
|
+
.input-file-drag-and-drop[data-dragging=true] {
|
|
2377
|
+
border-color: var(--input-border-active-color);
|
|
2378
|
+
background-color: var(--input-surface-muted-color);
|
|
2379
|
+
}
|
|
2380
|
+
.input-file-drag-and-drop[data-disabled=true] {
|
|
2381
|
+
opacity: 0.6;
|
|
2382
|
+
cursor: not-allowed;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
.input-file-list-container {
|
|
2386
|
+
width: 100%;
|
|
2387
|
+
display: flex;
|
|
2388
|
+
flex-direction: column;
|
|
2389
|
+
gap: 8px;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
.input-file-list-header {
|
|
2393
|
+
width: 100%;
|
|
2394
|
+
display: flex;
|
|
2395
|
+
align-items: center;
|
|
2396
|
+
justify-content: space-between;
|
|
2397
|
+
}
|
|
2398
|
+
.input-file-list-header[data-trigger=true] {
|
|
2399
|
+
cursor: pointer;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
.input-file-list-body {
|
|
2403
|
+
width: 100%;
|
|
2404
|
+
display: flex;
|
|
2405
|
+
flex-direction: column;
|
|
2406
|
+
gap: 6px;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
.input-file-list-item {
|
|
2410
|
+
width: 100%;
|
|
2411
|
+
display: flex;
|
|
2412
|
+
align-items: center;
|
|
2413
|
+
justify-content: space-between;
|
|
2414
|
+
gap: 8px;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
.input-file-list-remove {
|
|
2418
|
+
border: 0;
|
|
2419
|
+
padding: 0;
|
|
2420
|
+
background: transparent;
|
|
2421
|
+
color: inherit;
|
|
2422
|
+
cursor: pointer;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2369
2425
|
/* Select tokens mapped to Input primary tokens for visual parity */
|
|
2370
2426
|
|
|
2371
2427
|
|
package/package.json
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
1
|
export { useDigitField } from "./useDigitField";
|
|
2
2
|
export { useAddress, useAddressFields } from "./useAddress";
|
|
3
|
+
export { useInputFile } from "./useInputFile";
|
|
4
|
+
export { useInputFileContext } from "./useInputFileContext";
|
|
5
|
+
export { useInputFileRHF } from "./useInputFileRHF";
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
|
+
import type { DragEvent, SyntheticEvent, ChangeEvent } from "react";
|
|
5
|
+
import type { UseInputFileOptions, UseInputFileResult } from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input Hook; File Upload 이벤트 제어 Hook
|
|
9
|
+
* @hook
|
|
10
|
+
* @param {UseInputFileOptions} options file upload 옵션
|
|
11
|
+
* @param {InputFileChangeHandler} options.onFilesChange 파일 변경 콜백
|
|
12
|
+
* @param {RefObject<HTMLInputElement | null>} [options.inputRef] 직접 제어할 file input ref
|
|
13
|
+
* @param {string} [options.selectorId] inputRef가 없을 때 조회할 input id
|
|
14
|
+
* @param {boolean} [options.disabled=false] 동작 비활성화 여부
|
|
15
|
+
* @param {boolean} [options.resetInputValue=true] 등록 후 input 값을 비울지 여부
|
|
16
|
+
* @returns {UseInputFileResult} file 업로드 핸들러 묶음
|
|
17
|
+
* @desc
|
|
18
|
+
* - return.state: { isDragging, selectedFiles, hasSelectedFiles }
|
|
19
|
+
* - return.input: { onOpenFileDialog, onInputFileChange }
|
|
20
|
+
* - return.dragdrop: { onDropFiles, onDragEnter, onDragOver, onDragLeave }
|
|
21
|
+
* - return.helper: { onClearSelectedFiles }
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* // 1) input element ref 준비
|
|
25
|
+
* const inputRef = useRef<HTMLInputElement | null>(null);
|
|
26
|
+
*
|
|
27
|
+
* // 2) 파일 이벤트 제어 훅 연결
|
|
28
|
+
* const fileUpload = useInputFile({
|
|
29
|
+
* inputRef,
|
|
30
|
+
* onFilesChange: (files, source) => {
|
|
31
|
+
* console.log(source, files);
|
|
32
|
+
* },
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // 3) 업로드 트리거 + input change 연결
|
|
36
|
+
* <input ref={inputRef} type="file" hidden onChange={fileUpload.onInputFileChange} />
|
|
37
|
+
* <Input.File.UploadButton onOpenFileDialog={fileUpload.onOpenFileDialog}>
|
|
38
|
+
* 파일 선택
|
|
39
|
+
* </Input.File.UploadButton>
|
|
40
|
+
*
|
|
41
|
+
* // 4) 목록 렌더링 시 key는 안정적인 식별자(id) + depth 포맷을 사용
|
|
42
|
+
* fileContext.fileMetas.map(meta => (
|
|
43
|
+
* <Input.File.List.Item key={`input/file/list/item/${meta.id}`}>
|
|
44
|
+
* {meta.name}
|
|
45
|
+
* </Input.File.List.Item>
|
|
46
|
+
* ));
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export const useInputFile = ({
|
|
50
|
+
onFilesChange,
|
|
51
|
+
inputRef,
|
|
52
|
+
selectorId,
|
|
53
|
+
disabled = false,
|
|
54
|
+
resetInputValue = true,
|
|
55
|
+
}: UseInputFileOptions): UseInputFileResult => {
|
|
56
|
+
/**
|
|
57
|
+
* Input Hook; File Upload 드래그 오버 상태
|
|
58
|
+
*/
|
|
59
|
+
// dragenter/dragleave/drop 이벤트에 따라 drop zone 시각 상태를 제어한다.
|
|
60
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
61
|
+
/**
|
|
62
|
+
* Input Hook; File Upload 최근 선택 파일 상태
|
|
63
|
+
*/
|
|
64
|
+
// input change 또는 drop으로 유입된 최신 파일 배열을 보관한다.
|
|
65
|
+
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
66
|
+
/**
|
|
67
|
+
* Input Hook; File Upload dragenter/dragleave depth 추적 ref
|
|
68
|
+
*/
|
|
69
|
+
// 중첩 DOM에서 연속 발생하는 drag 이벤트를 안정적으로 계산하기 위한 depth 카운터다.
|
|
70
|
+
const dragDepthRef = useRef(0);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Input Hook; File Upload input element 조회
|
|
74
|
+
*/
|
|
75
|
+
const resolveInputElement = useCallback(() => {
|
|
76
|
+
// 우선순위 1: 소비자가 직접 전달한 ref를 그대로 사용한다.
|
|
77
|
+
if (inputRef?.current) {
|
|
78
|
+
return inputRef.current;
|
|
79
|
+
}
|
|
80
|
+
// 우선순위 2: selectorId 기반으로 DOM을 조회한다.
|
|
81
|
+
if (!selectorId || typeof document === "undefined") {
|
|
82
|
+
// selectorId가 없거나 SSR 환경이면 조회할 수 없으므로 null을 반환한다.
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
// selectorId가 있으면 file input element를 querySelector로 찾는다.
|
|
86
|
+
return document.querySelector<HTMLInputElement>(`#${selectorId}`);
|
|
87
|
+
}, [inputRef, selectorId]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Input Hook; File Upload 파일 선택창 열기 핸들러
|
|
91
|
+
* @param {SyntheticEvent<HTMLElement>} [event] 파일 선택창 오픈 트리거 이벤트
|
|
92
|
+
*/
|
|
93
|
+
const onOpenFileDialog = useCallback(
|
|
94
|
+
(event?: SyntheticEvent<HTMLElement>) => {
|
|
95
|
+
// 버튼 클릭 시 기본 submit/navigation 동작을 방지한다.
|
|
96
|
+
event?.preventDefault();
|
|
97
|
+
// 비활성화 상태거나 window가 없는 환경(SSR)에서는 즉시 중단한다.
|
|
98
|
+
if (disabled || typeof window === "undefined") {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// 현재 연결 가능한 file input element를 조회한다.
|
|
102
|
+
const inputElement = resolveInputElement();
|
|
103
|
+
// native file dialog를 열기 위해 input.click()을 호출한다.
|
|
104
|
+
inputElement?.click();
|
|
105
|
+
},
|
|
106
|
+
[disabled, resolveInputElement],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Input Hook; File Upload input change 등록 핸들러
|
|
111
|
+
* @param {ChangeEvent<HTMLInputElement>} event file input change 이벤트
|
|
112
|
+
*/
|
|
113
|
+
const onInputFileChange = useCallback(
|
|
114
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
115
|
+
// disabled 상태에서는 입력 이벤트를 무시한다.
|
|
116
|
+
if (disabled) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// change 이벤트에서 FileList를 읽는다.
|
|
120
|
+
const files = event.currentTarget.files;
|
|
121
|
+
// FileList가 비었으면 아무 동작도 하지 않는다.
|
|
122
|
+
if (!files || files.length === 0) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
// FileList를 배열로 변환해 후속 로직에서 다루기 쉽게 만든다.
|
|
126
|
+
const fileArray = Array.from(files);
|
|
127
|
+
// 최근 선택 파일 상태를 갱신한다.
|
|
128
|
+
setSelectedFiles(fileArray);
|
|
129
|
+
// 외부 콜백으로 입력 소스(input)와 함께 파일 배열을 전달한다.
|
|
130
|
+
onFilesChange(fileArray, "input", event);
|
|
131
|
+
|
|
132
|
+
// Input Hook; 같은 파일 재선택 시 change 이벤트 재트리거 보장
|
|
133
|
+
if (resetInputValue) {
|
|
134
|
+
// 입력값을 비워 같은 파일 재선택 시에도 change가 다시 발생하도록 한다.
|
|
135
|
+
event.currentTarget.value = "";
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
[disabled, onFilesChange, resetInputValue],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Input Hook; File Upload dragenter 상태 진입 핸들러
|
|
143
|
+
* @param {DragEvent<HTMLElement>} event dragenter 이벤트
|
|
144
|
+
*/
|
|
145
|
+
const onDragEnter = useCallback(
|
|
146
|
+
(event: DragEvent<HTMLElement>) => {
|
|
147
|
+
// 브라우저 기본 drop 처리(파일 열기)를 방지한다.
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
// 상위 핸들러로 이벤트 전파를 막아 상태 갱신 충돌을 줄인다.
|
|
150
|
+
event.stopPropagation();
|
|
151
|
+
// disabled 상태에서는 drag 상태를 변경하지 않는다.
|
|
152
|
+
if (disabled) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// dragenter가 중첩 호출될 수 있어 depth를 증가시킨다.
|
|
156
|
+
dragDepthRef.current += 1;
|
|
157
|
+
// drop zone 진입 상태를 true로 전환한다.
|
|
158
|
+
setIsDragging(true);
|
|
159
|
+
},
|
|
160
|
+
[disabled],
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Input Hook; File Upload dragover 기본 이벤트 차단 핸들러
|
|
165
|
+
* @param {DragEvent<HTMLElement>} event dragover 이벤트
|
|
166
|
+
*/
|
|
167
|
+
const onDragOver = useCallback((event: DragEvent<HTMLElement>) => {
|
|
168
|
+
// dragover의 기본 동작을 막아 drop 가능 상태를 유지한다.
|
|
169
|
+
event.preventDefault();
|
|
170
|
+
// 불필요한 이벤트 버블링을 차단한다.
|
|
171
|
+
event.stopPropagation();
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Input Hook; File Upload dragleave 상태 이탈 핸들러
|
|
176
|
+
* @param {DragEvent<HTMLElement>} event dragleave 이벤트
|
|
177
|
+
*/
|
|
178
|
+
const onDragLeave = useCallback(
|
|
179
|
+
(event: DragEvent<HTMLElement>) => {
|
|
180
|
+
// 브라우저 기본 동작을 방지한다.
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
// 이벤트 전파를 차단한다.
|
|
183
|
+
event.stopPropagation();
|
|
184
|
+
// disabled 상태에서는 drag 상태를 변경하지 않는다.
|
|
185
|
+
if (disabled) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// 중첩 leave 이벤트를 고려해 depth를 1 감소시킨다.
|
|
189
|
+
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
|
190
|
+
// depth가 0이면 실제로 영역 밖으로 벗어난 상태로 판단한다.
|
|
191
|
+
if (dragDepthRef.current === 0) {
|
|
192
|
+
// drop zone 시각 상태를 기본값(false)으로 되돌린다.
|
|
193
|
+
setIsDragging(false);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
[disabled],
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Input Hook; File Upload drop 파일 등록 핸들러
|
|
201
|
+
* @param {DragEvent<HTMLElement>} event drop 이벤트
|
|
202
|
+
*/
|
|
203
|
+
const onDropFiles = useCallback(
|
|
204
|
+
(event: DragEvent<HTMLElement>) => {
|
|
205
|
+
// drop 시 브라우저 기본 파일 열기 동작을 방지한다.
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
// 이벤트 전파를 차단해 상위 충돌을 방지한다.
|
|
208
|
+
event.stopPropagation();
|
|
209
|
+
// drop 시점에는 drag depth를 초기화한다.
|
|
210
|
+
dragDepthRef.current = 0;
|
|
211
|
+
// drop 완료 후 drag 시각 상태를 해제한다.
|
|
212
|
+
setIsDragging(false);
|
|
213
|
+
// disabled 상태에서는 파일 등록 로직을 수행하지 않는다.
|
|
214
|
+
if (disabled) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// drop 이벤트에서 전달된 FileList를 읽는다.
|
|
218
|
+
const files = event.dataTransfer.files;
|
|
219
|
+
// 파일이 없으면 중단한다.
|
|
220
|
+
if (!files || files.length === 0) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// FileList를 배열로 변환한다.
|
|
224
|
+
const fileArray = Array.from(files);
|
|
225
|
+
// 최근 선택 파일 상태를 drop 기준으로 갱신한다.
|
|
226
|
+
setSelectedFiles(fileArray);
|
|
227
|
+
// 외부 콜백으로 입력 소스(drop)와 함께 파일 배열을 전달한다.
|
|
228
|
+
onFilesChange(fileArray, "drop", event);
|
|
229
|
+
},
|
|
230
|
+
[disabled, onFilesChange],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Input Hook; File Upload 선택 파일 상태 초기화 핸들러
|
|
235
|
+
*/
|
|
236
|
+
const onClearSelectedFiles = useCallback(() => {
|
|
237
|
+
// 최근 선택 파일 상태를 빈 배열로 초기화한다.
|
|
238
|
+
setSelectedFiles([]);
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
// 훅 소비자가 바로 사용하도록 상태/핸들러를 역할별로 반환한다.
|
|
242
|
+
return {
|
|
243
|
+
isDragging,
|
|
244
|
+
selectedFiles,
|
|
245
|
+
hasSelectedFiles: selectedFiles.length > 0,
|
|
246
|
+
onClearSelectedFiles,
|
|
247
|
+
onOpenFileDialog,
|
|
248
|
+
onInputFileChange,
|
|
249
|
+
onDropFiles,
|
|
250
|
+
onDragEnter,
|
|
251
|
+
onDragOver,
|
|
252
|
+
onDragLeave,
|
|
253
|
+
};
|
|
254
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import type {
|
|
5
|
+
InputFileEntry,
|
|
6
|
+
InputFileEvent,
|
|
7
|
+
InputFileFormDataOptions,
|
|
8
|
+
InputFileSource,
|
|
9
|
+
UseInputFileContextOptions,
|
|
10
|
+
UseInputFileContextResult,
|
|
11
|
+
} from "../types";
|
|
12
|
+
import { createInputFileFormData, createInputFileMeta } from "../utils/file";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Input Hook; File Upload 컨텍스트 관리 Hook
|
|
16
|
+
* @hook
|
|
17
|
+
* @param {UseInputFileContextOptions} [options] 파일 컨텍스트 옵션
|
|
18
|
+
* @param {File[]} [options.initialFiles] 초기 파일 목록
|
|
19
|
+
* @param {InputFileContextMergeMode} [options.mergeMode="append"] 파일 합치기 전략
|
|
20
|
+
* @returns {UseInputFileContextResult} 파일 컨텍스트 상태/액션
|
|
21
|
+
* @desc
|
|
22
|
+
* - return.state: { entries, files, fileMetas, hasFiles, fileCount }
|
|
23
|
+
* - return.sync: { onSyncFromInputFile }
|
|
24
|
+
* - return.collection: { onReplaceFiles, onAppendFiles, onRemoveFileById, onRemoveFileByIndex, onClearFiles }
|
|
25
|
+
* - return.submit: { toFormData }
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* // 1) 파일 컨텍스트를 append 모드로 생성
|
|
29
|
+
* const fileContext = useInputFileContext({ mergeMode: "append" });
|
|
30
|
+
*
|
|
31
|
+
* // 2) Input 이벤트를 컨텍스트 동기화 핸들러에 연결
|
|
32
|
+
* const fileUpload = useInputFile({
|
|
33
|
+
* onFilesChange: fileContext.onSyncFromInputFile,
|
|
34
|
+
* });
|
|
35
|
+
*
|
|
36
|
+
* // 3) 목록 렌더링은 fileMetas로 구성
|
|
37
|
+
* fileContext.fileMetas.map(meta => {
|
|
38
|
+
* return `${meta.name} / ${meta.sizeLabel}`;
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* // 4) submit payload는 컨텍스트에서 직렬화
|
|
42
|
+
* const formData = fileContext.toFormData({ fieldName: "attach_files" });
|
|
43
|
+
* formData.append("message", "hello");
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export const useInputFileContext = ({
|
|
47
|
+
initialFiles,
|
|
48
|
+
mergeMode = "append",
|
|
49
|
+
}: UseInputFileContextOptions = {}): UseInputFileContextResult => {
|
|
50
|
+
/**
|
|
51
|
+
* Input Hook; File Upload 엔트리 고유 id 시퀀스 ref
|
|
52
|
+
*/
|
|
53
|
+
// 파일이 추가될 때마다 증가하는 시퀀스를 유지한다.
|
|
54
|
+
const entryIdRef = useRef(0);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Input Hook; File Upload 엔트리 id 생성
|
|
58
|
+
*/
|
|
59
|
+
const createEntryId = useCallback((): string => {
|
|
60
|
+
// 시퀀스를 1 증가시킨다.
|
|
61
|
+
entryIdRef.current += 1;
|
|
62
|
+
// 엔트리 key로 사용할 고유 문자열을 반환한다.
|
|
63
|
+
return `input-file-${entryIdRef.current}`;
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Input Hook; File Upload 원본 파일 배열을 엔트리 배열로 변환
|
|
68
|
+
* @param {File[]} files 원본 File 배열
|
|
69
|
+
*/
|
|
70
|
+
const mapFilesToEntries = useCallback(
|
|
71
|
+
(files: File[]): InputFileEntry[] =>
|
|
72
|
+
// 원본 File 배열을 내부 엔트리(id + file) 배열로 정규화한다.
|
|
73
|
+
files.map(file => ({
|
|
74
|
+
// 각 파일에 고유 id를 부여한다.
|
|
75
|
+
id: createEntryId(),
|
|
76
|
+
// 원본 파일 객체를 그대로 보관한다.
|
|
77
|
+
file,
|
|
78
|
+
})),
|
|
79
|
+
[createEntryId],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Input Hook; File Upload 엔트리 상태
|
|
84
|
+
*/
|
|
85
|
+
const [entries, setEntries] = useState<InputFileEntry[]>(() =>
|
|
86
|
+
// 초기 렌더에서 initialFiles를 내부 엔트리 형식으로 변환한다.
|
|
87
|
+
mapFilesToEntries(initialFiles ?? []),
|
|
88
|
+
);
|
|
89
|
+
/**
|
|
90
|
+
* Input Hook; File Upload initialFiles 시그니처 비교 ref
|
|
91
|
+
*/
|
|
92
|
+
const initialFilesSignatureRef = useRef("");
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Input Hook; File Upload 파일 전체 교체 핸들러
|
|
96
|
+
* @param {File[]} files 교체할 File 배열
|
|
97
|
+
*/
|
|
98
|
+
const onReplaceFiles = useCallback(
|
|
99
|
+
(files: File[]) => {
|
|
100
|
+
// 전달받은 파일 배열을 내부 엔트리 배열로 변환 후 전체 교체한다.
|
|
101
|
+
setEntries(mapFilesToEntries(files));
|
|
102
|
+
},
|
|
103
|
+
[mapFilesToEntries],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Input Hook; File Upload 파일 추가 핸들러
|
|
108
|
+
* @param {File[]} files 추가할 File 배열
|
|
109
|
+
*/
|
|
110
|
+
const onAppendFiles = useCallback(
|
|
111
|
+
(files: File[]) => {
|
|
112
|
+
// 추가할 파일이 없으면 상태 변경을 생략한다.
|
|
113
|
+
if (files.length === 0) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// 기존 엔트리 뒤에 신규 엔트리를 이어 붙인다.
|
|
117
|
+
setEntries(prevEntries => [...prevEntries, ...mapFilesToEntries(files)]);
|
|
118
|
+
},
|
|
119
|
+
[mapFilesToEntries],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Input Hook; File Upload useInputFile 연결 동기화 핸들러
|
|
124
|
+
* @param {File[]} files 입력된 File 배열
|
|
125
|
+
* @param {InputFileSource} _source 입력 소스(input | drop)
|
|
126
|
+
* @param {InputFileEvent} _event 원본 이벤트 객체
|
|
127
|
+
*/
|
|
128
|
+
const onSyncFromInputFile = useCallback(
|
|
129
|
+
(files: File[], _source: InputFileSource, _event: InputFileEvent) => {
|
|
130
|
+
// source/event 인자는 확장 포인트를 위해 시그니처에 유지한다.
|
|
131
|
+
void _source;
|
|
132
|
+
void _event;
|
|
133
|
+
// UI 전략에 따라 append/replace를 선택할 수 있도록 mergeMode를 분리한다.
|
|
134
|
+
if (mergeMode === "replace") {
|
|
135
|
+
// replace 모드면 기존 파일을 모두 교체한다.
|
|
136
|
+
onReplaceFiles(files);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// append 모드면 기존 파일 뒤에 신규 파일을 추가한다.
|
|
140
|
+
onAppendFiles(files);
|
|
141
|
+
},
|
|
142
|
+
[mergeMode, onAppendFiles, onReplaceFiles],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Input Hook; File Upload id 기준 파일 제거 핸들러
|
|
147
|
+
* @param {string} id 제거할 파일 엔트리 id
|
|
148
|
+
*/
|
|
149
|
+
const onRemoveFileById = useCallback((id: string) => {
|
|
150
|
+
// 전달받은 id와 일치하지 않는 엔트리만 남겨 제거를 수행한다.
|
|
151
|
+
setEntries(prevEntries =>
|
|
152
|
+
prevEntries.filter(entry => {
|
|
153
|
+
return entry.id !== id;
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Input Hook; File Upload index 기준 파일 제거 핸들러
|
|
160
|
+
* @param {number} index 제거할 파일 인덱스
|
|
161
|
+
*/
|
|
162
|
+
const onRemoveFileByIndex = useCallback((index: number) => {
|
|
163
|
+
setEntries(prevEntries => {
|
|
164
|
+
// 범위를 벗어난 index면 기존 배열을 그대로 반환한다.
|
|
165
|
+
if (index < 0 || index >= prevEntries.length) {
|
|
166
|
+
return prevEntries;
|
|
167
|
+
}
|
|
168
|
+
// 지정된 index를 제외한 엔트리만 유지한다.
|
|
169
|
+
return prevEntries.filter((_, itemIndex) => itemIndex !== index);
|
|
170
|
+
});
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Input Hook; File Upload 파일 전체 초기화 핸들러
|
|
175
|
+
*/
|
|
176
|
+
const onClearFiles = useCallback(() => {
|
|
177
|
+
// 모든 파일 엔트리를 비워 컨텍스트를 초기화한다.
|
|
178
|
+
setEntries([]);
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Input Hook; File Upload initialFiles 변경 동기화
|
|
183
|
+
*/
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
// undefined를 방지하기 위해 기본 빈 배열을 사용한다.
|
|
186
|
+
const files = initialFiles ?? [];
|
|
187
|
+
// 파일 목록 내용을 비교하기 위한 시그니처 문자열을 생성한다.
|
|
188
|
+
const currentSignature = files
|
|
189
|
+
.map(
|
|
190
|
+
file => `${file.name}-${file.size}-${file.lastModified}-${file.type}`,
|
|
191
|
+
)
|
|
192
|
+
.join("|");
|
|
193
|
+
// 시그니처가 이전과 같으면 동기화를 생략한다.
|
|
194
|
+
if (initialFilesSignatureRef.current === currentSignature) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// 최신 시그니처를 저장한다.
|
|
198
|
+
initialFilesSignatureRef.current = currentSignature;
|
|
199
|
+
// 외부 initialFiles 변경을 내부 상태로 동기화한다.
|
|
200
|
+
onReplaceFiles(files);
|
|
201
|
+
}, [initialFiles, onReplaceFiles]);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Input Hook; File Upload 원본 파일 배열 메모값
|
|
205
|
+
*/
|
|
206
|
+
const files = useMemo(() => entries.map(entry => entry.file), [entries]);
|
|
207
|
+
/**
|
|
208
|
+
* Input Hook; File Upload 파일 메타데이터 배열 메모값
|
|
209
|
+
*/
|
|
210
|
+
const fileMetas = useMemo(
|
|
211
|
+
// entries를 화면 렌더링/표시에 유리한 메타데이터로 변환한다.
|
|
212
|
+
() => entries.map(entry => createInputFileMeta(entry)),
|
|
213
|
+
[entries],
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Input Hook; File Upload 제출용 FormData 직렬화
|
|
218
|
+
* @param {InputFileFormDataOptions} [options] FormData 직렬화 옵션
|
|
219
|
+
*/
|
|
220
|
+
const toFormData = useCallback(
|
|
221
|
+
(options?: InputFileFormDataOptions) => {
|
|
222
|
+
// 현재 컨텍스트 파일 목록을 API 제출용 FormData로 직렬화한다.
|
|
223
|
+
return createInputFileFormData(files, options);
|
|
224
|
+
},
|
|
225
|
+
[files],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// 상태/조작/동기화/직렬화 API를 하나의 객체로 반환한다.
|
|
229
|
+
return {
|
|
230
|
+
entries,
|
|
231
|
+
files,
|
|
232
|
+
fileMetas,
|
|
233
|
+
hasFiles: entries.length > 0,
|
|
234
|
+
fileCount: entries.length,
|
|
235
|
+
onSyncFromInputFile,
|
|
236
|
+
onReplaceFiles,
|
|
237
|
+
onAppendFiles,
|
|
238
|
+
onRemoveFileById,
|
|
239
|
+
onRemoveFileByIndex,
|
|
240
|
+
onClearFiles,
|
|
241
|
+
// Input Hook; 소비처에서 즉시 submit payload를 만들 수 있도록 직렬화 헬퍼를 노출한다.
|
|
242
|
+
toFormData,
|
|
243
|
+
};
|
|
244
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import type { ChangeEvent, FocusEvent } from "react";
|
|
5
|
+
import type { UseInputFileRHFOptions, UseInputFileRHFResult } from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Input Hook; File Upload RHF 통합 Hook
|
|
9
|
+
* @hook
|
|
10
|
+
* @param {UseInputFileRHFOptions} options RHF 통합 옵션
|
|
11
|
+
* @param {(event: ChangeEvent<HTMLInputElement>) => void} options.onInputFileChange 파일 입력 핸들러
|
|
12
|
+
* @param {RefObject<HTMLInputElement | null>} [options.inputRef] file input ref
|
|
13
|
+
* @param {UseFormRegisterReturn} [options.register] react-hook-form register 반환값
|
|
14
|
+
* @returns {UseInputFileRHFResult} file input bind props
|
|
15
|
+
* @desc
|
|
16
|
+
* - return.inputBindProps: { name, ref, onChange, onBlur }
|
|
17
|
+
* - return.inputBindProps.ref: inputRef와 RHF register.ref를 병합한다.
|
|
18
|
+
* - return.inputBindProps.onChange: RHF onChange 후 file handler를 순차 실행한다.
|
|
19
|
+
* - return.inputBindProps.onBlur: RHF onBlur를 전달한다(register 사용 시에만 제공).
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* // 1) Input.File 컨텍스트/핸들러 준비
|
|
23
|
+
* const fileContext = useInputFileContext();
|
|
24
|
+
* const fileUpload = useInputFile({
|
|
25
|
+
* onFilesChange: fileContext.onSyncFromInputFile,
|
|
26
|
+
* });
|
|
27
|
+
*
|
|
28
|
+
* // 2) RHF register와 file input 핸들러 병합
|
|
29
|
+
* const { inputBindProps } = useInputFileRHF({
|
|
30
|
+
* register: register("form.attach_files"),
|
|
31
|
+
* inputRef,
|
|
32
|
+
* onInputFileChange: fileUpload.onInputFileChange,
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* // 3) hidden input에 bind props 연결
|
|
36
|
+
* <input {...inputBindProps} type="file" hidden multiple />
|
|
37
|
+
*
|
|
38
|
+
* // 4) submit 시 payload 직렬화
|
|
39
|
+
* const payload = fileContext.toFormData({ fieldName: "attach_files" });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export const useInputFileRHF = ({
|
|
43
|
+
onInputFileChange,
|
|
44
|
+
inputRef,
|
|
45
|
+
register,
|
|
46
|
+
}: UseInputFileRHFOptions): UseInputFileRHFResult => {
|
|
47
|
+
/**
|
|
48
|
+
* Input Hook; File Upload RHF ref 병합 핸들러
|
|
49
|
+
* @param {HTMLInputElement | null} node file input DOM 노드
|
|
50
|
+
*/
|
|
51
|
+
const handleRef = useCallback(
|
|
52
|
+
(node: HTMLInputElement | null) => {
|
|
53
|
+
// 소비자가 전달한 inputRef가 있으면 동일한 DOM 노드를 동기화한다.
|
|
54
|
+
if (inputRef) {
|
|
55
|
+
inputRef.current = node;
|
|
56
|
+
}
|
|
57
|
+
// RHF register.ref에도 같은 노드를 전달해 폼 제어를 연결한다.
|
|
58
|
+
register?.ref(node);
|
|
59
|
+
},
|
|
60
|
+
[inputRef, register],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Input Hook; File Upload RHF onChange 병합 핸들러
|
|
65
|
+
* @param {ChangeEvent<HTMLInputElement>} event file input change 이벤트
|
|
66
|
+
*/
|
|
67
|
+
const handleChange = useCallback(
|
|
68
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
69
|
+
// RHF onChange를 먼저 호출해 폼 상태를 최신화한다.
|
|
70
|
+
register?.onChange(event);
|
|
71
|
+
// 이후 file upload 훅의 change 핸들러를 호출해 파일 로직을 수행한다.
|
|
72
|
+
onInputFileChange(event);
|
|
73
|
+
},
|
|
74
|
+
[onInputFileChange, register],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Input Hook; File Upload RHF onBlur 전달 핸들러
|
|
79
|
+
* @param {FocusEvent<HTMLInputElement>} event file input blur 이벤트
|
|
80
|
+
*/
|
|
81
|
+
const handleBlur = useCallback(
|
|
82
|
+
(event: FocusEvent<HTMLInputElement>) => {
|
|
83
|
+
// blur 이벤트는 RHF에 전달해 touched/validation 흐름을 유지한다.
|
|
84
|
+
register?.onBlur(event);
|
|
85
|
+
},
|
|
86
|
+
[register],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Input Hook; File Upload RHF input bind props 반환 객체
|
|
91
|
+
*/
|
|
92
|
+
// file input spread에 필요한 속성을 하나로 묶어 반환한다.
|
|
93
|
+
return {
|
|
94
|
+
inputBindProps: {
|
|
95
|
+
// RHF name을 그대로 노출한다(register가 없으면 undefined).
|
|
96
|
+
name: register?.name,
|
|
97
|
+
// ref 병합 핸들러
|
|
98
|
+
ref: handleRef,
|
|
99
|
+
// onChange 병합 핸들러
|
|
100
|
+
onChange: handleChange,
|
|
101
|
+
// register가 있을 때만 onBlur를 주입한다.
|
|
102
|
+
onBlur: register ? handleBlur : undefined,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
};
|