@ncds/ui-admin 1.8.16 → 1.8.18
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/cjs/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
- package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
- package/dist/cjs/src/components/forms-and-input/file-input/FileInput.js +25 -7
- package/dist/cjs/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +168 -0
- package/dist/cjs/src/components/forms-and-input/image-file-input/ImageFileInput.js +76 -19
- package/dist/cjs/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +533 -0
- package/dist/cjs/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
- package/dist/cjs/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/cjs/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
- package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +249 -0
- package/dist/cjs/src/hooks/__tests__/useObjectUrl.test.js +120 -0
- package/dist/cjs/src/hooks/index.js +11 -0
- package/dist/cjs/src/hooks/useObjectUrl.js +39 -0
- package/dist/cjs/vitest.config.js +23 -1
- package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
- package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
- package/dist/esm/src/components/forms-and-input/file-input/FileInput.js +25 -7
- package/dist/esm/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +165 -0
- package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +71 -19
- package/dist/esm/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +530 -0
- package/dist/esm/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
- package/dist/esm/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/esm/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
- package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +250 -1
- package/dist/esm/src/hooks/__tests__/useObjectUrl.test.js +117 -0
- package/dist/esm/src/hooks/index.js +2 -1
- package/dist/esm/src/hooks/useObjectUrl.js +32 -0
- package/dist/esm/vitest.config.js +22 -1
- package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +184 -36
- package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +323 -0
- package/dist/temp/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
- package/dist/temp/src/components/forms-and-input/file-input/FileInput.js +16 -3
- package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +139 -0
- package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
- package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +63 -18
- package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +435 -0
- package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
- package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.js +7 -2
- package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +40 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +190 -1
- package/dist/temp/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
- package/dist/temp/src/hooks/__tests__/useObjectUrl.test.js +96 -0
- package/dist/temp/src/hooks/index.d.ts +1 -0
- package/dist/temp/src/hooks/index.js +1 -0
- package/dist/temp/src/hooks/useObjectUrl.d.ts +16 -0
- package/dist/temp/src/hooks/useObjectUrl.js +32 -0
- package/dist/temp/vitest.config.js +24 -0
- package/dist/types/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
- package/dist/types/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
- package/dist/types/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
- package/dist/types/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
- package/dist/types/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
- package/dist/types/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
- package/dist/types/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
- package/dist/types/src/hooks/index.d.ts +1 -0
- package/dist/types/src/hooks/useObjectUrl.d.ts +16 -0
- package/dist/ui-admin/assets/styles/style.css +20 -4
- package/package.json +5 -3
|
@@ -2,16 +2,29 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { HelpCircle, Upload01 } from '@ncds/ui-admin-icon';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
4
|
import { forwardRef, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { useObjectUrl } from '../../../hooks/useObjectUrl';
|
|
5
6
|
import { Button } from '../../action/button';
|
|
6
7
|
import { Tag } from '../../feedback-and-status/tag/Tag';
|
|
7
8
|
import { HintText, Label } from '../../shared';
|
|
9
|
+
/**
|
|
10
|
+
* Thumbnail next to a file tag.
|
|
11
|
+
*
|
|
12
|
+
* Split out so the object URL can live in a hook — hooks cannot be called
|
|
13
|
+
* from inside the file list's map callback.
|
|
14
|
+
*/
|
|
15
|
+
const FileTagImage = ({ file }) => {
|
|
16
|
+
const objectUrl = useObjectUrl(file);
|
|
17
|
+
if (!objectUrl)
|
|
18
|
+
return null;
|
|
19
|
+
return _jsx("img", { className: "ncua-file-input__file-image", src: objectUrl, alt: file.name });
|
|
20
|
+
};
|
|
8
21
|
export var FileInputErrorType;
|
|
9
22
|
(function (FileInputErrorType) {
|
|
10
23
|
FileInputErrorType["ALREADY_UPLOADED"] = "ALREADY_UPLOADED";
|
|
11
24
|
FileInputErrorType["EXCEED_MAX_FILE_SIZE"] = "EXCEED_MAX_FILE_SIZE";
|
|
12
25
|
FileInputErrorType["EXCEED_MAX_FILE_COUNT"] = "EXCEED_MAX_FILE_COUNT";
|
|
13
26
|
})(FileInputErrorType || (FileInputErrorType = {}));
|
|
14
|
-
export const FileInput = forwardRef(({ size = 'xs', accept, multiple = false, maxFileSize, maxFileCount, value, onChange, onFileSelect, onFail, buttonLabel = '파일 찾기', disabled, label, hintItems, validation, destructive, isRequired, showHelpIcon, hintText, ...props }, _ref) => {
|
|
27
|
+
export const FileInput = forwardRef(({ size = 'xs', accept, multiple = false, maxFileSize, maxFileCount, value, onChange, onFileSelect, onFail, buttonLabel = '파일 찾기', disabled, label, hintItems, validation, destructive, isRequired, showHelpIcon, hintText, showFileTags = true, ...props }, _ref) => {
|
|
15
28
|
const fileInputRef = useRef(null);
|
|
16
29
|
const [internalFiles, setInternalFiles] = useState([]);
|
|
17
30
|
// Determine if component is controlled or uncontrolled
|
|
@@ -74,9 +87,9 @@ export const FileInput = forwardRef(({ size = 'xs', accept, multiple = false, ma
|
|
|
74
87
|
updateFiles(newFiles);
|
|
75
88
|
};
|
|
76
89
|
const renderFileTagList = () => {
|
|
77
|
-
if (files.length === 0)
|
|
90
|
+
if (!showFileTags || files.length === 0)
|
|
78
91
|
return null;
|
|
79
|
-
return (_jsx("div", { className: "ncua-file-input__file-tags", children: files.map((file, index) => (_jsxs("div", { className: "ncua-file-input__file-tag-container", children: [_jsx(Tag, { text: file.name, size: size === 'xs' ? 'sm' : 'md', close: true, onButtonClick: () => handleRemoveFile(index) }), file.type.startsWith('image/') &&
|
|
92
|
+
return (_jsx("div", { className: "ncua-file-input__file-tags", children: files.map((file, index) => (_jsxs("div", { className: "ncua-file-input__file-tag-container", children: [_jsx(Tag, { text: file.name, size: size === 'xs' ? 'sm' : 'md', close: true, onButtonClick: () => handleRemoveFile(index) }), file.type.startsWith('image/') && _jsx(FileTagImage, { file: file })] }, `${file.name}-${index}`))) }));
|
|
80
93
|
};
|
|
81
94
|
const renderHintList = () => {
|
|
82
95
|
if (!hintItems || hintItems.length === 0)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
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 { FileInput } from '../FileInput';
|
|
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
|
+
afterEach(() => {
|
|
19
|
+
URL.createObjectURL = originalCreateObjectURL;
|
|
20
|
+
URL.revokeObjectURL = originalRevokeObjectURL;
|
|
21
|
+
});
|
|
22
|
+
/** 내용은 검증 대상이 아니므로 최소 크기만 채운다 */
|
|
23
|
+
const DUMMY_FILE_BYTES = 4;
|
|
24
|
+
const makeFile = (name, type) => new File([new Uint8Array(DUMMY_FILE_BYTES)], name, { type });
|
|
25
|
+
function mountFileInput(files, extraProps = {}) {
|
|
26
|
+
const container = document.createElement('div');
|
|
27
|
+
document.body.appendChild(container);
|
|
28
|
+
const root = createRoot(container);
|
|
29
|
+
const render = () => {
|
|
30
|
+
act(() => {
|
|
31
|
+
root.render(createElement(FileInput, { value: files, onChange: () => undefined, ...extraProps }));
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
render();
|
|
35
|
+
const unmount = () => {
|
|
36
|
+
act(() => {
|
|
37
|
+
root.unmount();
|
|
38
|
+
});
|
|
39
|
+
container.remove();
|
|
40
|
+
};
|
|
41
|
+
return { container, render, unmount };
|
|
42
|
+
}
|
|
43
|
+
describe('FileInput — 이미지 썸네일 object URL 해제', () => {
|
|
44
|
+
it('이미지 파일의 썸네일을 렌더한다', () => {
|
|
45
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg')]);
|
|
46
|
+
const img = view.container.querySelector('.ncua-file-input__file-image');
|
|
47
|
+
expect(img).not.toBeNull();
|
|
48
|
+
expect(img?.getAttribute('src')).toBe('blob:mock/1');
|
|
49
|
+
expect(img?.getAttribute('alt')).toBe('a.jpg');
|
|
50
|
+
view.unmount();
|
|
51
|
+
});
|
|
52
|
+
it('리렌더를 반복해도 파일당 object URL을 한 번만 만든다', () => {
|
|
53
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg')]);
|
|
54
|
+
view.render();
|
|
55
|
+
view.render();
|
|
56
|
+
view.render();
|
|
57
|
+
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
|
58
|
+
view.unmount();
|
|
59
|
+
});
|
|
60
|
+
it('언마운트하면 만들었던 object URL을 해제한다', () => {
|
|
61
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg')]);
|
|
62
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
63
|
+
view.unmount();
|
|
64
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock/1');
|
|
66
|
+
});
|
|
67
|
+
it('이미지가 아닌 파일에는 object URL을 만들지 않는다', () => {
|
|
68
|
+
const view = mountFileInput([makeFile('doc.pdf', 'application/pdf')]);
|
|
69
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
70
|
+
expect(view.container.querySelector('.ncua-file-input__file-image')).toBeNull();
|
|
71
|
+
view.unmount();
|
|
72
|
+
});
|
|
73
|
+
it('이미지가 여러 개면 각각 하나씩만 만들고 언마운트 시 모두 해제한다', () => {
|
|
74
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg'), makeFile('b.png', 'image/png')]);
|
|
75
|
+
expect(createObjectURL).toHaveBeenCalledTimes(2);
|
|
76
|
+
view.unmount();
|
|
77
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
/** 숨겨진 file input 에 파일을 넣고 change 를 발생시킨다 */
|
|
81
|
+
function selectFiles(container, files) {
|
|
82
|
+
const input = container.querySelector('input[type="file"]');
|
|
83
|
+
if (!input)
|
|
84
|
+
throw new Error('file input을 찾을 수 없다');
|
|
85
|
+
Object.defineProperty(input, 'files', { value: files, configurable: true });
|
|
86
|
+
act(() => {
|
|
87
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
describe('FileInput — showFileTags', () => {
|
|
91
|
+
it('기본값에서는 파일 태그 목록을 렌더한다', () => {
|
|
92
|
+
const view = mountFileInput([makeFile('doc.pdf', 'application/pdf')]);
|
|
93
|
+
expect(view.container.querySelector('.ncua-file-input__file-tags')).not.toBeNull();
|
|
94
|
+
expect(view.container.querySelector('.ncua-tag')).not.toBeNull();
|
|
95
|
+
view.unmount();
|
|
96
|
+
});
|
|
97
|
+
it('false면 파일 태그 목록을 렌더하지 않는다', () => {
|
|
98
|
+
const view = mountFileInput([makeFile('doc.pdf', 'application/pdf')], { showFileTags: false });
|
|
99
|
+
expect(view.container.querySelector('.ncua-file-input__file-tags')).toBeNull();
|
|
100
|
+
expect(view.container.querySelector('.ncua-tag')).toBeNull();
|
|
101
|
+
view.unmount();
|
|
102
|
+
});
|
|
103
|
+
it('false여도 파일 찾기 버튼과 안내 문구는 그대로 렌더한다', () => {
|
|
104
|
+
const view = mountFileInput([makeFile('doc.pdf', 'application/pdf')], {
|
|
105
|
+
showFileTags: false,
|
|
106
|
+
hintItems: ['파일은 5MB 이내입니다.'],
|
|
107
|
+
});
|
|
108
|
+
expect(view.container.querySelector('.ncua-btn')).not.toBeNull();
|
|
109
|
+
expect(view.container.querySelector('.ncua-file-input__hint-list')?.textContent).toContain('5MB');
|
|
110
|
+
view.unmount();
|
|
111
|
+
});
|
|
112
|
+
it('false면 이미지 썸네일 object URL을 만들지 않는다', () => {
|
|
113
|
+
const view = mountFileInput([makeFile('a.jpg', 'image/jpeg')], { showFileTags: false });
|
|
114
|
+
expect(createObjectURL).not.toHaveBeenCalled();
|
|
115
|
+
expect(view.container.querySelector('.ncua-file-input__file-image')).toBeNull();
|
|
116
|
+
view.unmount();
|
|
117
|
+
});
|
|
118
|
+
it('false여도 선택한 파일을 onChange로 전달한다', () => {
|
|
119
|
+
const onChange = vi.fn();
|
|
120
|
+
const view = mountFileInput([], { showFileTags: false, onChange });
|
|
121
|
+
const picked = makeFile('a.pdf', 'application/pdf');
|
|
122
|
+
selectFiles(view.container, [picked]);
|
|
123
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
124
|
+
expect(onChange).toHaveBeenCalledWith([picked]);
|
|
125
|
+
view.unmount();
|
|
126
|
+
});
|
|
127
|
+
it('false여도 이미 등록된 파일은 중복으로 걸러 onFail로 알린다', () => {
|
|
128
|
+
const already = makeFile('a.pdf', 'application/pdf');
|
|
129
|
+
const onChange = vi.fn();
|
|
130
|
+
const onFail = vi.fn();
|
|
131
|
+
const view = mountFileInput([already], { showFileTags: false, onChange, onFail });
|
|
132
|
+
selectFiles(view.container, [makeFile('a.pdf', 'application/pdf')]);
|
|
133
|
+
expect(onChange).toHaveBeenCalledWith([already]);
|
|
134
|
+
expect(onFail).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(onFail.mock.calls[0][0]).toHaveLength(1);
|
|
136
|
+
expect(onFail.mock.calls[0][0][0].errorType).toBe('ALREADY_UPLOADED');
|
|
137
|
+
view.unmount();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -1,5 +1,17 @@
|
|
|
1
|
-
import { type InvalidFile } from '../file-input/FileInput';
|
|
1
|
+
import { FileInputErrorType as ImageFileInputErrorType, type InvalidFile } from '../file-input/FileInput';
|
|
2
2
|
import type { InputBaseProps } from '../input-base/InputBase';
|
|
3
|
+
/**
|
|
4
|
+
* An image already stored on the server, identified by its URL.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the vanilla implementation's `UploadedFile`
|
|
7
|
+
* (assets/scripts/imageFileInput/const/types.ts), with two deliberate differences:
|
|
8
|
+
* the name is prefixed because this one is re-exported from the package root,
|
|
9
|
+
* and `fileImageUrl` is required — a preview entry without a URL has nothing to render.
|
|
10
|
+
*/
|
|
11
|
+
export type ImageUploadedFile = {
|
|
12
|
+
fileName: string;
|
|
13
|
+
fileImageUrl: string;
|
|
14
|
+
};
|
|
3
15
|
export interface ImageFileInputProps extends Omit<InputBaseProps, 'clearText' | 'onClearText' | 'hintText' | 'value' | 'onChange'> {
|
|
4
16
|
/**
|
|
5
17
|
* Accepted file types
|
|
@@ -22,6 +34,19 @@ export interface ImageFileInputProps extends Omit<InputBaseProps, 'clearText' |
|
|
|
22
34
|
* Callback when files change (controlled mode)
|
|
23
35
|
*/
|
|
24
36
|
onChange?: (files: File[]) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Images already stored on the server, shown before newly selected files.
|
|
39
|
+
* Rendered straight from their URL, so no fetch or CORS handling is needed.
|
|
40
|
+
*/
|
|
41
|
+
uploadedFiles?: ImageUploadedFile[];
|
|
42
|
+
/**
|
|
43
|
+
* Callback when a stored image is removed. Receives the remaining list.
|
|
44
|
+
*
|
|
45
|
+
* `uploadedFiles` is a controlled prop — just like `value`/`onChange`, removal
|
|
46
|
+
* only takes effect once the caller applies this list back. Passing
|
|
47
|
+
* `uploadedFiles` without this callback leaves the previews frozen.
|
|
48
|
+
*/
|
|
49
|
+
onUploadedFilesChange?: (uploadedFiles: ImageUploadedFile[]) => void;
|
|
25
50
|
/**
|
|
26
51
|
* Callback when files are selected (uncontrolled mode)
|
|
27
52
|
*/
|
|
@@ -68,3 +93,10 @@ export interface ImageFileInputProps extends Omit<InputBaseProps, 'clearText' |
|
|
|
68
93
|
showFileInput?: boolean;
|
|
69
94
|
}
|
|
70
95
|
export declare const ImageFileInput: import("react").ForwardRefExoticComponent<ImageFileInputProps & import("react").RefAttributes<HTMLInputElement>>;
|
|
96
|
+
/**
|
|
97
|
+
* Rejection reasons reported through `onFail`.
|
|
98
|
+
*
|
|
99
|
+
* Re-exported under the ImageFileInput name so callers do not have to reach
|
|
100
|
+
* into FileInput for it. Same enum, same values.
|
|
101
|
+
*/
|
|
102
|
+
export { ImageFileInputErrorType };
|
|
@@ -19,7 +19,7 @@ const toInvalidFile = (file, errorType) => ({
|
|
|
19
19
|
slice: (...args) => file.slice(...args),
|
|
20
20
|
errorType,
|
|
21
21
|
});
|
|
22
|
-
export const ImageFileInput = forwardRef(({ size = 'sm', accept, multiple = false, maxFileSize, maxFileCount, value, onChange, onFileSelect, onFail, buttonLabel = '파일 찾기', imagePreviewTooltipLabel = '이미지 업로드', disabled, label, hintItems, validation, destructive, isRequired, showHelpIcon, hintText, showFileTagList = true, showHintText = true, showFileInput = true, ...props }, ref) => {
|
|
22
|
+
export const ImageFileInput = forwardRef(({ size = 'sm', accept, multiple = false, maxFileSize, maxFileCount, value, onChange, uploadedFiles = [], onUploadedFilesChange, onFileSelect, onFail, buttonLabel = '파일 찾기', imagePreviewTooltipLabel = '이미지 업로드', disabled, label, hintItems, validation, destructive, isRequired, showHelpIcon, hintText, showFileTagList = true, showHintText = true, showFileInput = true, ...props }, ref) => {
|
|
23
23
|
const fileInputRef = useRef(null);
|
|
24
24
|
useImperativeHandle(ref, () => fileInputRef.current);
|
|
25
25
|
const [internalFiles, setInternalFiles] = useState([]);
|
|
@@ -27,6 +27,9 @@ export const ImageFileInput = forwardRef(({ size = 'sm', accept, multiple = fals
|
|
|
27
27
|
// Determine if component is controlled or uncontrolled
|
|
28
28
|
const isControlled = value !== undefined;
|
|
29
29
|
const files = isControlled ? value : internalFiles;
|
|
30
|
+
// An entry without a URL has nothing to render, so it must not occupy a slot either.
|
|
31
|
+
// TypeScript requires fileImageUrl, but CDN/vanilla consumers are untyped.
|
|
32
|
+
const visibleUploadedFiles = uploadedFiles.filter((uploadedFile) => Boolean(uploadedFile.fileImageUrl));
|
|
30
33
|
// Sync internal state with controlled value
|
|
31
34
|
useEffect(() => {
|
|
32
35
|
if (isControlled && value) {
|
|
@@ -47,32 +50,58 @@ export const ImageFileInput = forwardRef(({ size = 'sm', accept, multiple = fals
|
|
|
47
50
|
if (!selectedFiles || selectedFiles.length === 0)
|
|
48
51
|
return;
|
|
49
52
|
const { validFiles, invalidFiles } = validateFiles(Array.from(selectedFiles));
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
+
// Nothing passed validation — keep what the caller already had.
|
|
54
|
+
// Without this, a single slot would be overwritten with an empty list.
|
|
55
|
+
if (validFiles.length > 0) {
|
|
56
|
+
// Replace existing file if maxFileCount is 1
|
|
57
|
+
const nextFiles = maxFileCount === 1 ? validFiles : [...files, ...validFiles];
|
|
58
|
+
updateFiles(nextFiles);
|
|
59
|
+
// A single slot holds one image, so a new selection also clears the stored one.
|
|
60
|
+
// Entries we never rendered occupy no slot, so they are left in the caller's list.
|
|
61
|
+
if (maxFileCount === 1 && visibleUploadedFiles.length > 0) {
|
|
62
|
+
onUploadedFilesChange?.(uploadedFiles.filter((uploadedFile) => !uploadedFile.fileImageUrl));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
53
65
|
if (onFail && invalidFiles.length > 0) {
|
|
54
66
|
onFail(invalidFiles);
|
|
55
67
|
}
|
|
56
68
|
event.target.value = '';
|
|
57
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* Returns why a file must be rejected, or null when it is acceptable.
|
|
72
|
+
* `acceptedCount` is how many files of the same selection already passed.
|
|
73
|
+
*/
|
|
74
|
+
const findRejectionReason = (file, acceptedCount) => {
|
|
75
|
+
if (files.some((f) => f.name === file.name && f.size === file.size)) {
|
|
76
|
+
return ImageFileInputErrorType.ALREADY_UPLOADED;
|
|
77
|
+
}
|
|
78
|
+
// Stored images carry no size, so they can only be matched by name.
|
|
79
|
+
// A single slot is meant to be replaced, so re-picking the same name is allowed there.
|
|
80
|
+
if (maxFileCount !== 1 && visibleUploadedFiles.some((uploadedFile) => uploadedFile.fileName === file.name)) {
|
|
81
|
+
return ImageFileInputErrorType.ALREADY_UPLOADED;
|
|
82
|
+
}
|
|
83
|
+
if (!!maxFileSize && file.size > maxFileSize) {
|
|
84
|
+
return ImageFileInputErrorType.EXCEED_MAX_FILE_SIZE;
|
|
85
|
+
}
|
|
86
|
+
// Skip max count check if maxFileCount is 1 (allow replacement).
|
|
87
|
+
// Stored images occupy slots too, so they count toward the limit.
|
|
88
|
+
const usedSlots = visibleUploadedFiles.length + files.length + acceptedCount;
|
|
89
|
+
if (!!maxFileCount && maxFileCount !== 1 && usedSlots >= maxFileCount) {
|
|
90
|
+
return ImageFileInputErrorType.EXCEED_MAX_FILE_COUNT;
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
58
94
|
const validateFiles = (fileList) => {
|
|
59
95
|
const validFiles = [];
|
|
60
96
|
const invalidFiles = [];
|
|
61
97
|
for (const file of fileList) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
98
|
+
const rejectionReason = findRejectionReason(file, validFiles.length);
|
|
99
|
+
if (rejectionReason) {
|
|
100
|
+
invalidFiles.push(toInvalidFile(file, rejectionReason));
|
|
65
101
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
continue;
|
|
102
|
+
else {
|
|
103
|
+
validFiles.push(file);
|
|
69
104
|
}
|
|
70
|
-
// Skip max count check if maxFileCount is 1 (allow replacement)
|
|
71
|
-
if (!!maxFileCount && maxFileCount !== 1 && files.length + validFiles.length >= maxFileCount) {
|
|
72
|
-
invalidFiles.push(toInvalidFile(file, ImageFileInputErrorType.EXCEED_MAX_FILE_COUNT));
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
validFiles.push(file);
|
|
76
105
|
}
|
|
77
106
|
return { validFiles, invalidFiles };
|
|
78
107
|
};
|
|
@@ -86,9 +115,18 @@ export const ImageFileInput = forwardRef(({ size = 'sm', accept, multiple = fals
|
|
|
86
115
|
newFiles.splice(index, 1);
|
|
87
116
|
updateFiles(newFiles);
|
|
88
117
|
};
|
|
118
|
+
// Removed by identity, not index, so keys stay stable and the caller's
|
|
119
|
+
// untouched entries (including ones we skip rendering) are preserved
|
|
120
|
+
const handleRemoveUploadedFile = (target) => {
|
|
121
|
+
onUploadedFilesChange?.(uploadedFiles.filter((uploadedFile) => uploadedFile !== target));
|
|
122
|
+
};
|
|
89
123
|
const renderImagePreview = (files = []) => {
|
|
90
|
-
const
|
|
91
|
-
|
|
124
|
+
const totalCount = visibleUploadedFiles.length + files.length;
|
|
125
|
+
const showEmptySlot = maxFileCount ? totalCount < maxFileCount : totalCount === 0;
|
|
126
|
+
return (_jsxs("div", { className: "ncua-image-file-input__previews", children: [visibleUploadedFiles.map((uploadedFile, index) => (_jsx(ImagePreview
|
|
127
|
+
// Removal is by identity, so the index here only breaks ties between
|
|
128
|
+
// entries that share both URL and file name.
|
|
129
|
+
, { imageUrl: uploadedFile.fileImageUrl, alt: uploadedFile.fileName, disabled: disabled, onRemove: () => handleRemoveUploadedFile(uploadedFile) }, `uploaded-${index}-${uploadedFile.fileImageUrl}-${uploadedFile.fileName}`))), files.map((file, index) => (_jsx(ImagePreview, { file: file, disabled: disabled, onRemove: () => handleRemoveFile(index) }, `${file.name}-${index}`))), showEmptySlot && (_jsxs("div", { className: "ncua-image-file-input__empty-slot-wrapper", onMouseEnter: () => !disabled && setIsButtonHovered(true), onMouseLeave: () => setIsButtonHovered(false), onClick: handleBrowseClick, children: [_jsx(Button, { onlyIcon: true, size: size, className: classNames('ncua-image-file-input__preview-container'), onClick: handleBrowseClick, disabled: disabled, label: imagePreviewTooltipLabel }), _jsx(Tooltip, { content: imagePreviewTooltipLabel, position: "bottom", tooltipType: "black", forceVisible: isButtonHovered && !disabled, panelClassName: classNames('ncua-image-file-input__slot-tooltip', `ncua-image-file-input__slot-tooltip--${size}`) })] }))] }));
|
|
92
130
|
};
|
|
93
131
|
const renderHintList = () => {
|
|
94
132
|
if (!hintItems || hintItems.length === 0)
|
|
@@ -97,3 +135,10 @@ export const ImageFileInput = forwardRef(({ size = 'sm', accept, multiple = fals
|
|
|
97
135
|
};
|
|
98
136
|
return (_jsxs("div", { className: classNames('ncua-image-file-input', `ncua-image-file-input--${size}`, { destructive: destructive }), children: [renderImagePreview(files), _jsx("input", { hidden: true, ref: fileInputRef, type: "file", accept: accept, multiple: multiple, onChange: handleFileChange, tabIndex: -1, "aria-hidden": "true", ...props }), showFileInput && (_jsxs("div", { className: classNames('ncua-file-input', `ncua-file-input--${size}`), children: [_jsxs("div", { className: "ncua-file-input__input-container", children: [_jsxs("div", { className: "ncua-file-input__label", children: [_jsx(Label, { isRequired: isRequired, children: label }), showHelpIcon && _jsx(HelpCircle, { className: "ncua-input__help-icon" })] }), _jsx(Button, { size: "xs", onClick: handleBrowseClick, disabled: disabled, leadingIcon: { type: 'icon', icon: Upload01 }, label: buttonLabel }), showHintText && hintText && _jsx(HintText, { destructive: destructive, children: hintText })] }), showHintText && renderHintList()] }))] }));
|
|
99
137
|
});
|
|
138
|
+
/**
|
|
139
|
+
* Rejection reasons reported through `onFail`.
|
|
140
|
+
*
|
|
141
|
+
* Re-exported under the ImageFileInput name so callers do not have to reach
|
|
142
|
+
* into FileInput for it. Same enum, same values.
|
|
143
|
+
*/
|
|
144
|
+
export { ImageFileInputErrorType };
|
package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|