@fivem-nui/svelte-components 0.0.0-stage → 1.0.0

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 (143) hide show
  1. package/README.md +101 -2
  2. package/dist/components/actions/NuiButton.svelte +292 -0
  3. package/dist/components/actions/NuiButton.svelte.d.ts +60 -0
  4. package/dist/components/data/NuiBadge.svelte +277 -0
  5. package/dist/components/data/NuiBadge.svelte.d.ts +74 -0
  6. package/dist/components/data/NuiCodeBlock.svelte +256 -0
  7. package/dist/components/data/NuiCodeBlock.svelte.d.ts +49 -0
  8. package/dist/components/data/NuiList.svelte +341 -0
  9. package/dist/components/data/NuiList.svelte.d.ts +52 -0
  10. package/dist/components/data/NuiProgressBar.svelte +264 -0
  11. package/dist/components/data/NuiProgressBar.svelte.d.ts +51 -0
  12. package/dist/components/data/NuiRichText.svelte +260 -0
  13. package/dist/components/data/NuiRichText.svelte.d.ts +33 -0
  14. package/dist/components/data/NuiTable.svelte +470 -0
  15. package/dist/components/data/NuiTable.svelte.d.ts +53 -0
  16. package/dist/components/data/chart/NuiChart.svelte +384 -0
  17. package/dist/components/data/chart/NuiChart.svelte.d.ts +49 -0
  18. package/dist/components/data/chart/color.d.ts +9 -0
  19. package/dist/components/data/chart/color.js +78 -0
  20. package/dist/components/data/chart/index.d.ts +10 -0
  21. package/dist/components/data/chart/index.js +10 -0
  22. package/dist/components/data/chart/registry.d.ts +14 -0
  23. package/dist/components/data/chart/registry.js +53 -0
  24. package/dist/components/data/chart/renderers/bar.d.ts +8 -0
  25. package/dist/components/data/chart/renderers/bar.js +79 -0
  26. package/dist/components/data/chart/renderers/base.d.ts +18 -0
  27. package/dist/components/data/chart/renderers/base.js +10 -0
  28. package/dist/components/data/chart/renderers/line.d.ts +8 -0
  29. package/dist/components/data/chart/renderers/line.js +89 -0
  30. package/dist/components/data/chart/renderers/pie.d.ts +10 -0
  31. package/dist/components/data/chart/renderers/pie.js +55 -0
  32. package/dist/components/data/chart/scale.d.ts +22 -0
  33. package/dist/components/data/chart/scale.js +101 -0
  34. package/dist/components/data/chart/types.d.ts +84 -0
  35. package/dist/components/data/chart/types.js +1 -0
  36. package/dist/components/data/chart/validator.d.ts +10 -0
  37. package/dist/components/data/chart/validator.js +82 -0
  38. package/dist/components/forms/NuiCheckbox.svelte +218 -0
  39. package/dist/components/forms/NuiCheckbox.svelte.d.ts +37 -0
  40. package/dist/components/forms/NuiDatepicker.svelte +755 -0
  41. package/dist/components/forms/NuiDatepicker.svelte.d.ts +34 -0
  42. package/dist/components/forms/NuiFileInput.svelte +1047 -0
  43. package/dist/components/forms/NuiFileInput.svelte.d.ts +93 -0
  44. package/dist/components/forms/NuiFileInput.types.d.ts +28 -0
  45. package/dist/components/forms/NuiFileInput.types.js +1 -0
  46. package/dist/components/forms/NuiForm.svelte +391 -0
  47. package/dist/components/forms/NuiForm.svelte.d.ts +69 -0
  48. package/dist/components/forms/NuiInput.svelte +296 -0
  49. package/dist/components/forms/NuiInput.svelte.d.ts +62 -0
  50. package/dist/components/forms/NuiRadioGroup.svelte +238 -0
  51. package/dist/components/forms/NuiRadioGroup.svelte.d.ts +47 -0
  52. package/dist/components/forms/NuiSelect.svelte +819 -0
  53. package/dist/components/forms/NuiSelect.svelte.d.ts +49 -0
  54. package/dist/components/layout/NuiCard.svelte +314 -0
  55. package/dist/components/layout/NuiCard.svelte.d.ts +48 -0
  56. package/dist/components/layout/NuiCardGrid.svelte +98 -0
  57. package/dist/components/layout/NuiCardGrid.svelte.d.ts +22 -0
  58. package/dist/components/layout/NuiFlex.svelte +121 -0
  59. package/dist/components/layout/NuiFlex.svelte.d.ts +23 -0
  60. package/dist/components/layout/NuiGrid.svelte +66 -0
  61. package/dist/components/layout/NuiGrid.svelte.d.ts +14 -0
  62. package/dist/components/layout/NuiStack.svelte +138 -0
  63. package/dist/components/layout/NuiStack.svelte.d.ts +26 -0
  64. package/dist/components/navigation/NuiAccordion.svelte +323 -0
  65. package/dist/components/navigation/NuiAccordion.svelte.d.ts +38 -0
  66. package/dist/components/navigation/NuiBreadcrumbs.svelte +133 -0
  67. package/dist/components/navigation/NuiBreadcrumbs.svelte.d.ts +19 -0
  68. package/dist/components/navigation/NuiMenu.svelte +596 -0
  69. package/dist/components/navigation/NuiMenu.svelte.d.ts +28 -0
  70. package/dist/components/navigation/NuiPagination.svelte +306 -0
  71. package/dist/components/navigation/NuiPagination.svelte.d.ts +40 -0
  72. package/dist/components/navigation/NuiSidebar.svelte +186 -0
  73. package/dist/components/navigation/NuiSidebar.svelte.d.ts +26 -0
  74. package/dist/components/navigation/NuiTabs.svelte +249 -0
  75. package/dist/components/navigation/NuiTabs.svelte.d.ts +31 -0
  76. package/dist/components/overlay/NuiDialog.svelte +118 -0
  77. package/dist/components/overlay/NuiDialog.svelte.d.ts +34 -0
  78. package/dist/components/overlay/NuiModal.svelte +992 -0
  79. package/dist/components/overlay/NuiModal.svelte.d.ts +72 -0
  80. package/dist/components/overlay/NuiTooltip.svelte +159 -0
  81. package/dist/components/overlay/NuiTooltip.svelte.d.ts +13 -0
  82. package/dist/composables/useClickOutside.d.ts +43 -0
  83. package/dist/composables/useClickOutside.js +73 -0
  84. package/dist/composables/useFormValidation.d.ts +23 -0
  85. package/dist/composables/useFormValidation.js +72 -0
  86. package/dist/composables/useIdle.svelte.d.ts +18 -0
  87. package/dist/composables/useIdle.svelte.js +144 -0
  88. package/dist/composables/useKeyboardShortcut.svelte.d.ts +114 -0
  89. package/dist/composables/useKeyboardShortcut.svelte.js +153 -0
  90. package/dist/composables/useLocalStorage.svelte.d.ts +22 -0
  91. package/dist/composables/useLocalStorage.svelte.js +105 -0
  92. package/dist/composables/useMediaQuery.svelte.d.ts +51 -0
  93. package/dist/composables/useMediaQuery.svelte.js +80 -0
  94. package/dist/index.d.ts +46 -0
  95. package/dist/index.js +42 -0
  96. package/package.json +58 -3
  97. package/src/components/actions/NuiButton.svelte +292 -0
  98. package/src/components/data/NuiBadge.svelte +277 -0
  99. package/src/components/data/NuiCodeBlock.svelte +256 -0
  100. package/src/components/data/NuiList.svelte +341 -0
  101. package/src/components/data/NuiProgressBar.svelte +264 -0
  102. package/src/components/data/NuiRichText.svelte +260 -0
  103. package/src/components/data/NuiTable.svelte +470 -0
  104. package/src/components/data/chart/NuiChart.svelte +384 -0
  105. package/src/components/data/chart/color.ts +83 -0
  106. package/src/components/data/chart/index.ts +10 -0
  107. package/src/components/data/chart/registry.ts +71 -0
  108. package/src/components/data/chart/renderers/bar.ts +90 -0
  109. package/src/components/data/chart/renderers/base.ts +32 -0
  110. package/src/components/data/chart/renderers/line.ts +99 -0
  111. package/src/components/data/chart/renderers/pie.ts +66 -0
  112. package/src/components/data/chart/scale.ts +139 -0
  113. package/src/components/data/chart/types.ts +98 -0
  114. package/src/components/data/chart/validator.ts +98 -0
  115. package/src/components/forms/NuiCheckbox.svelte +218 -0
  116. package/src/components/forms/NuiDatepicker.svelte +755 -0
  117. package/src/components/forms/NuiFileInput.svelte +1047 -0
  118. package/src/components/forms/NuiFileInput.types.ts +29 -0
  119. package/src/components/forms/NuiForm.svelte +391 -0
  120. package/src/components/forms/NuiInput.svelte +296 -0
  121. package/src/components/forms/NuiRadioGroup.svelte +238 -0
  122. package/src/components/forms/NuiSelect.svelte +819 -0
  123. package/src/components/layout/NuiCard.svelte +314 -0
  124. package/src/components/layout/NuiCardGrid.svelte +98 -0
  125. package/src/components/layout/NuiFlex.svelte +121 -0
  126. package/src/components/layout/NuiGrid.svelte +66 -0
  127. package/src/components/layout/NuiStack.svelte +138 -0
  128. package/src/components/navigation/NuiAccordion.svelte +323 -0
  129. package/src/components/navigation/NuiBreadcrumbs.svelte +133 -0
  130. package/src/components/navigation/NuiMenu.svelte +596 -0
  131. package/src/components/navigation/NuiPagination.svelte +306 -0
  132. package/src/components/navigation/NuiSidebar.svelte +186 -0
  133. package/src/components/navigation/NuiTabs.svelte +249 -0
  134. package/src/components/overlay/NuiDialog.svelte +118 -0
  135. package/src/components/overlay/NuiModal.svelte +992 -0
  136. package/src/components/overlay/NuiTooltip.svelte +159 -0
  137. package/src/composables/useClickOutside.ts +132 -0
  138. package/src/composables/useFormValidation.ts +105 -0
  139. package/src/composables/useIdle.svelte.ts +191 -0
  140. package/src/composables/useKeyboardShortcut.svelte.ts +278 -0
  141. package/src/composables/useLocalStorage.svelte.ts +137 -0
  142. package/src/composables/useMediaQuery.svelte.ts +102 -0
  143. package/src/index.ts +66 -0
@@ -0,0 +1,1047 @@
1
+ <!--
2
+ @component NuiFileInput
3
+ Accessible file input component conforming to W3C standards with drag-and-drop,
4
+ per-file size constraints, total file count limits, reactive preview generation,
5
+ memory leak-free ObjectURL management, and FiveM NUI event synchronization.
6
+
7
+ Important note for FiveM NUI:
8
+ Due to CEF browser sandbox and JSON serialization boundaries, the NUI event transport
9
+ sends clean file metadata (FileMetadata: id, name, size, type), NOT temporary browser
10
+ ObjectURLs ('blob:...') nor binary file contents. If the C# FiveM resource requires raw
11
+ file content, serialize it via FileReader (ArrayBuffer/base64) before transmission.
12
+ Client-side 'accept' and size validation serve as immediate UX guards; server/resource
13
+ handlers should always independently re-validate transmitted file data.
14
+ -->
15
+ <script module lang="ts">
16
+ export type { FileInfo, FileMetadata } from "./NuiFileInput.types";
17
+ </script>
18
+
19
+ <script lang="ts">
20
+ import { onDestroy } from "svelte";
21
+ import { nui } from "@fivem-nui/svelte";
22
+ import type { SvelteNuiClient } from "@fivem-nui/svelte";
23
+ import type { FileInfo, FileMetadata } from "./NuiFileInput.types";
24
+
25
+ interface Props {
26
+ /** Optional HTML id attribute. Auto-generated via $props.id() if omitted. */
27
+ id?: string;
28
+ /** Form input name attribute. */
29
+ name?: string;
30
+ /** Visual label text displayed above the control. */
31
+ label?: string;
32
+ /** Secondary description text displayed below label. */
33
+ description?: string;
34
+ /**
35
+ * Accepted file types, e.g. "image/*", ".pdf", "image/png,image/jpeg".
36
+ * Validates both MIME types and file extensions with fallback for empty MIME types.
37
+ * Evaluated during user interactions (file picker, drag & drop).
38
+ * Defaults to empty string (all file types accepted).
39
+ */
40
+ accept?: string;
41
+ /**
42
+ * Whether multiple file selection is enabled. Defaults to false.
43
+ * If dynamically switched at runtime, values are automatically normalized.
44
+ */
45
+ multiple?: boolean;
46
+ /**
47
+ * Maximum allowed file size in bytes per single file. Must be > 0.
48
+ * Evaluated during user interactions (file picker, drag & drop).
49
+ */
50
+ maxSize?: number;
51
+ /**
52
+ * Maximum allowed total number of files when multiple=true. Must be > 0.
53
+ * Evaluated during user interactions (file picker, drag & drop) and reactively derived
54
+ * as an external limit error if the parent supplies or configures a limit lower than
55
+ * the current file count (without destructively truncating the parent's external data).
56
+ */
57
+ maxFiles?: number;
58
+ /**
59
+ * Selected raw DOM File object or array of File objects.
60
+ * Serves as the single canonical source of truth for selected files.
61
+ * When multiple=false: File | null.
62
+ * When multiple=true: File[].
63
+ * Supports two-way binding.
64
+ */
65
+ value?: File | File[] | null;
66
+ /**
67
+ * Read-only metadata array of all currently selected files.
68
+ * Represents the active UI metadata, including temporary ObjectURLs when showPreview=true.
69
+ * Supports two-way binding.
70
+ */
71
+ files?: FileInfo[];
72
+ /** Whether the file input is disabled. */
73
+ disabled?: boolean;
74
+ /** Placeholder prompt text displayed in the dropzone when no files are chosen. */
75
+ placeholder?: string;
76
+ /** Whether to render preview list and generate image ObjectURLs. Defaults to true. */
77
+ showPreview?: boolean;
78
+ /** Accessible label for screen readers when visual label is omitted. */
79
+ ariaLabel?: string;
80
+ /** External validation error message displayed below the input. */
81
+ error?: string;
82
+ /**
83
+ * Optional NUI event name sent on file addition/removal.
84
+ * Transmits clean FileMetadata ({ fileInfos: FileMetadata[], count }).
85
+ */
86
+ eventName?: string;
87
+ /**
88
+ * Callback fired when the user adds or removes files through UI interactions
89
+ * (native file picker dialog, drag-and-drop, or remove button).
90
+ * Does not fire on external programmatic changes to the `value` prop to prevent circular update loops.
91
+ */
92
+ onChange?: (files: FileInfo[], rawFiles: File | File[] | null) => void;
93
+ /** Callback fired when validation fails (e.g. exceeds maxSize or maxFiles). */
94
+ onError?: (errorMessage: string) => void;
95
+ /** SvelteNuiClient instance for event transport. */
96
+ client?: SvelteNuiClient;
97
+ }
98
+
99
+ const autoId = $props.id();
100
+
101
+ let {
102
+ id,
103
+ name,
104
+ label = "File",
105
+ description,
106
+ accept = "",
107
+ multiple = false,
108
+ maxSize,
109
+ maxFiles,
110
+ value = $bindable(multiple ? [] : null),
111
+ files = $bindable([] as FileInfo[]),
112
+ disabled = false,
113
+ placeholder = "Click or drag files here",
114
+ showPreview = true,
115
+ ariaLabel,
116
+ error = "",
117
+ eventName = "",
118
+ onChange,
119
+ onError,
120
+ client = nui,
121
+ }: Props = $props();
122
+
123
+ // Internal representation coupling DOM File objects with FileInfo metadata
124
+ let items = $state<Array<{ id: string; file: File; info: FileInfo }>>([]);
125
+ // Registry tracking generated ObjectURLs keyed by file entry ID
126
+ const createdUrls = new Map<string, string>();
127
+
128
+ let interactionError = $state("");
129
+ let isDragOver = $state(false);
130
+
131
+ const controlId = $derived(id || autoId);
132
+ const inputId = $derived(`${controlId}-input`);
133
+ const labelId = $derived(label ? `${controlId}-label` : undefined);
134
+ const descId = $derived(description ? `${controlId}-desc` : undefined);
135
+
136
+ // Automatically derived validation error when selected files count exceeds maxFiles
137
+ const externalLimitError = $derived.by(() => {
138
+ if (multiple && isMaxFilesValid && maxFiles && items.length > maxFiles) {
139
+ return `File count (${items.length}) exceeds the maximum limit of ${maxFiles}.`;
140
+ }
141
+ return "";
142
+ });
143
+
144
+ const effectiveError = $derived(
145
+ error || interactionError || externalLimitError,
146
+ );
147
+ const errorId = $derived(effectiveError ? `${controlId}-error` : undefined);
148
+ const resolvedAriaLabel = $derived(
149
+ !label ? ariaLabel || placeholder : undefined,
150
+ );
151
+ const describedBy = $derived(
152
+ [descId, errorId].filter(Boolean).join(" ") || undefined,
153
+ );
154
+
155
+ // Validate numeric limits (ignoring non-finite, zero or negative values)
156
+ const isMaxSizeValid = $derived(
157
+ typeof maxSize === "number" && Number.isFinite(maxSize) && maxSize > 0,
158
+ );
159
+ const isMaxFilesValid = $derived(
160
+ typeof maxFiles === "number" && Number.isFinite(maxFiles) && maxFiles > 0,
161
+ );
162
+
163
+ /** Formats bytes into a human-readable string up to PB. */
164
+ function formatSize(bytes: number): string {
165
+ if (bytes <= 0 || !Number.isFinite(bytes)) return "0 B";
166
+ const k = 1024;
167
+ const sizes = ["B", "KB", "MB", "GB", "TB", "PB"];
168
+ const i = Math.min(
169
+ Math.floor(Math.log(bytes) / Math.log(k)),
170
+ sizes.length - 1,
171
+ );
172
+ const val = bytes / Math.pow(k, i);
173
+ return `${val >= 100 ? val.toFixed(0) : val >= 10 ? val.toFixed(1) : val.toFixed(2)} ${sizes[i]}`;
174
+ }
175
+
176
+ /** Pluralization helper for file count. */
177
+ function filePluralText(count: number): string {
178
+ return count === 1 ? "file selected" : "files selected";
179
+ }
180
+
181
+ /** Returns an emoji icon reflecting the MIME type of a file. */
182
+ //TODO change icons to font Awesome
183
+ function getFileIcon(mimeType: string): string {
184
+ if (mimeType.startsWith("image/")) return "image";
185
+ if (mimeType.startsWith("video/")) return "video";
186
+ if (mimeType.startsWith("audio/")) return "audio";
187
+ if (mimeType.includes("pdf")) return "pdf";
188
+ if (
189
+ mimeType.includes("zip") ||
190
+ mimeType.includes("compressed") ||
191
+ mimeType.includes("tar")
192
+ )
193
+ return "archive";
194
+ if (
195
+ mimeType.startsWith("text/") ||
196
+ mimeType.includes("json") ||
197
+ mimeType.includes("xml")
198
+ )
199
+ return "document";
200
+ return "file";
201
+ }
202
+
203
+ const hintText = $derived.by(() => {
204
+ const parts: string[] = [];
205
+ if (accept) parts.push(accept);
206
+ if (isMaxSizeValid && maxSize)
207
+ parts.push(`Max. ${formatSize(maxSize)} / file`);
208
+ if (multiple && isMaxFilesValid && maxFiles)
209
+ parts.push(`Max. ${maxFiles} files`);
210
+ return parts.join(" • ");
211
+ });
212
+
213
+ const MIME_EXT_FALLBACKS: Record<string, string[]> = {
214
+ "image/*": [
215
+ ".png",
216
+ ".jpg",
217
+ ".jpeg",
218
+ ".gif",
219
+ ".webp",
220
+ ".svg",
221
+ ".bmp",
222
+ ".ico",
223
+ ".avif",
224
+ ],
225
+ "video/*": [".mp4", ".webm", ".ogg", ".mov", ".avi", ".mkv"],
226
+ "audio/*": [".mp3", ".wav", ".ogg", ".aac", ".flac", ".m4a"],
227
+ "application/pdf": [".pdf"],
228
+ "text/plain": [".txt", ".log"],
229
+ };
230
+
231
+ /**
232
+ * Duck-typing guard for File and File-like objects.
233
+ * Validates that the object implements the core File interface properties (name, size, type, slice),
234
+ * supporting native File instances as well as cross-realm / iframe File-like implementations.
235
+ */
236
+ function isFileLike(val: unknown): val is File {
237
+ if (!val || typeof val !== "object") return false;
238
+ if (typeof File !== "undefined" && val instanceof File) return true;
239
+ return (
240
+ typeof (val as File).name === "string" &&
241
+ typeof (val as File).size === "number" &&
242
+ typeof (val as File).type === "string" &&
243
+ typeof (val as File).slice === "function"
244
+ );
245
+ }
246
+
247
+ /** Validates whether a file matches accept rules with fallback for empty or generic MIME types. */
248
+ function isFileTypeAccepted(file: File, rules: string[]): boolean {
249
+ if (rules.length === 0) return true;
250
+ const fileName = file.name.toLowerCase();
251
+ const fileType = file.type ? file.type.toLowerCase() : "";
252
+ const isGenericMime = !fileType || fileType === "application/octet-stream";
253
+
254
+ return rules.some((rule) => {
255
+ // 1. File extension rule, eg. '.pdf'
256
+ if (rule.startsWith(".")) {
257
+ return fileName.endsWith(rule);
258
+ }
259
+
260
+ if (fileType && fileType === rule) {
261
+ return true;
262
+ }
263
+
264
+ // 3. Wildcard MIME match, eg. 'image/*'
265
+ if (rule.endsWith("/*")) {
266
+ const prefix = rule.slice(0, -2);
267
+ if (fileType && fileType.startsWith(prefix)) {
268
+ return true;
269
+ }
270
+ }
271
+
272
+ // Fallback for empty or generic (application/octet-stream) MIME types based on extension
273
+ if (isGenericMime) {
274
+ const fallbacks = MIME_EXT_FALLBACKS[rule];
275
+ if (fallbacks && fallbacks.some((ext) => fileName.endsWith(ext))) {
276
+ return true;
277
+ }
278
+ }
279
+
280
+ return false;
281
+ });
282
+ }
283
+
284
+ function createItem(file: File): { id: string; file: File; info: FileInfo } {
285
+ const itemId = `nui-file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
286
+ let url: string | undefined;
287
+
288
+ if (
289
+ showPreview &&
290
+ file.type.startsWith("image/") &&
291
+ typeof URL !== "undefined"
292
+ ) {
293
+ try {
294
+ url = URL.createObjectURL(file);
295
+ createdUrls.set(itemId, url);
296
+ } catch {
297
+ url = undefined;
298
+ }
299
+ }
300
+
301
+ const info: FileInfo = {
302
+ id: itemId,
303
+ name: file.name,
304
+ size: file.size,
305
+ type: file.type || "application/octet-stream",
306
+ url,
307
+ };
308
+
309
+ return { id: itemId, file, info };
310
+ }
311
+
312
+ function revokeItemUrl(itemId: string) {
313
+ const url = createdUrls.get(itemId);
314
+ if (url && typeof URL !== "undefined") {
315
+ try {
316
+ URL.revokeObjectURL(url);
317
+ } catch {
318
+ // ignore cleanup error
319
+ }
320
+ createdUrls.delete(itemId);
321
+ }
322
+ }
323
+
324
+ function revokeAllUrls() {
325
+ if (typeof URL !== "undefined") {
326
+ for (const url of createdUrls.values()) {
327
+ try {
328
+ URL.revokeObjectURL(url);
329
+ } catch {
330
+ // ignore cleanup error
331
+ }
332
+ }
333
+ }
334
+ createdUrls.clear();
335
+ }
336
+
337
+ /**
338
+ * Synchronize state when 'value' or 'multiple' is externally updated by parent component.
339
+ * Treats 'value' as the canonical single source of truth, normalizes types, and deduplicates.
340
+ */
341
+ $effect(() => {
342
+ // 1. Single-file mode (multiple = false)
343
+ if (!multiple) {
344
+ let targetFile: File | null = null;
345
+ if (isFileLike(value)) {
346
+ targetFile = value;
347
+ } else if (Array.isArray(value) && value.length > 0) {
348
+ const validFirst = value.find(isFileLike);
349
+ targetFile = validFirst ?? null;
350
+ value = targetFile;
351
+ } else if (value !== null) {
352
+ value = null;
353
+ }
354
+
355
+ const currentFile = items[0]?.file ?? null;
356
+ const needsSync =
357
+ targetFile !== currentFile || items.length !== (targetFile ? 1 : 0);
358
+
359
+ if (needsSync) {
360
+ interactionError = "";
361
+ if (targetFile && targetFile === currentFile && items.length > 1) {
362
+ for (let i = 1; i < items.length; i++) {
363
+ revokeItemUrl(items[i].id);
364
+ }
365
+ items = [items[0]];
366
+ files = [items[0].info];
367
+ } else {
368
+ revokeAllUrls();
369
+ if (targetFile) {
370
+ const item = createItem(targetFile);
371
+ items = [item];
372
+ files = [item.info];
373
+ } else {
374
+ items = [];
375
+ files = [];
376
+ }
377
+ }
378
+ }
379
+ return;
380
+ }
381
+
382
+ let targetFiles: File[] = [];
383
+ if (Array.isArray(value)) {
384
+ const incomingArray = value;
385
+ const validFiles = incomingArray.filter(isFileLike);
386
+ // Deduplicate identical File instances to maintain unique keys and prevent duplicate IDs
387
+ targetFiles = Array.from(new Set(validFiles));
388
+
389
+ if (
390
+ targetFiles.length !== incomingArray.length ||
391
+ targetFiles.some((f, idx) => f !== incomingArray[idx])
392
+ ) {
393
+ value = targetFiles;
394
+ }
395
+ } else if (isFileLike(value)) {
396
+ // Mode switched from single: wrap existing File in array
397
+ targetFiles = [value];
398
+ value = targetFiles;
399
+ } else if (value !== null) {
400
+ // Normalize invalid scalar or undefined to empty array for multiple mode
401
+ value = [];
402
+ targetFiles = [];
403
+ } else {
404
+ value = [];
405
+ targetFiles = [];
406
+ }
407
+
408
+ const currentFiles = items.map((it) => it.file);
409
+ const isIdentical =
410
+ targetFiles.length === currentFiles.length &&
411
+ targetFiles.every((f, idx) => f === currentFiles[idx]);
412
+
413
+ if (!isIdentical) {
414
+ interactionError = "";
415
+ const targetSet = new Set(targetFiles);
416
+
417
+ // Clean up URLs for removed files
418
+ for (const item of items) {
419
+ if (!targetSet.has(item.file)) {
420
+ revokeItemUrl(item.id);
421
+ }
422
+ }
423
+
424
+ // Preserve existing items for matching files to avoid re-creating object URLs
425
+ const existingByFile = new Map<
426
+ File,
427
+ { id: string; file: File; info: FileInfo }
428
+ >();
429
+ for (const item of items) {
430
+ if (targetSet.has(item.file) && !existingByFile.has(item.file)) {
431
+ existingByFile.set(item.file, item);
432
+ }
433
+ }
434
+
435
+ const nextItems = targetFiles.map((file) => {
436
+ const existing = existingByFile.get(file);
437
+ if (existing) {
438
+ existingByFile.delete(file);
439
+ return existing;
440
+ }
441
+ return createItem(file);
442
+ });
443
+
444
+ items = nextItems;
445
+ files = nextItems.map((it) => it.info);
446
+ }
447
+ });
448
+
449
+ /**
450
+ * Reactively manage ObjectURL resource lifecycle when 'showPreview' prop changes.
451
+ * Generates URLs if enabled, or immediately revokes them to prevent memory waste if disabled.
452
+ */
453
+ $effect(() => {
454
+ if (showPreview) {
455
+ let changed = false;
456
+ for (const item of items) {
457
+ if (
458
+ item.file.type.startsWith("image/") &&
459
+ !item.info.url &&
460
+ typeof URL !== "undefined"
461
+ ) {
462
+ try {
463
+ const url = URL.createObjectURL(item.file);
464
+ createdUrls.set(item.id, url);
465
+ item.info.url = url;
466
+ changed = true;
467
+ } catch {
468
+ // ignore preview generation error
469
+ }
470
+ }
471
+ }
472
+ if (changed) {
473
+ files = items.map((it) => it.info);
474
+ }
475
+ } else {
476
+ if (createdUrls.size > 0) {
477
+ revokeAllUrls();
478
+ for (const item of items) {
479
+ item.info.url = undefined;
480
+ }
481
+ files = items.map((it) => it.info);
482
+ }
483
+ }
484
+ });
485
+
486
+ onDestroy(() => {
487
+ revokeAllUrls();
488
+ });
489
+
490
+ function sendNuiEvent() {
491
+ if (!eventName) return;
492
+ try {
493
+ // Build clean metadata directly from internal authoritative items (decoupled from bindable files)
494
+ const fileInfos: FileMetadata[] = items.map(({ info }) => ({
495
+ id: info.id,
496
+ name: info.name,
497
+ size: info.size,
498
+ type: info.type,
499
+ }));
500
+
501
+ const res = client.send(eventName, {
502
+ fileInfos,
503
+ count: fileInfos.length,
504
+ }) as unknown;
505
+
506
+ // Handle cases where client.send returns a Promise (e.g. custom or mock clients)
507
+ if (res && typeof (res as Promise<unknown>).catch === "function") {
508
+ (res as Promise<unknown>).catch((err: unknown) => {
509
+ console.error(
510
+ `[fivem-nui-components] Async error sending file input event '${eventName}':`,
511
+ err,
512
+ );
513
+ });
514
+ }
515
+ } catch (err) {
516
+ console.error(
517
+ `[fivem-nui-components] Failed to send file input event '${eventName}':`,
518
+ err,
519
+ );
520
+ }
521
+ }
522
+
523
+ function processFiles(incomingFiles: File[]) {
524
+ interactionError = "";
525
+
526
+ if (disabled || incomingFiles.length === 0) return;
527
+
528
+ // Reject multiple files in single-file mode
529
+ if (!multiple && incomingFiles.length > 1) {
530
+ interactionError =
531
+ "Only a single file can be selected in single-file mode.";
532
+ onError?.(interactionError);
533
+ return;
534
+ }
535
+
536
+ // 1. Validate accept constraint if specified
537
+ if (accept) {
538
+ const acceptRules = accept
539
+ .split(",")
540
+ .map((s) => s.trim().toLowerCase())
541
+ .filter(Boolean);
542
+
543
+ if (acceptRules.length > 0) {
544
+ const invalidFile = incomingFiles.find(
545
+ (f) => !isFileTypeAccepted(f, acceptRules),
546
+ );
547
+ if (invalidFile) {
548
+ interactionError = `Invalid file format: "${invalidFile.name}". Allowed: ${accept}`;
549
+ onError?.(interactionError);
550
+ return;
551
+ }
552
+ }
553
+ }
554
+
555
+ // 2. Validate maxSize (per-file limit)
556
+ if (isMaxSizeValid && maxSize) {
557
+ const tooLarge = incomingFiles.find((f) => f.size > maxSize);
558
+ if (tooLarge) {
559
+ interactionError = `File "${tooLarge.name}" (${formatSize(tooLarge.size)}) exceeds maximum size limit (${formatSize(maxSize)}).`;
560
+ onError?.(interactionError);
561
+ return;
562
+ }
563
+ }
564
+
565
+ // 3. Validate maxFiles (cumulative total count in multiple mode)
566
+ if (multiple && isMaxFilesValid && maxFiles) {
567
+ const totalCount = items.length + incomingFiles.length;
568
+ if (totalCount > maxFiles) {
569
+ interactionError = `File limit exceeded (${maxFiles}). Currently selected: ${items.length}, attempted to add: ${incomingFiles.length}.`;
570
+ onError?.(interactionError);
571
+ return;
572
+ }
573
+ }
574
+
575
+ // 4. Update items and value
576
+ if (!multiple) {
577
+ // In single mode, revoke old URLs BEFORE creating the new item's URL
578
+ revokeAllUrls();
579
+ const newItem = createItem(incomingFiles[0]);
580
+ items = [newItem];
581
+ files = [newItem.info];
582
+ value = newItem.file;
583
+ } else {
584
+ const newItems = incomingFiles.map((f) => createItem(f));
585
+ items = [...items, ...newItems];
586
+ files = items.map((it) => it.info);
587
+ value = items.map((it) => it.file);
588
+ }
589
+
590
+ sendNuiEvent();
591
+ onChange?.(files, value);
592
+ }
593
+
594
+ function handleFileChange(event: Event) {
595
+ const input = event.target as HTMLInputElement;
596
+ const selected = Array.from(input.files ?? []);
597
+ input.value = "";
598
+ if (selected.length === 0) return;
599
+ processFiles(selected);
600
+ }
601
+
602
+ function removeFile(index: number) {
603
+ if (disabled || index < 0 || index >= items.length) return;
604
+
605
+ interactionError = "";
606
+ const itemToRemove = items[index];
607
+ if (itemToRemove) {
608
+ revokeItemUrl(itemToRemove.id);
609
+ }
610
+
611
+ const nextItems = items.filter((_, i) => i !== index);
612
+ items = nextItems;
613
+
614
+ if (multiple) {
615
+ files = nextItems.map((it) => it.info);
616
+ value = nextItems.map((it) => it.file);
617
+ } else {
618
+ files = [];
619
+ value = null;
620
+ }
621
+
622
+ sendNuiEvent();
623
+ onChange?.(files, value);
624
+ }
625
+
626
+ function handleDragOver(event: DragEvent) {
627
+ if (disabled) return;
628
+ event.preventDefault();
629
+ event.stopPropagation();
630
+ isDragOver = true;
631
+ }
632
+
633
+ function handleDragEnter(event: DragEvent) {
634
+ if (disabled) return;
635
+ event.preventDefault();
636
+ event.stopPropagation();
637
+ isDragOver = true;
638
+ }
639
+
640
+ function handleDragLeave(event: DragEvent) {
641
+ if (disabled) return;
642
+ event.preventDefault();
643
+ event.stopPropagation();
644
+ if (event.currentTarget === event.target) {
645
+ isDragOver = false;
646
+ }
647
+ }
648
+
649
+ function handleDrop(event: DragEvent) {
650
+ if (disabled) return;
651
+ event.preventDefault();
652
+ event.stopPropagation();
653
+ isDragOver = false;
654
+
655
+ if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
656
+ const droppedFiles = Array.from(event.dataTransfer.files);
657
+ processFiles(droppedFiles);
658
+ }
659
+ }
660
+ </script>
661
+
662
+ <div class="nui-file-input" class:is-disabled={disabled}>
663
+ {#if label}
664
+ <label class="nui-file-input__label" id={labelId} for={inputId}>
665
+ {label}
666
+ </label>
667
+ {/if}
668
+
669
+ {#if description}
670
+ <span class="nui-file-input__description" id={descId}>
671
+ {description}
672
+ </span>
673
+ {/if}
674
+
675
+ <label
676
+ class="nui-file-input__dropzone"
677
+ class:is-dragover={isDragOver}
678
+ class:is-disabled={disabled}
679
+ class:is-invalid={Boolean(effectiveError)}
680
+ for={inputId}
681
+ ondragover={handleDragOver}
682
+ ondragenter={handleDragEnter}
683
+ ondragleave={handleDragLeave}
684
+ ondrop={handleDrop}
685
+ >
686
+ <input
687
+ id={inputId}
688
+ type="file"
689
+ class="nui-file-input__native"
690
+ {name}
691
+ accept={accept || undefined}
692
+ {multiple}
693
+ {disabled}
694
+ onchange={handleFileChange}
695
+ aria-labelledby={labelId}
696
+ aria-label={labelId ? undefined : resolvedAriaLabel}
697
+ aria-describedby={describedBy}
698
+ aria-invalid={effectiveError ? "true" : undefined}
699
+ />
700
+
701
+ <div class="nui-file-input__control">
702
+ <span class="nui-file-input__icon" aria-hidden="true">📎</span>
703
+ <div class="nui-file-input__content">
704
+ <span class="nui-file-input__text">
705
+ {#if items.length > 0}
706
+ {items.length} {filePluralText(items.length)}
707
+ {:else}
708
+ {placeholder}
709
+ {/if}
710
+ </span>
711
+ {#if hintText}
712
+ <span class="nui-file-input__hint">{hintText}</span>
713
+ {/if}
714
+ </div>
715
+ {#if !disabled}
716
+ <span class="nui-file-input__action-btn" aria-hidden="true">
717
+ Browse
718
+ </span>
719
+ {/if}
720
+ </div>
721
+ </label>
722
+
723
+ {#if effectiveError}
724
+ <div
725
+ class="nui-file-input__error"
726
+ id={errorId}
727
+ role="alert"
728
+ aria-live="polite"
729
+ >
730
+ <span class="nui-file-input__error-icon" aria-hidden="true">⚠️</span>
731
+ <span>{effectiveError}</span>
732
+ </div>
733
+ {/if}
734
+
735
+ {#if items.length > 0 && showPreview}
736
+ <ul class="nui-file-input__preview" aria-label="Selected files">
737
+ {#each items as item, i (item.id)}
738
+ <li class="nui-file-input__preview-item">
739
+ {#if item.info.url}
740
+ <img
741
+ src={item.info.url}
742
+ alt={item.info.name}
743
+ class="nui-file-input__preview-thumb"
744
+ />
745
+ {:else}
746
+ <span class="nui-file-input__preview-icon" aria-hidden="true">
747
+ {getFileIcon(item.file.type)}
748
+ </span>
749
+ {/if}
750
+ <div class="nui-file-input__preview-details">
751
+ <span class="nui-file-input__preview-name" title={item.info.name}>
752
+ {item.info.name}
753
+ </span>
754
+ <span class="nui-file-input__preview-size">
755
+ {formatSize(item.info.size)}
756
+ </span>
757
+ </div>
758
+ <button
759
+ type="button"
760
+ class="nui-file-input__remove"
761
+ aria-label="Remove file {item.info.name}"
762
+ title="Remove file {item.info.name}"
763
+ {disabled}
764
+ onclick={(e) => {
765
+ e.preventDefault();
766
+ e.stopPropagation();
767
+ removeFile(i);
768
+ }}
769
+ >
770
+ ×
771
+ </button>
772
+ </li>
773
+ {/each}
774
+ </ul>
775
+ {/if}
776
+ </div>
777
+
778
+ <style>
779
+ .nui-file-input {
780
+ display: flex;
781
+ flex-direction: column;
782
+ gap: 0.35rem;
783
+ width: 100%;
784
+ font-family: inherit;
785
+ box-sizing: border-box;
786
+ }
787
+
788
+ .nui-file-input__label {
789
+ display: block;
790
+ font-size: 0.8125rem;
791
+ font-weight: 600;
792
+ color: var(--nui-fg, #fafafa);
793
+ opacity: 0.85;
794
+ cursor: pointer;
795
+ }
796
+
797
+ .nui-file-input__description {
798
+ font-size: 0.75rem;
799
+ color: var(--nui-fg-secondary, #a1a1aa);
800
+ line-height: 1.25;
801
+ }
802
+
803
+ .nui-file-input__dropzone {
804
+ position: relative;
805
+ display: block;
806
+ border: 1px dashed var(--nui-border, #3f3f46);
807
+ border-radius: var(--nui-radius, 0.375rem);
808
+ background: var(--nui-surface-alt, #101012);
809
+ cursor: pointer;
810
+ box-sizing: border-box;
811
+ transition:
812
+ border-color 0.15s ease,
813
+ background-color 0.15s ease,
814
+ box-shadow 0.15s ease;
815
+ }
816
+
817
+ .nui-file-input__dropzone:hover:not(.is-disabled) {
818
+ border-color: var(--nui-primary, #3b82f6);
819
+ background: var(--nui-surface, #18181b);
820
+ }
821
+
822
+ .nui-file-input__dropzone.is-dragover {
823
+ border-color: var(--nui-primary, #3b82f6);
824
+ background: var(--nui-surface, #18181b);
825
+ border-style: solid;
826
+ box-shadow: 0 0 0 2px rgb(59 130 246 / 0.25);
827
+ }
828
+
829
+ .nui-file-input__dropzone.is-invalid {
830
+ border-color: var(--nui-danger, #ef4444);
831
+ border-style: solid;
832
+ }
833
+
834
+ .nui-file-input__dropzone:focus-within {
835
+ border-color: var(--nui-primary, #3b82f6);
836
+ outline: 2px solid var(--nui-primary, #3b82f6);
837
+ outline-offset: 2px;
838
+ }
839
+
840
+ .nui-file-input__dropzone.is-invalid:focus-within {
841
+ border-color: var(--nui-danger, #ef4444);
842
+ outline-color: var(--nui-danger, #ef4444);
843
+ }
844
+
845
+ .nui-file-input__dropzone.is-disabled {
846
+ opacity: 0.45;
847
+ cursor: not-allowed;
848
+ }
849
+
850
+ /* Visually hidden native input allowing focus and keyboard interaction */
851
+ .nui-file-input__native {
852
+ position: absolute;
853
+ width: 1px;
854
+ height: 1px;
855
+ padding: 0;
856
+ margin: -1px;
857
+ overflow: hidden;
858
+ clip: rect(0, 0, 0, 0);
859
+ white-space: nowrap;
860
+ border: 0;
861
+ }
862
+
863
+ .nui-file-input__control {
864
+ display: flex;
865
+ align-items: center;
866
+ gap: 0.75rem;
867
+ padding: 0.75rem 1rem;
868
+ box-sizing: border-box;
869
+ }
870
+
871
+ .nui-file-input__icon {
872
+ font-size: 1.25rem;
873
+ opacity: 0.85;
874
+ user-select: none;
875
+ flex-shrink: 0;
876
+ }
877
+
878
+ .nui-file-input__content {
879
+ flex: 1;
880
+ display: flex;
881
+ flex-direction: column;
882
+ gap: 0.15rem;
883
+ min-width: 0;
884
+ }
885
+
886
+ .nui-file-input__text {
887
+ font-size: 0.875rem;
888
+ font-weight: 500;
889
+ color: var(--nui-fg, #fafafa);
890
+ overflow: hidden;
891
+ text-overflow: ellipsis;
892
+ white-space: nowrap;
893
+ }
894
+
895
+ .nui-file-input__hint {
896
+ font-size: 0.75rem;
897
+ color: var(--nui-fg-secondary, #a1a1aa);
898
+ overflow: hidden;
899
+ text-overflow: ellipsis;
900
+ white-space: nowrap;
901
+ }
902
+
903
+ .nui-file-input__action-btn {
904
+ font-size: 0.75rem;
905
+ font-weight: 600;
906
+ padding: 0.35rem 0.65rem;
907
+ border-radius: var(--nui-radius, 0.25rem);
908
+ background: var(--nui-surface, #18181b);
909
+ border: 1px solid var(--nui-border, #3f3f46);
910
+ color: var(--nui-fg, #fafafa);
911
+ flex-shrink: 0;
912
+ user-select: none;
913
+ transition:
914
+ background-color 0.15s ease,
915
+ border-color 0.15s ease;
916
+ }
917
+
918
+ .nui-file-input__dropzone:hover:not(.is-disabled)
919
+ .nui-file-input__action-btn {
920
+ border-color: var(--nui-primary, #3b82f6);
921
+ background: var(--nui-primary, #3b82f6);
922
+ color: #fff;
923
+ }
924
+
925
+ .nui-file-input__error {
926
+ display: flex;
927
+ align-items: center;
928
+ gap: 0.4rem;
929
+ font-size: 0.75rem;
930
+ color: var(--nui-danger, #ef4444);
931
+ font-weight: 500;
932
+ margin-top: 0.1rem;
933
+ }
934
+
935
+ .nui-file-input__error-icon {
936
+ font-size: 0.875rem;
937
+ flex-shrink: 0;
938
+ }
939
+
940
+ .nui-file-input__preview {
941
+ list-style: none;
942
+ margin: 0;
943
+ padding: 0.25rem 0 0 0;
944
+ gap: 0.375rem;
945
+ display: flex;
946
+ flex-direction: column;
947
+ box-sizing: border-box;
948
+ }
949
+
950
+ .nui-file-input__preview-item {
951
+ display: flex;
952
+ align-items: center;
953
+ gap: 0.6rem;
954
+ padding: 0.45rem 0.65rem;
955
+ background: var(--nui-surface-alt, #18181b);
956
+ border: 1px solid var(--nui-border, #27272a);
957
+ border-radius: var(--nui-radius, 0.25rem);
958
+ font-size: 0.8125rem;
959
+ box-sizing: border-box;
960
+ transition: border-color 0.15s ease;
961
+ }
962
+
963
+ .nui-file-input__preview-item:hover {
964
+ border-color: var(--nui-border-hover, #3f3f46);
965
+ }
966
+
967
+ .nui-file-input__preview-thumb {
968
+ width: 2rem;
969
+ height: 2rem;
970
+ border-radius: var(--nui-radius, 0.25rem);
971
+ object-fit: cover;
972
+ flex-shrink: 0;
973
+ border: 1px solid var(--nui-border, #27272a);
974
+ }
975
+
976
+ .nui-file-input__preview-icon {
977
+ font-size: 1.15rem;
978
+ flex-shrink: 0;
979
+ user-select: none;
980
+ display: flex;
981
+ align-items: center;
982
+ justify-content: center;
983
+ width: 2rem;
984
+ height: 2rem;
985
+ }
986
+
987
+ .nui-file-input__preview-details {
988
+ flex: 1;
989
+ display: flex;
990
+ flex-direction: column;
991
+ min-width: 0;
992
+ }
993
+
994
+ .nui-file-input__preview-name {
995
+ overflow: hidden;
996
+ text-overflow: ellipsis;
997
+ white-space: nowrap;
998
+ color: var(--nui-fg, #fafafa);
999
+ font-weight: 500;
1000
+ }
1001
+
1002
+ .nui-file-input__preview-size {
1003
+ font-size: 0.72rem;
1004
+ color: var(--nui-fg-secondary, #a1a1aa);
1005
+ }
1006
+
1007
+ .nui-file-input__remove {
1008
+ border: 1px solid transparent;
1009
+ border-radius: var(--nui-radius, 0.25rem);
1010
+ background: transparent;
1011
+ color: var(--nui-danger, #ef4444);
1012
+ font-size: 1.25rem;
1013
+ cursor: pointer;
1014
+ padding: 0.15rem 0.35rem;
1015
+ line-height: 1;
1016
+ display: inline-flex;
1017
+ align-items: center;
1018
+ justify-content: center;
1019
+ transition:
1020
+ background-color 0.15s ease,
1021
+ border-color 0.15s ease;
1022
+ }
1023
+
1024
+ .nui-file-input__remove:hover:not(:disabled) {
1025
+ background: rgb(239 68 68 / 0.15);
1026
+ border-color: rgb(239 68 68 / 0.3);
1027
+ }
1028
+
1029
+ .nui-file-input__remove:focus-visible {
1030
+ outline: 2px solid var(--nui-danger, #ef4444);
1031
+ outline-offset: 1px;
1032
+ }
1033
+
1034
+ .nui-file-input__remove:disabled {
1035
+ opacity: 0.3;
1036
+ cursor: not-allowed;
1037
+ }
1038
+
1039
+ @media (prefers-reduced-motion: reduce) {
1040
+ .nui-file-input__dropzone,
1041
+ .nui-file-input__action-btn,
1042
+ .nui-file-input__preview-item,
1043
+ .nui-file-input__remove {
1044
+ transition: none;
1045
+ }
1046
+ }
1047
+ </style>