aplance-ui 1.0.1
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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/api.d.ts +175 -0
- package/dist/api.js +1954 -0
- package/dist/api.js.map +1 -0
- package/dist/frameworks/astro/astro.config.snippet.md +23 -0
- package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
- package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
- package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
- package/dist/frameworks/next/layout-import.snippet.md +19 -0
- package/dist/frameworks/next/next.config.snippet.md +15 -0
- package/dist/frameworks/next/postcss.config.js.template +16 -0
- package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
- package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
- package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
- package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
- package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
- package/dist/index.js +2047 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/registry/components/primitives/icon/icon.module.css +63 -0
- package/registry/components/primitives/icon/icon.tsx +53 -0
- package/registry/components/primitives/modal/modal-context.ts +19 -0
- package/registry/components/primitives/modal/modal.module.css +415 -0
- package/registry/components/primitives/modal/modal.tsx +550 -0
- package/registry/components/primitives/popover/popover-context.ts +28 -0
- package/registry/components/primitives/popover/popover.module.css +28 -0
- package/registry/components/primitives/popover/popover.tsx +465 -0
- package/registry/components/ui/accordion/accordion.module.css +208 -0
- package/registry/components/ui/accordion/accordion.tsx +327 -0
- package/registry/components/ui/avatar/avatar.module.css +32 -0
- package/registry/components/ui/avatar/avatar.tsx +37 -0
- package/registry/components/ui/badge/badge.module.css +103 -0
- package/registry/components/ui/badge/badge.tsx +29 -0
- package/registry/components/ui/button/button.module.css +216 -0
- package/registry/components/ui/button/button.tsx +133 -0
- package/registry/components/ui/chart/chart.module.css +168 -0
- package/registry/components/ui/chart/chart.tsx +1293 -0
- package/registry/components/ui/chart/types.ts +210 -0
- package/registry/components/ui/chart/utils.ts +59 -0
- package/registry/components/ui/copy-button/copy-button.module.css +21 -0
- package/registry/components/ui/copy-button/copy-button.tsx +123 -0
- package/registry/components/ui/countdown/countdown.module.css +5 -0
- package/registry/components/ui/countdown/countdown.tsx +152 -0
- package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
- package/registry/components/ui/data-table/data-table.module.css +594 -0
- package/registry/components/ui/data-table/data-table.tsx +1109 -0
- package/registry/components/ui/data-table/filter-bar.tsx +33 -0
- package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
- package/registry/components/ui/data-table/types.ts +594 -0
- package/registry/components/ui/data-table/useDataTable.tsx +573 -0
- package/registry/components/ui/data-table/utils.ts +445 -0
- package/registry/components/ui/empty-state/empty-state.module.css +26 -0
- package/registry/components/ui/empty-state/empty-state.tsx +32 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
- package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
- package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
- package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
- package/registry/components/ui/input-fields/common/types.ts +38 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
- package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
- package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
- package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
- package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
- package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
- package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
- package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
- package/registry/components/ui/input-fields/input/input.tsx +319 -0
- package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
- package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
- package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
- package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
- package/registry/components/ui/input-fields/media-input/types.ts +129 -0
- package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
- package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
- package/registry/components/ui/input-fields/select/select.tsx +118 -0
- package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
- package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
- package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
- package/registry/components/ui/loader/loader.module.css +156 -0
- package/registry/components/ui/loader/loader.tsx +102 -0
- package/registry/components/ui/stepper/stepper.module.css +294 -0
- package/registry/components/ui/stepper/stepper.tsx +197 -0
- package/registry/components/ui/tabs/tabs.module.css +150 -0
- package/registry/components/ui/tabs/tabs.tsx +134 -0
- package/registry/components/ui/toast/toast.module.css +159 -0
- package/registry/components/ui/toast/toast.tsx +104 -0
- package/registry/components/ui/tooltip/tooltip.module.css +82 -0
- package/registry/components/ui/tooltip/tooltip.tsx +72 -0
- package/registry/global.d.ts +4 -0
- package/registry/hooks/useFocusOutside.tsx +33 -0
- package/registry/hooks/useResizeObserver.tsx +32 -0
- package/registry/lib/dom.ts +25 -0
- package/registry/lib/string.ts +23 -0
- package/registry/registry.json +719 -0
- package/registry/styles/app.css +240 -0
- package/registry/styles/components.css +78 -0
- package/registry/styles/fonts/geist-regular.woff2 +0 -0
- package/registry/styles/globals.css +7 -0
- package/registry/styles/media.css +25 -0
- package/registry/styles/utilities.css +90 -0
- package/registry/themes/colors/blue.css +37 -0
- package/registry/themes/colors/green.css +37 -0
- package/registry/themes/colors/rose.css +37 -0
- package/registry/themes/colors/slate.css +37 -0
- package/registry/themes/colors/stone.css +37 -0
- package/registry/themes/colors/violet.css +37 -0
- package/registry/themes/colors/zinc.css +37 -0
- package/registry/themes/radius/pill.css +8 -0
- package/registry/themes/radius/rounded.css +8 -0
- package/registry/themes/radius/sharp.css +8 -0
- package/registry/themes/spacing/compact.css +17 -0
- package/registry/themes/spacing/spacious.css +17 -0
- package/registry/tsconfig.json +25 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChangeEvent,
|
|
3
|
+
type DragEvent,
|
|
4
|
+
type HTMLAttributes,
|
|
5
|
+
type InputHTMLAttributes,
|
|
6
|
+
type RefObject,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from "react";
|
|
13
|
+
import type { MediaFile, MediaRejection } from "./types";
|
|
14
|
+
import {
|
|
15
|
+
revokePreviewUrl,
|
|
16
|
+
validateIncomingFiles,
|
|
17
|
+
wrapAsMediaFile,
|
|
18
|
+
} from "./utils";
|
|
19
|
+
|
|
20
|
+
export type UseMediaUploadOptions = {
|
|
21
|
+
/** Controlled value. Must be the array previously returned by the hook. */
|
|
22
|
+
value?: MediaFile[];
|
|
23
|
+
/** Initial value for uncontrolled usage. Plain `File`s are wrapped automatically. */
|
|
24
|
+
defaultValue?: MediaFile[] | File[];
|
|
25
|
+
onChange?: (files: MediaFile[]) => void;
|
|
26
|
+
onAccepted?: (files: MediaFile[]) => void;
|
|
27
|
+
onRejected?: (rejections: MediaRejection[]) => void;
|
|
28
|
+
onRemove?: (file: MediaFile) => void;
|
|
29
|
+
|
|
30
|
+
accept?: string;
|
|
31
|
+
multiple?: boolean;
|
|
32
|
+
maxFiles?: number;
|
|
33
|
+
maxSize?: number;
|
|
34
|
+
|
|
35
|
+
disabled?: boolean;
|
|
36
|
+
readOnly?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type UseMediaUploadReturn = {
|
|
40
|
+
files: MediaFile[];
|
|
41
|
+
rejections: MediaRejection[];
|
|
42
|
+
isDragging: boolean;
|
|
43
|
+
|
|
44
|
+
/** Imperatively open the native file picker. */
|
|
45
|
+
open: () => void;
|
|
46
|
+
/** Programmatic add - used by the host component and consumers alike. */
|
|
47
|
+
add: (incoming: File[] | FileList | null | undefined) => void;
|
|
48
|
+
remove: (id: string) => void;
|
|
49
|
+
clear: () => void;
|
|
50
|
+
|
|
51
|
+
/** Spread on the dropzone container to enable drag-and-drop + click-to-open. */
|
|
52
|
+
getRootProps: <E extends HTMLElement = HTMLDivElement>(
|
|
53
|
+
overrides?: HTMLAttributes<E>,
|
|
54
|
+
) => HTMLAttributes<E>;
|
|
55
|
+
/** Spread on the hidden `<input type="file">`. The hook owns this element. */
|
|
56
|
+
getInputProps: (
|
|
57
|
+
overrides?: InputHTMLAttributes<HTMLInputElement>,
|
|
58
|
+
) => InputHTMLAttributes<HTMLInputElement> & {
|
|
59
|
+
ref: RefObject<HTMLInputElement | null>;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Headless file upload behavior. Owns dedupe, validation, drag state, and
|
|
65
|
+
* preview URL lifecycle. The host component decides what to render - see
|
|
66
|
+
* `MediaInput` for the full UI, or wire `getRootProps`/`getInputProps`
|
|
67
|
+
* yourself for a custom layout.
|
|
68
|
+
*/
|
|
69
|
+
export function useMediaUpload({
|
|
70
|
+
value,
|
|
71
|
+
defaultValue,
|
|
72
|
+
onChange,
|
|
73
|
+
onAccepted,
|
|
74
|
+
onRejected,
|
|
75
|
+
onRemove,
|
|
76
|
+
accept,
|
|
77
|
+
multiple = false,
|
|
78
|
+
maxFiles,
|
|
79
|
+
maxSize,
|
|
80
|
+
disabled = false,
|
|
81
|
+
readOnly = false,
|
|
82
|
+
}: UseMediaUploadOptions): UseMediaUploadReturn {
|
|
83
|
+
const isControlled = value !== undefined;
|
|
84
|
+
|
|
85
|
+
const [internal, setInternal] = useState<MediaFile[]>(() =>
|
|
86
|
+
defaultValue ? defaultValue.map(wrapAsMediaFile) : [],
|
|
87
|
+
);
|
|
88
|
+
const [rejections, setRejections] = useState<MediaRejection[]>([]);
|
|
89
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
90
|
+
const dragCounter = useRef(0);
|
|
91
|
+
|
|
92
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
93
|
+
const filesRef = useRef<MediaFile[]>(isControlled ? value! : internal);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
filesRef.current = isControlled ? value! : internal;
|
|
97
|
+
}, [isControlled, value, internal]);
|
|
98
|
+
|
|
99
|
+
// Revoke any preview URLs the hook created on unmount so blob memory is
|
|
100
|
+
// freed even if the consumer forgets to clear state.
|
|
101
|
+
useEffect(
|
|
102
|
+
() => () => {
|
|
103
|
+
filesRef.current.forEach(revokePreviewUrl);
|
|
104
|
+
},
|
|
105
|
+
[],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const commit = useCallback(
|
|
109
|
+
(next: MediaFile[]) => {
|
|
110
|
+
if (!isControlled) setInternal(next);
|
|
111
|
+
onChange?.(next);
|
|
112
|
+
},
|
|
113
|
+
[isControlled, onChange],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const add = useCallback(
|
|
117
|
+
(incoming: File[] | FileList | null | undefined) => {
|
|
118
|
+
if (!incoming || disabled || readOnly) return;
|
|
119
|
+
const list = Array.from(incoming as ArrayLike<File>);
|
|
120
|
+
if (list.length === 0) return;
|
|
121
|
+
|
|
122
|
+
const existing = filesRef.current;
|
|
123
|
+
const baseExisting = multiple ? existing : [];
|
|
124
|
+
|
|
125
|
+
const { accepted, rejected } = validateIncomingFiles(list, {
|
|
126
|
+
accept,
|
|
127
|
+
maxSize,
|
|
128
|
+
maxFiles,
|
|
129
|
+
multiple,
|
|
130
|
+
existing: baseExisting,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Single-mode replacement frees the previous preview blob immediately.
|
|
134
|
+
if (!multiple && existing.length > 0) existing.forEach(revokePreviewUrl);
|
|
135
|
+
|
|
136
|
+
const next = multiple ? [...existing, ...accepted] : accepted;
|
|
137
|
+
if (accepted.length > 0) {
|
|
138
|
+
commit(next);
|
|
139
|
+
onAccepted?.(accepted);
|
|
140
|
+
}
|
|
141
|
+
setRejections(rejected);
|
|
142
|
+
if (rejected.length > 0) onRejected?.(rejected);
|
|
143
|
+
},
|
|
144
|
+
[
|
|
145
|
+
accept,
|
|
146
|
+
commit,
|
|
147
|
+
disabled,
|
|
148
|
+
maxFiles,
|
|
149
|
+
maxSize,
|
|
150
|
+
multiple,
|
|
151
|
+
onAccepted,
|
|
152
|
+
onRejected,
|
|
153
|
+
readOnly,
|
|
154
|
+
],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const remove = useCallback(
|
|
158
|
+
(id: string) => {
|
|
159
|
+
if (disabled || readOnly) return;
|
|
160
|
+
const target = filesRef.current.find((f) => f.id === id);
|
|
161
|
+
if (!target) return;
|
|
162
|
+
revokePreviewUrl(target);
|
|
163
|
+
const next = filesRef.current.filter((f) => f.id !== id);
|
|
164
|
+
commit(next);
|
|
165
|
+
onRemove?.(target);
|
|
166
|
+
},
|
|
167
|
+
[commit, disabled, onRemove, readOnly],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const clear = useCallback(() => {
|
|
171
|
+
if (disabled || readOnly) return;
|
|
172
|
+
filesRef.current.forEach(revokePreviewUrl);
|
|
173
|
+
commit([]);
|
|
174
|
+
setRejections([]);
|
|
175
|
+
}, [commit, disabled, readOnly]);
|
|
176
|
+
|
|
177
|
+
const open = useCallback(() => {
|
|
178
|
+
if (disabled || readOnly) return;
|
|
179
|
+
inputRef.current?.click();
|
|
180
|
+
}, [disabled, readOnly]);
|
|
181
|
+
|
|
182
|
+
const handleInputChange = useCallback(
|
|
183
|
+
(e: ChangeEvent<HTMLInputElement>) => {
|
|
184
|
+
add(e.target.files);
|
|
185
|
+
// Reset so re-selecting the same file fires `change` again.
|
|
186
|
+
e.target.value = "";
|
|
187
|
+
},
|
|
188
|
+
[add],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const handleDragEnter = useCallback(
|
|
192
|
+
(e: DragEvent) => {
|
|
193
|
+
if (disabled || readOnly) return;
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
e.stopPropagation();
|
|
196
|
+
dragCounter.current += 1;
|
|
197
|
+
if (e.dataTransfer?.types?.includes("Files")) setIsDragging(true);
|
|
198
|
+
},
|
|
199
|
+
[disabled, readOnly],
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
e.stopPropagation();
|
|
205
|
+
dragCounter.current = Math.max(0, dragCounter.current - 1);
|
|
206
|
+
if (dragCounter.current === 0) setIsDragging(false);
|
|
207
|
+
}, []);
|
|
208
|
+
|
|
209
|
+
const handleDragOver = useCallback(
|
|
210
|
+
(e: DragEvent) => {
|
|
211
|
+
if (disabled || readOnly) return;
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
e.stopPropagation();
|
|
214
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
|
215
|
+
},
|
|
216
|
+
[disabled, readOnly],
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const handleDrop = useCallback(
|
|
220
|
+
(e: DragEvent) => {
|
|
221
|
+
if (disabled || readOnly) return;
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
e.stopPropagation();
|
|
224
|
+
dragCounter.current = 0;
|
|
225
|
+
setIsDragging(false);
|
|
226
|
+
add(e.dataTransfer?.files);
|
|
227
|
+
},
|
|
228
|
+
[add, disabled, readOnly],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const getRootProps = useCallback(
|
|
232
|
+
<E extends HTMLElement = HTMLDivElement>(
|
|
233
|
+
overrides: HTMLAttributes<E> = {},
|
|
234
|
+
): HTMLAttributes<E> => ({
|
|
235
|
+
...overrides,
|
|
236
|
+
onClick: (e) => {
|
|
237
|
+
overrides.onClick?.(e);
|
|
238
|
+
if (!e.defaultPrevented) open();
|
|
239
|
+
},
|
|
240
|
+
onKeyDown: (e) => {
|
|
241
|
+
overrides.onKeyDown?.(e);
|
|
242
|
+
if (e.defaultPrevented) return;
|
|
243
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
open();
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
onDragEnter: (e) => {
|
|
249
|
+
overrides.onDragEnter?.(e);
|
|
250
|
+
handleDragEnter(e);
|
|
251
|
+
},
|
|
252
|
+
onDragLeave: (e) => {
|
|
253
|
+
overrides.onDragLeave?.(e);
|
|
254
|
+
handleDragLeave(e);
|
|
255
|
+
},
|
|
256
|
+
onDragOver: (e) => {
|
|
257
|
+
overrides.onDragOver?.(e);
|
|
258
|
+
handleDragOver(e);
|
|
259
|
+
},
|
|
260
|
+
onDrop: (e) => {
|
|
261
|
+
overrides.onDrop?.(e);
|
|
262
|
+
handleDrop(e);
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
[handleDragEnter, handleDragLeave, handleDragOver, handleDrop, open],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const getInputProps = useCallback(
|
|
269
|
+
(overrides: InputHTMLAttributes<HTMLInputElement> = {}) => ({
|
|
270
|
+
type: "file" as const,
|
|
271
|
+
hidden: true,
|
|
272
|
+
tabIndex: -1,
|
|
273
|
+
accept,
|
|
274
|
+
multiple,
|
|
275
|
+
disabled: disabled || readOnly,
|
|
276
|
+
...overrides,
|
|
277
|
+
ref: inputRef,
|
|
278
|
+
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
|
279
|
+
overrides.onChange?.(e);
|
|
280
|
+
handleInputChange(e);
|
|
281
|
+
},
|
|
282
|
+
}),
|
|
283
|
+
[accept, disabled, handleInputChange, multiple, readOnly],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const files = isControlled ? value! : internal;
|
|
287
|
+
|
|
288
|
+
return useMemo(
|
|
289
|
+
() => ({
|
|
290
|
+
files,
|
|
291
|
+
rejections,
|
|
292
|
+
isDragging,
|
|
293
|
+
open,
|
|
294
|
+
add,
|
|
295
|
+
remove,
|
|
296
|
+
clear,
|
|
297
|
+
getRootProps,
|
|
298
|
+
getInputProps,
|
|
299
|
+
}),
|
|
300
|
+
[add, clear, files, getInputProps, getRootProps, isDragging, open, rejections, remove],
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import type { MediaFile, MediaKind, MediaRejection, MediaRejectionCode } from "./types";
|
|
2
|
+
|
|
3
|
+
const SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
|
|
4
|
+
|
|
5
|
+
export const formatBytes = (bytes: number): string => {
|
|
6
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
|
|
7
|
+
if (bytes === 0) return "0 B";
|
|
8
|
+
const exp = Math.min(
|
|
9
|
+
SIZE_UNITS.length - 1,
|
|
10
|
+
Math.floor(Math.log(bytes) / Math.log(1024)),
|
|
11
|
+
);
|
|
12
|
+
const value = bytes / 1024 ** exp;
|
|
13
|
+
const formatted = value >= 100 || exp === 0 ? value.toFixed(0) : value.toFixed(1);
|
|
14
|
+
return `${formatted} ${SIZE_UNITS[exp]}`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const isImageFile = (file: File): boolean =>
|
|
18
|
+
file.type.startsWith("image/");
|
|
19
|
+
|
|
20
|
+
export const isVideoFile = (file: File): boolean =>
|
|
21
|
+
file.type.startsWith("video/");
|
|
22
|
+
|
|
23
|
+
export const isAudioFile = (file: File): boolean =>
|
|
24
|
+
file.type.startsWith("audio/");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Match a file against an HTML `accept` string. Supports MIME globs
|
|
28
|
+
* (image-slash-star), exact MIME types (`application/pdf`), extension globs
|
|
29
|
+
* (`.png`), and bare wildcards (`*` or any-MIME). Empty/undefined accept
|
|
30
|
+
* matches every file.
|
|
31
|
+
*/
|
|
32
|
+
export const matchesAccept = (file: File, accept?: string): boolean => {
|
|
33
|
+
if (!accept) return true;
|
|
34
|
+
const normalized = accept
|
|
35
|
+
.split(",")
|
|
36
|
+
.map((token) => token.trim().toLowerCase())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
if (normalized.length === 0) return true;
|
|
39
|
+
|
|
40
|
+
const mime = (file.type || "").toLowerCase();
|
|
41
|
+
const name = file.name.toLowerCase();
|
|
42
|
+
|
|
43
|
+
return normalized.some((token) => {
|
|
44
|
+
if (token === "*" || token === "*/*") return true;
|
|
45
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
46
|
+
if (token.endsWith("/*")) {
|
|
47
|
+
const prefix = token.slice(0, -1);
|
|
48
|
+
return mime.startsWith(prefix);
|
|
49
|
+
}
|
|
50
|
+
return mime === token;
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const fileFingerprint = (file: File): string =>
|
|
55
|
+
`${file.name}::${file.size}::${file.lastModified}`;
|
|
56
|
+
|
|
57
|
+
const generateId = (): string =>
|
|
58
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
59
|
+
? crypto.randomUUID()
|
|
60
|
+
: `media-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
|
61
|
+
|
|
62
|
+
export const buildMediaFile = (file: File): MediaFile => {
|
|
63
|
+
const previewUrl =
|
|
64
|
+
typeof URL !== "undefined" && (isImageFile(file) || isVideoFile(file))
|
|
65
|
+
? URL.createObjectURL(file)
|
|
66
|
+
: undefined;
|
|
67
|
+
return {
|
|
68
|
+
id: generateId(),
|
|
69
|
+
file,
|
|
70
|
+
previewUrl,
|
|
71
|
+
status: "ready",
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Only revoke `blob:` URLs that the hook itself created. Consumers may pass
|
|
77
|
+
* pre-existing `https://...` URLs (server-rendered media, signed thumbnails,
|
|
78
|
+
* etc.) and we must not touch those.
|
|
79
|
+
*/
|
|
80
|
+
export const revokePreviewUrl = (file: MediaFile): void => {
|
|
81
|
+
if (
|
|
82
|
+
typeof URL !== "undefined" &&
|
|
83
|
+
typeof file.previewUrl === "string" &&
|
|
84
|
+
file.previewUrl.startsWith("blob:")
|
|
85
|
+
) {
|
|
86
|
+
URL.revokeObjectURL(file.previewUrl);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const wrapAsMediaFile = (input: File | MediaFile): MediaFile =>
|
|
91
|
+
input instanceof File ? buildMediaFile(input) : input;
|
|
92
|
+
|
|
93
|
+
export const rejectionMessage = (
|
|
94
|
+
code: MediaRejectionCode,
|
|
95
|
+
meta: { maxSize?: number; maxFiles?: number; accept?: string } = {},
|
|
96
|
+
): string => {
|
|
97
|
+
switch (code) {
|
|
98
|
+
case "file-too-large":
|
|
99
|
+
return meta.maxSize
|
|
100
|
+
? `File exceeds the ${formatBytes(meta.maxSize)} size limit.`
|
|
101
|
+
: "File is too large.";
|
|
102
|
+
case "file-invalid-type":
|
|
103
|
+
return meta.accept
|
|
104
|
+
? `File type is not allowed. Accepted: ${meta.accept}.`
|
|
105
|
+
: "File type is not allowed.";
|
|
106
|
+
case "too-many-files":
|
|
107
|
+
return meta.maxFiles
|
|
108
|
+
? `Only ${meta.maxFiles} file${meta.maxFiles === 1 ? "" : "s"} can be added.`
|
|
109
|
+
: "Too many files selected.";
|
|
110
|
+
case "duplicate-file":
|
|
111
|
+
return "File has already been added.";
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type ValidateOptions = {
|
|
116
|
+
accept?: string;
|
|
117
|
+
maxSize?: number;
|
|
118
|
+
maxFiles?: number;
|
|
119
|
+
multiple: boolean;
|
|
120
|
+
existing: MediaFile[];
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type ValidateResult = {
|
|
124
|
+
accepted: MediaFile[];
|
|
125
|
+
rejected: MediaRejection[];
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Apply accept/size/dedupe/limit rules and convert raw `File`s into
|
|
130
|
+
* `MediaFile`s. Stops adding once `maxFiles` is reached and reports the
|
|
131
|
+
* remainder as `too-many-files` so consumers can display the issue.
|
|
132
|
+
*/
|
|
133
|
+
export const validateIncomingFiles = (
|
|
134
|
+
incoming: File[],
|
|
135
|
+
options: ValidateOptions,
|
|
136
|
+
): ValidateResult => {
|
|
137
|
+
const accepted: MediaFile[] = [];
|
|
138
|
+
const rejected: MediaRejection[] = [];
|
|
139
|
+
|
|
140
|
+
const seen = new Set(options.existing.map((f) => fileFingerprint(f.file)));
|
|
141
|
+
const remainingCapacity = options.multiple
|
|
142
|
+
? options.maxFiles
|
|
143
|
+
? Math.max(0, options.maxFiles - options.existing.length)
|
|
144
|
+
: Number.POSITIVE_INFINITY
|
|
145
|
+
: 1;
|
|
146
|
+
|
|
147
|
+
for (const file of incoming) {
|
|
148
|
+
if (!matchesAccept(file, options.accept)) {
|
|
149
|
+
rejected.push({
|
|
150
|
+
file,
|
|
151
|
+
code: "file-invalid-type",
|
|
152
|
+
message: rejectionMessage("file-invalid-type", { accept: options.accept }),
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (options.maxSize && file.size > options.maxSize) {
|
|
157
|
+
rejected.push({
|
|
158
|
+
file,
|
|
159
|
+
code: "file-too-large",
|
|
160
|
+
message: rejectionMessage("file-too-large", { maxSize: options.maxSize }),
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const fingerprint = fileFingerprint(file);
|
|
165
|
+
if (seen.has(fingerprint)) {
|
|
166
|
+
rejected.push({
|
|
167
|
+
file,
|
|
168
|
+
code: "duplicate-file",
|
|
169
|
+
message: rejectionMessage("duplicate-file"),
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (accepted.length >= remainingCapacity) {
|
|
174
|
+
rejected.push({
|
|
175
|
+
file,
|
|
176
|
+
code: "too-many-files",
|
|
177
|
+
message: rejectionMessage("too-many-files", { maxFiles: options.maxFiles }),
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
seen.add(fingerprint);
|
|
182
|
+
accepted.push(buildMediaFile(file));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { accepted, rejected };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ─── File kind detection ─────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
const EXTENSION_KIND_MAP: Record<string, MediaKind> = {
|
|
191
|
+
// documents
|
|
192
|
+
doc: "document",
|
|
193
|
+
docx: "document",
|
|
194
|
+
rtf: "document",
|
|
195
|
+
odt: "document",
|
|
196
|
+
pages: "document",
|
|
197
|
+
// spreadsheets
|
|
198
|
+
xls: "spreadsheet",
|
|
199
|
+
xlsx: "spreadsheet",
|
|
200
|
+
csv: "spreadsheet",
|
|
201
|
+
tsv: "spreadsheet",
|
|
202
|
+
ods: "spreadsheet",
|
|
203
|
+
numbers: "spreadsheet",
|
|
204
|
+
// presentations
|
|
205
|
+
ppt: "presentation",
|
|
206
|
+
pptx: "presentation",
|
|
207
|
+
odp: "presentation",
|
|
208
|
+
key: "presentation",
|
|
209
|
+
// archives
|
|
210
|
+
zip: "archive",
|
|
211
|
+
rar: "archive",
|
|
212
|
+
"7z": "archive",
|
|
213
|
+
tar: "archive",
|
|
214
|
+
gz: "archive",
|
|
215
|
+
bz2: "archive",
|
|
216
|
+
xz: "archive",
|
|
217
|
+
// code
|
|
218
|
+
js: "code",
|
|
219
|
+
jsx: "code",
|
|
220
|
+
ts: "code",
|
|
221
|
+
tsx: "code",
|
|
222
|
+
mjs: "code",
|
|
223
|
+
cjs: "code",
|
|
224
|
+
json: "code",
|
|
225
|
+
html: "code",
|
|
226
|
+
htm: "code",
|
|
227
|
+
css: "code",
|
|
228
|
+
scss: "code",
|
|
229
|
+
less: "code",
|
|
230
|
+
vue: "code",
|
|
231
|
+
svelte: "code",
|
|
232
|
+
py: "code",
|
|
233
|
+
rb: "code",
|
|
234
|
+
go: "code",
|
|
235
|
+
rs: "code",
|
|
236
|
+
java: "code",
|
|
237
|
+
c: "code",
|
|
238
|
+
cpp: "code",
|
|
239
|
+
h: "code",
|
|
240
|
+
cs: "code",
|
|
241
|
+
php: "code",
|
|
242
|
+
sh: "code",
|
|
243
|
+
yaml: "code",
|
|
244
|
+
yml: "code",
|
|
245
|
+
toml: "code",
|
|
246
|
+
xml: "code",
|
|
247
|
+
sql: "code",
|
|
248
|
+
// text
|
|
249
|
+
txt: "text",
|
|
250
|
+
md: "text",
|
|
251
|
+
markdown: "text",
|
|
252
|
+
log: "text",
|
|
253
|
+
// font
|
|
254
|
+
ttf: "font",
|
|
255
|
+
otf: "font",
|
|
256
|
+
woff: "font",
|
|
257
|
+
woff2: "font",
|
|
258
|
+
eot: "font",
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const MIME_KIND_MAP: Record<string, MediaKind> = {
|
|
262
|
+
"application/pdf": "pdf",
|
|
263
|
+
"application/msword": "document",
|
|
264
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "document",
|
|
265
|
+
"application/vnd.oasis.opendocument.text": "document",
|
|
266
|
+
"application/rtf": "document",
|
|
267
|
+
"application/vnd.ms-excel": "spreadsheet",
|
|
268
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "spreadsheet",
|
|
269
|
+
"application/vnd.oasis.opendocument.spreadsheet": "spreadsheet",
|
|
270
|
+
"text/csv": "spreadsheet",
|
|
271
|
+
"text/tab-separated-values": "spreadsheet",
|
|
272
|
+
"application/vnd.ms-powerpoint": "presentation",
|
|
273
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "presentation",
|
|
274
|
+
"application/vnd.oasis.opendocument.presentation": "presentation",
|
|
275
|
+
"application/zip": "archive",
|
|
276
|
+
"application/x-zip-compressed": "archive",
|
|
277
|
+
"application/x-rar-compressed": "archive",
|
|
278
|
+
"application/vnd.rar": "archive",
|
|
279
|
+
"application/x-7z-compressed": "archive",
|
|
280
|
+
"application/x-tar": "archive",
|
|
281
|
+
"application/gzip": "archive",
|
|
282
|
+
"application/x-gzip": "archive",
|
|
283
|
+
"application/x-bzip2": "archive",
|
|
284
|
+
"application/json": "code",
|
|
285
|
+
"application/xml": "code",
|
|
286
|
+
"application/sql": "code",
|
|
287
|
+
"application/x-yaml": "code",
|
|
288
|
+
"text/yaml": "code",
|
|
289
|
+
"text/html": "code",
|
|
290
|
+
"text/css": "code",
|
|
291
|
+
"text/javascript": "code",
|
|
292
|
+
"application/javascript": "code",
|
|
293
|
+
"application/typescript": "code",
|
|
294
|
+
"text/x-python": "code",
|
|
295
|
+
"text/markdown": "text",
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const fileExtension = (file: File): string => {
|
|
299
|
+
const dot = file.name.lastIndexOf(".");
|
|
300
|
+
if (dot === -1 || dot === file.name.length - 1) return "";
|
|
301
|
+
return file.name.slice(dot + 1).toLowerCase();
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Resolve a {@link MediaKind} from MIME type first (authoritative), then
|
|
306
|
+
* fall back to extension lookup. Always returns a value - `"other"` when
|
|
307
|
+
* nothing matches - so callers never need to handle `undefined`.
|
|
308
|
+
*/
|
|
309
|
+
export const previewKindOf = (file: File): MediaKind => {
|
|
310
|
+
if (isImageFile(file)) return "image";
|
|
311
|
+
if (isVideoFile(file)) return "video";
|
|
312
|
+
if (isAudioFile(file)) return "audio";
|
|
313
|
+
const mime = (file.type || "").toLowerCase();
|
|
314
|
+
if (mime.startsWith("font/")) return "font";
|
|
315
|
+
if (mime.startsWith("text/")) {
|
|
316
|
+
if (MIME_KIND_MAP[mime]) return MIME_KIND_MAP[mime];
|
|
317
|
+
return "text";
|
|
318
|
+
}
|
|
319
|
+
if (MIME_KIND_MAP[mime]) return MIME_KIND_MAP[mime];
|
|
320
|
+
|
|
321
|
+
const ext = fileExtension(file);
|
|
322
|
+
if (ext && EXTENSION_KIND_MAP[ext]) return EXTENSION_KIND_MAP[ext];
|
|
323
|
+
|
|
324
|
+
return "other";
|
|
325
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* OTP Input - N-cell variant of input-fields family.
|
|
2
|
+
Each cell reuses sCommon.field_default for border/focus/disabled tokens
|
|
3
|
+
so theme variants flow through unchanged. This module only sizes the
|
|
4
|
+
cell into a square and centers its single character. */
|
|
5
|
+
|
|
6
|
+
.cells {
|
|
7
|
+
display: inline-flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
gap: var(--spacing-4);
|
|
10
|
+
width: fit-content;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.cell {
|
|
14
|
+
--otp-cell-size: var(--spacing-12);
|
|
15
|
+
|
|
16
|
+
width: var(--otp-cell-size);
|
|
17
|
+
min-width: var(--otp-cell-size);
|
|
18
|
+
height: var(--otp-cell-size);
|
|
19
|
+
min-height: var(--otp-cell-size);
|
|
20
|
+
padding: 0;
|
|
21
|
+
text-align: center;
|
|
22
|
+
font-size: var(--font-size-lg);
|
|
23
|
+
font-variant-numeric: tabular-nums;
|
|
24
|
+
}
|