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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/api.d.ts +175 -0
  4. package/dist/api.js +1954 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/frameworks/astro/astro.config.snippet.md +23 -0
  7. package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
  8. package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
  9. package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
  10. package/dist/frameworks/next/layout-import.snippet.md +19 -0
  11. package/dist/frameworks/next/next.config.snippet.md +15 -0
  12. package/dist/frameworks/next/postcss.config.js.template +16 -0
  13. package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
  14. package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
  15. package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
  16. package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
  17. package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
  18. package/dist/index.js +2047 -0
  19. package/dist/index.js.map +1 -0
  20. package/package.json +63 -0
  21. package/registry/components/primitives/icon/icon.module.css +63 -0
  22. package/registry/components/primitives/icon/icon.tsx +53 -0
  23. package/registry/components/primitives/modal/modal-context.ts +19 -0
  24. package/registry/components/primitives/modal/modal.module.css +415 -0
  25. package/registry/components/primitives/modal/modal.tsx +550 -0
  26. package/registry/components/primitives/popover/popover-context.ts +28 -0
  27. package/registry/components/primitives/popover/popover.module.css +28 -0
  28. package/registry/components/primitives/popover/popover.tsx +465 -0
  29. package/registry/components/ui/accordion/accordion.module.css +208 -0
  30. package/registry/components/ui/accordion/accordion.tsx +327 -0
  31. package/registry/components/ui/avatar/avatar.module.css +32 -0
  32. package/registry/components/ui/avatar/avatar.tsx +37 -0
  33. package/registry/components/ui/badge/badge.module.css +103 -0
  34. package/registry/components/ui/badge/badge.tsx +29 -0
  35. package/registry/components/ui/button/button.module.css +216 -0
  36. package/registry/components/ui/button/button.tsx +133 -0
  37. package/registry/components/ui/chart/chart.module.css +168 -0
  38. package/registry/components/ui/chart/chart.tsx +1293 -0
  39. package/registry/components/ui/chart/types.ts +210 -0
  40. package/registry/components/ui/chart/utils.ts +59 -0
  41. package/registry/components/ui/copy-button/copy-button.module.css +21 -0
  42. package/registry/components/ui/copy-button/copy-button.tsx +123 -0
  43. package/registry/components/ui/countdown/countdown.module.css +5 -0
  44. package/registry/components/ui/countdown/countdown.tsx +152 -0
  45. package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
  46. package/registry/components/ui/data-table/data-table.module.css +594 -0
  47. package/registry/components/ui/data-table/data-table.tsx +1109 -0
  48. package/registry/components/ui/data-table/filter-bar.tsx +33 -0
  49. package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
  50. package/registry/components/ui/data-table/types.ts +594 -0
  51. package/registry/components/ui/data-table/useDataTable.tsx +573 -0
  52. package/registry/components/ui/data-table/utils.ts +445 -0
  53. package/registry/components/ui/empty-state/empty-state.module.css +26 -0
  54. package/registry/components/ui/empty-state/empty-state.tsx +32 -0
  55. package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
  56. package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
  57. package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
  58. package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
  59. package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
  60. package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
  61. package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
  62. package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
  63. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
  64. package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
  65. package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
  66. package/registry/components/ui/input-fields/common/types.ts +38 -0
  67. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
  68. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
  69. package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
  70. package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
  71. package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
  72. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
  73. package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
  74. package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
  75. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
  76. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
  77. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
  78. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
  79. package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
  80. package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
  81. package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
  82. package/registry/components/ui/input-fields/input/input.tsx +319 -0
  83. package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
  84. package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
  85. package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
  86. package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
  87. package/registry/components/ui/input-fields/media-input/types.ts +129 -0
  88. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
  89. package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
  90. package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
  91. package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
  92. package/registry/components/ui/input-fields/select/select.tsx +118 -0
  93. package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
  94. package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
  95. package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
  96. package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
  97. package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
  98. package/registry/components/ui/loader/loader.module.css +156 -0
  99. package/registry/components/ui/loader/loader.tsx +102 -0
  100. package/registry/components/ui/stepper/stepper.module.css +294 -0
  101. package/registry/components/ui/stepper/stepper.tsx +197 -0
  102. package/registry/components/ui/tabs/tabs.module.css +150 -0
  103. package/registry/components/ui/tabs/tabs.tsx +134 -0
  104. package/registry/components/ui/toast/toast.module.css +159 -0
  105. package/registry/components/ui/toast/toast.tsx +104 -0
  106. package/registry/components/ui/tooltip/tooltip.module.css +82 -0
  107. package/registry/components/ui/tooltip/tooltip.tsx +72 -0
  108. package/registry/global.d.ts +4 -0
  109. package/registry/hooks/useFocusOutside.tsx +33 -0
  110. package/registry/hooks/useResizeObserver.tsx +32 -0
  111. package/registry/lib/dom.ts +25 -0
  112. package/registry/lib/string.ts +23 -0
  113. package/registry/registry.json +719 -0
  114. package/registry/styles/app.css +240 -0
  115. package/registry/styles/components.css +78 -0
  116. package/registry/styles/fonts/geist-regular.woff2 +0 -0
  117. package/registry/styles/globals.css +7 -0
  118. package/registry/styles/media.css +25 -0
  119. package/registry/styles/utilities.css +90 -0
  120. package/registry/themes/colors/blue.css +37 -0
  121. package/registry/themes/colors/green.css +37 -0
  122. package/registry/themes/colors/rose.css +37 -0
  123. package/registry/themes/colors/slate.css +37 -0
  124. package/registry/themes/colors/stone.css +37 -0
  125. package/registry/themes/colors/violet.css +37 -0
  126. package/registry/themes/colors/zinc.css +37 -0
  127. package/registry/themes/radius/pill.css +8 -0
  128. package/registry/themes/radius/rounded.css +8 -0
  129. package/registry/themes/radius/sharp.css +8 -0
  130. package/registry/themes/spacing/compact.css +17 -0
  131. package/registry/themes/spacing/spacious.css +17 -0
  132. 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
+ }