@ncds/ui-admin 1.8.15 → 1.8.17

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 (42) hide show
  1. package/dist/cjs/src/components/forms-and-input/file-input/FileInput.js +25 -7
  2. package/dist/cjs/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +168 -0
  3. package/dist/cjs/src/components/forms-and-input/image-file-input/ImageFileInput.js +76 -19
  4. package/dist/cjs/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +533 -0
  5. package/dist/cjs/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
  6. package/dist/cjs/src/hooks/__tests__/useObjectUrl.test.js +120 -0
  7. package/dist/cjs/src/hooks/index.js +11 -0
  8. package/dist/cjs/src/hooks/useObjectUrl.js +39 -0
  9. package/dist/esm/src/components/forms-and-input/file-input/FileInput.js +25 -7
  10. package/dist/esm/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +165 -0
  11. package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +71 -19
  12. package/dist/esm/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +530 -0
  13. package/dist/esm/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
  14. package/dist/esm/src/hooks/__tests__/useObjectUrl.test.js +117 -0
  15. package/dist/esm/src/hooks/index.js +2 -1
  16. package/dist/esm/src/hooks/useObjectUrl.js +32 -0
  17. package/dist/temp/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
  18. package/dist/temp/src/components/forms-and-input/file-input/FileInput.js +16 -3
  19. package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
  20. package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +139 -0
  21. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
  22. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +63 -18
  23. package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
  24. package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +435 -0
  25. package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
  26. package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.js +7 -2
  27. package/dist/temp/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
  28. package/dist/temp/src/hooks/__tests__/useObjectUrl.test.js +96 -0
  29. package/dist/temp/src/hooks/index.d.ts +1 -0
  30. package/dist/temp/src/hooks/index.js +1 -0
  31. package/dist/temp/src/hooks/useObjectUrl.d.ts +16 -0
  32. package/dist/temp/src/hooks/useObjectUrl.js +32 -0
  33. package/dist/types/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
  34. package/dist/types/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
  35. package/dist/types/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
  36. package/dist/types/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
  37. package/dist/types/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
  38. package/dist/types/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
  39. package/dist/types/src/hooks/index.d.ts +1 -0
  40. package/dist/types/src/hooks/useObjectUrl.d.ts +16 -0
  41. package/dist/ui-admin/assets/styles/style.css +21 -5
  42. package/package.json +1 -1
@@ -0,0 +1,435 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { ImageFileInput, ImageFileInputErrorType } from '../ImageFileInput';
7
+ const mounted = [];
8
+ function mount(props) {
9
+ const container = document.createElement('div');
10
+ document.body.appendChild(container);
11
+ const root = createRoot(container);
12
+ act(() => {
13
+ root.render(createElement(ImageFileInput, props));
14
+ });
15
+ mounted.push({ root, container });
16
+ return container;
17
+ }
18
+ const previewImages = (container) => Array.from(container.querySelectorAll('.ncua-image-file-input__preview-image'));
19
+ const emptySlot = (container) => container.querySelector('.ncua-image-file-input__empty-slot-wrapper');
20
+ const makeFile = (name, size = 10) => new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
21
+ /**
22
+ * 숨겨진 file input에 파일을 실어 change 이벤트를 발생시킨다.
23
+ * jsdom에는 파일 선택 UI가 없어 files 프로퍼티를 직접 정의해야 한다.
24
+ */
25
+ function selectFiles(container, filesToSelect) {
26
+ const input = container.querySelector('input[type="file"]');
27
+ if (!input)
28
+ throw new Error('file input을 찾지 못했습니다');
29
+ Object.defineProperty(input, 'files', {
30
+ configurable: true,
31
+ value: {
32
+ ...filesToSelect,
33
+ length: filesToSelect.length,
34
+ item: (index) => filesToSelect[index] ?? null,
35
+ [Symbol.iterator]: function* () {
36
+ yield* filesToSelect;
37
+ },
38
+ },
39
+ });
40
+ act(() => {
41
+ input.dispatchEvent(new Event('change', { bubbles: true }));
42
+ });
43
+ }
44
+ afterEach(() => {
45
+ for (const { root, container } of mounted.splice(0)) {
46
+ act(() => {
47
+ root.unmount();
48
+ });
49
+ container.remove();
50
+ }
51
+ vi.restoreAllMocks();
52
+ });
53
+ describe('ImageFileInput — 저장된 이미지 URL 미리보기', () => {
54
+ it('uploadedFiles의 URL을 img src로 그대로 렌더한다 (fetch 없이)', () => {
55
+ const fetchSpy = vi.spyOn(globalThis, 'fetch');
56
+ const container = mount({
57
+ uploadedFiles: [{ fileName: 'saved.jpg', fileImageUrl: 'https://external.example.com/saved.jpg' }],
58
+ });
59
+ const images = previewImages(container);
60
+ expect(images).toHaveLength(1);
61
+ expect(images[0].getAttribute('src')).toBe('https://external.example.com/saved.jpg');
62
+ expect(fetchSpy).not.toHaveBeenCalled();
63
+ });
64
+ it('fileName을 img alt로 사용한다', () => {
65
+ const container = mount({
66
+ uploadedFiles: [{ fileName: '상품이미지.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
67
+ });
68
+ expect(previewImages(container)[0].getAttribute('alt')).toBe('상품이미지.jpg');
69
+ });
70
+ it('여러 장을 전달한 순서대로 렌더한다', () => {
71
+ const container = mount({
72
+ maxFileCount: 5,
73
+ uploadedFiles: [
74
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
75
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
76
+ ],
77
+ });
78
+ expect(previewImages(container).map((img) => img.getAttribute('src'))).toEqual([
79
+ 'https://external.example.com/a.jpg',
80
+ 'https://external.example.com/b.jpg',
81
+ ]);
82
+ });
83
+ it('저장된 이미지를 삭제하면 남은 목록으로 onUploadedFilesChange를 호출한다', () => {
84
+ const onUploadedFilesChange = vi.fn();
85
+ const container = mount({
86
+ maxFileCount: 5,
87
+ uploadedFiles: [
88
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
89
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
90
+ ],
91
+ onUploadedFilesChange,
92
+ });
93
+ const removeButtons = container.querySelectorAll('.ncua-image-file-input__preview-remove-button');
94
+ expect(removeButtons).toHaveLength(2);
95
+ act(() => {
96
+ removeButtons[0].click();
97
+ });
98
+ expect(onUploadedFilesChange).toHaveBeenCalledTimes(1);
99
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([
100
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
101
+ ]);
102
+ });
103
+ // uploadedFiles는 value/onChange와 같은 controlled prop이다.
104
+ // 콜백을 넘기지 않으면 미리보기가 그대로 유지되는 것이 의도된 동작이며,
105
+ // 이 테스트는 "무반응이어도 괜찮다"가 아니라 그 계약을 고정하기 위한 것이다.
106
+ it('콜백 없이 uploadedFiles만 넘기면 삭제해도 미리보기가 유지된다 (controlled 계약)', () => {
107
+ const container = mount({
108
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
109
+ });
110
+ const removeButton = container.querySelector('.ncua-image-file-input__preview-remove-button');
111
+ act(() => {
112
+ removeButton?.click();
113
+ });
114
+ expect(previewImages(container)).toHaveLength(1);
115
+ });
116
+ it('같은 URL에 이름만 다른 항목이 있어도 key가 겹치지 않는다', () => {
117
+ // React 는 key 가 겹쳐도 DOM 에는 그려주고 console.error 로만 경고한다.
118
+ // 따라서 렌더 결과가 아니라 경고 발생 여부로 판정해야 한다.
119
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
120
+ const sharedUrl = 'https://external.example.com/shared.jpg';
121
+ const container = mount({
122
+ maxFileCount: 5,
123
+ uploadedFiles: [
124
+ { fileName: 'first.jpg', fileImageUrl: sharedUrl },
125
+ { fileName: 'second.jpg', fileImageUrl: sharedUrl },
126
+ ],
127
+ });
128
+ expect(previewImages(container).map((img) => img.getAttribute('alt'))).toEqual(['first.jpg', 'second.jpg']);
129
+ expect(consoleError.mock.calls.flat(Number.POSITIVE_INFINITY).join(' ')).not.toContain('same key');
130
+ });
131
+ it('URL과 파일명이 모두 같은 항목이 둘이어도 key가 겹치지 않는다', () => {
132
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
133
+ const same = { fileName: 'same.jpg', fileImageUrl: 'https://external.example.com/same.jpg' };
134
+ const container = mount({ maxFileCount: 5, uploadedFiles: [same, { ...same }] });
135
+ expect(previewImages(container)).toHaveLength(2);
136
+ expect(consoleError.mock.calls.flat(Number.POSITIVE_INFINITY).join(' ')).not.toContain('same key');
137
+ });
138
+ it('같은 파일명이 여러 개여도 클릭한 항목만 정확히 제거한다 (순번 아닌 항목 기준 삭제)', () => {
139
+ const onUploadedFilesChange = vi.fn();
140
+ const duplicated = [
141
+ { fileName: '같은이름.jpg', fileImageUrl: 'https://external.example.com/first.jpg' },
142
+ { fileName: '같은이름.jpg', fileImageUrl: 'https://external.example.com/second.jpg' },
143
+ ];
144
+ const container = mount({ maxFileCount: 5, uploadedFiles: duplicated, onUploadedFilesChange });
145
+ act(() => {
146
+ container.querySelectorAll('.ncua-image-file-input__preview-remove-button')[1].click();
147
+ });
148
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([duplicated[0]]);
149
+ });
150
+ });
151
+ describe('ImageFileInput — URL 없는 항목 방어 (CDN/바닐라 등 타입 검사 밖 사용)', () => {
152
+ it('fileImageUrl이 비어 있으면 렌더하지 않는다', () => {
153
+ const container = mount({
154
+ maxFileCount: 3,
155
+ uploadedFiles: [
156
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
157
+ { fileName: 'broken.jpg', fileImageUrl: '' },
158
+ ],
159
+ });
160
+ expect(previewImages(container).map((img) => img.getAttribute('src'))).toEqual([
161
+ 'https://external.example.com/a.jpg',
162
+ ]);
163
+ });
164
+ it('fileImageUrl이 비어 있는 항목은 슬롯을 차지하지 않는다', () => {
165
+ const container = mount({
166
+ maxFileCount: 2,
167
+ uploadedFiles: [
168
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
169
+ { fileName: 'broken.jpg', fileImageUrl: '' },
170
+ ],
171
+ });
172
+ // 유효한 1장 < maxFileCount 2 → 빈 슬롯이 남아야 한다
173
+ expect(emptySlot(container)).not.toBeNull();
174
+ });
175
+ it('삭제 시 렌더되지 않은 항목도 호출자 목록에 그대로 남긴다', () => {
176
+ const onUploadedFilesChange = vi.fn();
177
+ const broken = { fileName: 'broken.jpg', fileImageUrl: '' };
178
+ const container = mount({
179
+ maxFileCount: 3,
180
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }, broken],
181
+ onUploadedFilesChange,
182
+ });
183
+ act(() => {
184
+ container.querySelector('.ncua-image-file-input__preview-remove-button')?.click();
185
+ });
186
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([broken]);
187
+ });
188
+ });
189
+ describe('ImageFileInput — object URL 해제', () => {
190
+ /** 리렌더를 반복해도 URL이 늘지 않는지 보기 위한 횟수 */
191
+ const REPEATED_RENDERS = 3;
192
+ const originalCreateObjectURL = URL.createObjectURL;
193
+ const originalRevokeObjectURL = URL.revokeObjectURL;
194
+ let createObjectURL;
195
+ let revokeObjectURL;
196
+ beforeEach(() => {
197
+ let issued = 0;
198
+ createObjectURL = vi.fn(() => `blob:mock/${++issued}`);
199
+ revokeObjectURL = vi.fn();
200
+ URL.createObjectURL = createObjectURL;
201
+ URL.revokeObjectURL = revokeObjectURL;
202
+ });
203
+ afterEach(() => {
204
+ URL.createObjectURL = originalCreateObjectURL;
205
+ URL.revokeObjectURL = originalRevokeObjectURL;
206
+ });
207
+ it('리렌더를 반복해도 파일당 object URL을 한 번만 만든다', () => {
208
+ const file = makeFile('a.jpg');
209
+ const container = document.createElement('div');
210
+ document.body.appendChild(container);
211
+ const root = createRoot(container);
212
+ for (let renderCount = 0; renderCount < REPEATED_RENDERS; renderCount++) {
213
+ act(() => {
214
+ root.render(createElement(ImageFileInput, { value: [file], onChange: () => undefined }));
215
+ });
216
+ }
217
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
218
+ act(() => {
219
+ root.unmount();
220
+ });
221
+ container.remove();
222
+ });
223
+ it('언마운트하면 만들었던 object URL을 해제한다', () => {
224
+ const container = mount({ value: [makeFile('a.jpg')], onChange: () => undefined });
225
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
226
+ for (const entry of mounted.splice(0)) {
227
+ act(() => {
228
+ entry.root.unmount();
229
+ });
230
+ entry.container.remove();
231
+ }
232
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
233
+ expect(container.isConnected).toBe(false);
234
+ });
235
+ });
236
+ describe('ImageFileInput — 파일 선택 시 저장된 이미지 반영', () => {
237
+ it('저장된 이미지를 포함해 maxFileCount를 초과하면 EXCEED_MAX_FILE_COUNT로 거른다', () => {
238
+ const onFail = vi.fn();
239
+ const onChange = vi.fn();
240
+ const container = mount({
241
+ maxFileCount: 2,
242
+ value: [],
243
+ onChange,
244
+ onFail,
245
+ uploadedFiles: [
246
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
247
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
248
+ ],
249
+ });
250
+ selectFiles(container, [makeFile('new.jpg')]);
251
+ expect(onFail).toHaveBeenCalledTimes(1);
252
+ expect(onFail.mock.calls[0][0][0].errorType).toBe(ImageFileInputErrorType.EXCEED_MAX_FILE_COUNT);
253
+ // 전량 반려이므로 목록을 건드리지 않는다
254
+ expect(onChange).not.toHaveBeenCalled();
255
+ });
256
+ it('저장된 이미지와 파일명이 같으면 ALREADY_UPLOADED로 거른다', () => {
257
+ const onFail = vi.fn();
258
+ const container = mount({
259
+ maxFileCount: 5,
260
+ value: [],
261
+ onChange: () => undefined,
262
+ onFail,
263
+ uploadedFiles: [{ fileName: 'same.jpg', fileImageUrl: 'https://external.example.com/same.jpg' }],
264
+ });
265
+ selectFiles(container, [makeFile('same.jpg')]);
266
+ expect(onFail).toHaveBeenCalledTimes(1);
267
+ expect(onFail.mock.calls[0][0][0].errorType).toBe(ImageFileInputErrorType.ALREADY_UPLOADED);
268
+ });
269
+ it('maxFileCount가 1이면 새 파일 선택 시 저장된 이미지도 비운다', () => {
270
+ const onUploadedFilesChange = vi.fn();
271
+ const onChange = vi.fn();
272
+ const container = mount({
273
+ maxFileCount: 1,
274
+ value: [],
275
+ onChange,
276
+ uploadedFiles: [{ fileName: 'old.jpg', fileImageUrl: 'https://external.example.com/old.jpg' }],
277
+ onUploadedFilesChange,
278
+ });
279
+ const picked = makeFile('new.jpg');
280
+ selectFiles(container, [picked]);
281
+ expect(onChange).toHaveBeenCalledWith([picked]);
282
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([]);
283
+ });
284
+ it('maxFileCount가 1이면 저장된 이미지와 이름이 같아도 교체를 허용한다', () => {
285
+ const onFail = vi.fn();
286
+ const onChange = vi.fn();
287
+ const onUploadedFilesChange = vi.fn();
288
+ const container = mount({
289
+ maxFileCount: 1,
290
+ value: [],
291
+ onChange,
292
+ onFail,
293
+ uploadedFiles: [{ fileName: 'photo.jpg', fileImageUrl: 'https://external.example.com/photo.jpg' }],
294
+ onUploadedFilesChange,
295
+ });
296
+ const picked = makeFile('photo.jpg');
297
+ selectFiles(container, [picked]);
298
+ expect(onFail).not.toHaveBeenCalled();
299
+ expect(onChange).toHaveBeenCalledWith([picked]);
300
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([]);
301
+ });
302
+ it('한 장짜리 교체 시 렌더되지 않은 항목은 호출자 목록에 남긴다', () => {
303
+ const onUploadedFilesChange = vi.fn();
304
+ const broken = { fileName: 'broken.jpg', fileImageUrl: '' };
305
+ const container = mount({
306
+ maxFileCount: 1,
307
+ value: [],
308
+ onChange: () => undefined,
309
+ uploadedFiles: [{ fileName: 'old.jpg', fileImageUrl: 'https://external.example.com/old.jpg' }, broken],
310
+ onUploadedFilesChange,
311
+ });
312
+ selectFiles(container, [makeFile('new.jpg')]);
313
+ expect(onUploadedFilesChange).toHaveBeenCalledWith([broken]);
314
+ });
315
+ it('저장된 이미지가 없으면 maxFileCount 1이어도 불필요하게 콜백을 호출하지 않는다', () => {
316
+ const onUploadedFilesChange = vi.fn();
317
+ const container = mount({
318
+ maxFileCount: 1,
319
+ value: [],
320
+ onChange: () => undefined,
321
+ uploadedFiles: [],
322
+ onUploadedFilesChange,
323
+ });
324
+ selectFiles(container, [makeFile('new.jpg')]);
325
+ expect(onUploadedFilesChange).not.toHaveBeenCalled();
326
+ });
327
+ });
328
+ describe('ImageFileInput — disabled 읽기 전용', () => {
329
+ const removeButtons = (container) => container.querySelectorAll('.ncua-image-file-input__preview-remove-button');
330
+ it('disabled면 저장된 이미지의 삭제 버튼을 렌더하지 않는다', () => {
331
+ const container = mount({
332
+ disabled: true,
333
+ maxFileCount: 3,
334
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
335
+ });
336
+ expect(previewImages(container)).toHaveLength(1); // 썸네일은 보인다
337
+ expect(removeButtons(container)).toHaveLength(0); // 삭제 아이콘은 미노출
338
+ });
339
+ it('disabled면 새로 고른 파일의 삭제 버튼도 렌더하지 않는다', () => {
340
+ const container = mount({
341
+ disabled: true,
342
+ maxFileCount: 3,
343
+ value: [makeFile('new.jpg')],
344
+ onChange: () => undefined,
345
+ });
346
+ expect(removeButtons(container)).toHaveLength(0);
347
+ });
348
+ it('disabled가 아니면 삭제 버튼을 렌더한다', () => {
349
+ const container = mount({
350
+ maxFileCount: 3,
351
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
352
+ });
353
+ expect(removeButtons(container)).toHaveLength(1);
354
+ });
355
+ });
356
+ describe('ImageFileInput — 전량 반려 시 기존 선택 보존', () => {
357
+ it('maxFileCount가 1일 때 모두 반려되면 이미 고른 파일을 유지한다', () => {
358
+ const already = makeFile('picked.jpg');
359
+ const onChange = vi.fn();
360
+ const onFail = vi.fn();
361
+ const container = mount({
362
+ maxFileCount: 1,
363
+ value: [already],
364
+ onChange,
365
+ onFail,
366
+ });
367
+ // 같은 파일을 다시 고르면 ALREADY_UPLOADED 로 전량 반려된다
368
+ selectFiles(container, [makeFile('picked.jpg')]);
369
+ expect(onFail).toHaveBeenCalledTimes(1);
370
+ expect(onFail.mock.calls[0][0][0].errorType).toBe(ImageFileInputErrorType.ALREADY_UPLOADED);
371
+ expect(onChange).not.toHaveBeenCalled(); // 빈 배열로 덮어쓰지 않는다
372
+ });
373
+ it('전량 반려되면 저장된 이미지도 비우지 않는다', () => {
374
+ const saved = { fileName: 'saved.jpg', fileImageUrl: 'https://external.example.com/saved.jpg' };
375
+ const onUploadedFilesChange = vi.fn();
376
+ const container = mount({
377
+ maxFileCount: 3,
378
+ value: [makeFile('picked.jpg')],
379
+ onChange: () => undefined,
380
+ uploadedFiles: [saved],
381
+ onUploadedFilesChange,
382
+ onFail: () => undefined,
383
+ });
384
+ selectFiles(container, [makeFile('picked.jpg')]);
385
+ expect(onUploadedFilesChange).not.toHaveBeenCalled();
386
+ });
387
+ it('일부만 통과하면 통과분만 반영한다', () => {
388
+ const already = makeFile('dup.jpg');
389
+ const onChange = vi.fn();
390
+ const container = mount({
391
+ maxFileCount: 5,
392
+ value: [already],
393
+ onChange,
394
+ onFail: () => undefined,
395
+ });
396
+ const fresh = makeFile('fresh.jpg');
397
+ selectFiles(container, [makeFile('dup.jpg'), fresh]);
398
+ expect(onChange).toHaveBeenCalledWith([already, fresh]);
399
+ });
400
+ });
401
+ describe('ImageFileInput — 저장된 이미지의 슬롯 수 합산', () => {
402
+ it('uploadedFiles가 maxFileCount를 채우면 빈 슬롯을 감춘다', () => {
403
+ const container = mount({
404
+ maxFileCount: 2,
405
+ uploadedFiles: [
406
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
407
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
408
+ ],
409
+ });
410
+ expect(emptySlot(container)).toBeNull();
411
+ });
412
+ it('maxFileCount에 여유가 있으면 빈 슬롯을 노출한다', () => {
413
+ const container = mount({
414
+ maxFileCount: 3,
415
+ uploadedFiles: [
416
+ { fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' },
417
+ { fileName: 'b.jpg', fileImageUrl: 'https://external.example.com/b.jpg' },
418
+ ],
419
+ });
420
+ expect(emptySlot(container)).not.toBeNull();
421
+ });
422
+ it('maxFileCount가 없을 때 저장된 이미지가 있으면 빈 슬롯을 감춘다', () => {
423
+ const container = mount({
424
+ uploadedFiles: [{ fileName: 'a.jpg', fileImageUrl: 'https://external.example.com/a.jpg' }],
425
+ });
426
+ expect(emptySlot(container)).toBeNull();
427
+ });
428
+ });
429
+ describe('ImageFileInput — 기존 File 동작 회귀', () => {
430
+ it('uploadedFiles를 넘기지 않으면 빈 슬롯만 렌더한다', () => {
431
+ const container = mount({});
432
+ expect(previewImages(container)).toHaveLength(0);
433
+ expect(emptySlot(container)).not.toBeNull();
434
+ });
435
+ });
@@ -1,5 +1,23 @@
1
1
  export interface ImagePreviewProps {
2
- file: File;
2
+ /**
3
+ * Newly selected file. Rendered via object URL.
4
+ * Either this or `imageUrl` must be provided.
5
+ */
6
+ file?: File;
7
+ /**
8
+ * URL of an image already stored on the server.
9
+ * Rendered directly, so no fetch or CORS handling is required.
10
+ */
11
+ imageUrl?: string;
12
+ /**
13
+ * Alternative text. Falls back to the file name when `file` is given.
14
+ */
15
+ alt?: string;
16
+ /**
17
+ * Read-only mode. The thumbnail stays visible but the remove button is not
18
+ * rendered, per DES-SPEC-013 §5.3 (Disabled — 삭제 아이콘 미노출).
19
+ */
20
+ disabled?: boolean;
3
21
  onRemove: () => void;
4
22
  }
5
- export declare const ImagePreview: ({ file, onRemove }: ImagePreviewProps) => import("react/jsx-runtime").JSX.Element;
23
+ export declare const ImagePreview: ({ file, imageUrl, alt, disabled, onRemove }: ImagePreviewProps) => import("react/jsx-runtime").JSX.Element | null;
@@ -1,6 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Trash03 } from '@ncds/ui-admin-icon';
3
+ import { useObjectUrl } from '../../../../hooks/useObjectUrl';
3
4
  import { Button } from '../../../action/button';
4
- export const ImagePreview = ({ file, onRemove }) => {
5
- return (_jsxs("div", { className: "ncua-image-file-input__preview-container", children: [_jsx("img", { className: "ncua-image-file-input__preview-image", src: URL.createObjectURL(file), alt: file.name }), _jsx(Button, { size: "sm", hierarchy: "text", onlyIcon: true, leadingIcon: { type: 'icon', icon: Trash03, color: 'gray200' }, label: "Remove image", onClick: onRemove, className: "ncua-image-file-input__preview-remove-button" })] }));
5
+ export const ImagePreview = ({ file, imageUrl, alt, disabled, onRemove }) => {
6
+ const objectUrl = useObjectUrl(file);
7
+ const src = file ? objectUrl : imageUrl;
8
+ if (!src)
9
+ return null;
10
+ return (_jsxs("div", { className: "ncua-image-file-input__preview-container", children: [_jsx("img", { className: "ncua-image-file-input__preview-image", src: src, alt: alt ?? file?.name ?? '' }), !disabled && (_jsx(Button, { size: "sm", hierarchy: "text", onlyIcon: true, leadingIcon: { type: 'icon', icon: Trash03, color: 'gray200' }, label: "Remove image", onClick: onRemove, className: "ncua-image-file-input__preview-remove-button" }))] }));
6
11
  };
@@ -0,0 +1,96 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { useObjectUrl } from '../useObjectUrl';
7
+ const originalCreateObjectURL = URL.createObjectURL;
8
+ const originalRevokeObjectURL = URL.revokeObjectURL;
9
+ let createObjectURL;
10
+ let revokeObjectURL;
11
+ beforeEach(() => {
12
+ let issued = 0;
13
+ createObjectURL = vi.fn(() => `blob:mock/${++issued}`);
14
+ revokeObjectURL = vi.fn();
15
+ URL.createObjectURL = createObjectURL;
16
+ URL.revokeObjectURL = revokeObjectURL;
17
+ });
18
+ const mounted = [];
19
+ afterEach(() => {
20
+ for (const { root, container } of mounted.splice(0)) {
21
+ act(() => {
22
+ root.unmount();
23
+ });
24
+ container.remove();
25
+ }
26
+ URL.createObjectURL = originalCreateObjectURL;
27
+ URL.revokeObjectURL = originalRevokeObjectURL;
28
+ });
29
+ /** 내용은 검증 대상이 아니므로 최소 크기만 채운다 */
30
+ const DUMMY_FILE_BYTES = 4;
31
+ const makeFile = (name) => new File([new Uint8Array(DUMMY_FILE_BYTES)], name, { type: 'image/jpeg' });
32
+ /** 훅 결과를 data-url 속성으로 노출하는 관찰용 컴포넌트 */
33
+ const Probe = ({ file }) => createElement('span', { 'data-url': useObjectUrl(file) ?? '' });
34
+ function renderProbe(file) {
35
+ const container = document.createElement('div');
36
+ document.body.appendChild(container);
37
+ const root = createRoot(container);
38
+ const render = (nextFile) => {
39
+ act(() => {
40
+ root.render(createElement(Probe, { file: nextFile }));
41
+ });
42
+ };
43
+ render(file);
44
+ mounted.push({ root, container });
45
+ const currentUrl = () => container.querySelector('span')?.getAttribute('data-url');
46
+ const unmount = () => {
47
+ act(() => {
48
+ root.unmount();
49
+ });
50
+ const index = mounted.findIndex((entry) => entry.root === root);
51
+ mounted.splice(index, 1);
52
+ container.remove();
53
+ };
54
+ return { render, currentUrl, unmount };
55
+ }
56
+ describe('useObjectUrl', () => {
57
+ it('파일을 주면 object URL을 만들어 반환한다', () => {
58
+ const probe = renderProbe(makeFile('a.jpg'));
59
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
60
+ expect(probe.currentUrl()).toBe('blob:mock/1');
61
+ });
62
+ it('같은 파일로 여러 번 리렌더해도 URL을 한 번만 만든다', () => {
63
+ const file = makeFile('a.jpg');
64
+ const probe = renderProbe(file);
65
+ probe.render(file);
66
+ probe.render(file);
67
+ probe.render(file);
68
+ expect(createObjectURL).toHaveBeenCalledTimes(1);
69
+ expect(revokeObjectURL).not.toHaveBeenCalled();
70
+ });
71
+ it('언마운트하면 만들었던 URL을 해제한다', () => {
72
+ const probe = renderProbe(makeFile('a.jpg'));
73
+ expect(revokeObjectURL).not.toHaveBeenCalled();
74
+ probe.unmount();
75
+ expect(revokeObjectURL).toHaveBeenCalledTimes(1);
76
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
77
+ });
78
+ it('파일이 바뀌면 이전 URL을 해제하고 새로 만든다', () => {
79
+ const probe = renderProbe(makeFile('a.jpg'));
80
+ probe.render(makeFile('b.jpg'));
81
+ expect(createObjectURL).toHaveBeenCalledTimes(2);
82
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
83
+ expect(probe.currentUrl()).toBe('blob:mock/2');
84
+ });
85
+ it('파일이 없으면 URL을 만들지 않는다', () => {
86
+ const probe = renderProbe(undefined);
87
+ expect(createObjectURL).not.toHaveBeenCalled();
88
+ expect(probe.currentUrl()).toBe('');
89
+ });
90
+ it('파일을 없애면 이전 URL을 해제한다', () => {
91
+ const probe = renderProbe(makeFile('a.jpg'));
92
+ probe.render(undefined);
93
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
94
+ expect(probe.currentUrl()).toBe('');
95
+ });
96
+ });
@@ -4,3 +4,4 @@ export * from './useFloatingPosition';
4
4
  export * from './useIsomorphicLayoutEffect';
5
5
  export * from './useMediaQuery';
6
6
  export * from './useMergeRefs';
7
+ export * from './useObjectUrl';
@@ -6,3 +6,4 @@ export * from './useFloatingPosition';
6
6
  export * from './useIsomorphicLayoutEffect';
7
7
  export * from './useMediaQuery';
8
8
  export * from './useMergeRefs';
9
+ export * from './useObjectUrl';
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Creates an object URL for a file and revokes it when the file changes
3
+ * or the component unmounts.
4
+ *
5
+ * Calling `URL.createObjectURL` inline while rendering mints a new blob URL on
6
+ * every re-render and never releases it, so the browser holds every one of them
7
+ * for the lifetime of the document. Creating it in an effect gives us a cleanup
8
+ * hook to revoke on.
9
+ *
10
+ * A layout effect is used so the URL is in place before the browser paints —
11
+ * with a plain effect the image would blink out for a frame whenever the
12
+ * component remounts (for example when a sibling is removed and keys shift).
13
+ *
14
+ * Returns `null` while no file is given.
15
+ */
16
+ export declare const useObjectUrl: (file?: File) => string | null;
@@ -0,0 +1,32 @@
1
+ import { useState } from 'react';
2
+ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
3
+ /**
4
+ * Creates an object URL for a file and revokes it when the file changes
5
+ * or the component unmounts.
6
+ *
7
+ * Calling `URL.createObjectURL` inline while rendering mints a new blob URL on
8
+ * every re-render and never releases it, so the browser holds every one of them
9
+ * for the lifetime of the document. Creating it in an effect gives us a cleanup
10
+ * hook to revoke on.
11
+ *
12
+ * A layout effect is used so the URL is in place before the browser paints —
13
+ * with a plain effect the image would blink out for a frame whenever the
14
+ * component remounts (for example when a sibling is removed and keys shift).
15
+ *
16
+ * Returns `null` while no file is given.
17
+ */
18
+ export const useObjectUrl = (file) => {
19
+ const [objectUrl, setObjectUrl] = useState(null);
20
+ useIsomorphicLayoutEffect(() => {
21
+ if (!file) {
22
+ setObjectUrl(null);
23
+ return;
24
+ }
25
+ const createdUrl = URL.createObjectURL(file);
26
+ setObjectUrl(createdUrl);
27
+ return () => {
28
+ URL.revokeObjectURL(createdUrl);
29
+ };
30
+ }, [file]);
31
+ return objectUrl;
32
+ };
@@ -57,5 +57,13 @@ export interface FileInputProps extends Omit<InputBaseProps, 'clearText' | 'onCl
57
57
  * Hint text to display
58
58
  */
59
59
  hintText?: string;
60
+ /**
61
+ * Whether to render the built-in file tag list
62
+ *
63
+ * Set to `false` when the file list is rendered outside the component —
64
+ * the selected files still arrive through `onChange` / `onFileSelect`, and
65
+ * file count/duplicate validation keeps working.
66
+ */
67
+ showFileTags?: boolean;
60
68
  }
61
69
  export declare const FileInput: import("react").ForwardRefExoticComponent<FileInputProps & import("react").RefAttributes<HTMLInputElement>>;