@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.
- package/README.md +101 -2
- package/dist/components/actions/NuiButton.svelte +292 -0
- package/dist/components/actions/NuiButton.svelte.d.ts +60 -0
- package/dist/components/data/NuiBadge.svelte +277 -0
- package/dist/components/data/NuiBadge.svelte.d.ts +74 -0
- package/dist/components/data/NuiCodeBlock.svelte +256 -0
- package/dist/components/data/NuiCodeBlock.svelte.d.ts +49 -0
- package/dist/components/data/NuiList.svelte +341 -0
- package/dist/components/data/NuiList.svelte.d.ts +52 -0
- package/dist/components/data/NuiProgressBar.svelte +264 -0
- package/dist/components/data/NuiProgressBar.svelte.d.ts +51 -0
- package/dist/components/data/NuiRichText.svelte +260 -0
- package/dist/components/data/NuiRichText.svelte.d.ts +33 -0
- package/dist/components/data/NuiTable.svelte +470 -0
- package/dist/components/data/NuiTable.svelte.d.ts +53 -0
- package/dist/components/data/chart/NuiChart.svelte +384 -0
- package/dist/components/data/chart/NuiChart.svelte.d.ts +49 -0
- package/dist/components/data/chart/color.d.ts +9 -0
- package/dist/components/data/chart/color.js +78 -0
- package/dist/components/data/chart/index.d.ts +10 -0
- package/dist/components/data/chart/index.js +10 -0
- package/dist/components/data/chart/registry.d.ts +14 -0
- package/dist/components/data/chart/registry.js +53 -0
- package/dist/components/data/chart/renderers/bar.d.ts +8 -0
- package/dist/components/data/chart/renderers/bar.js +79 -0
- package/dist/components/data/chart/renderers/base.d.ts +18 -0
- package/dist/components/data/chart/renderers/base.js +10 -0
- package/dist/components/data/chart/renderers/line.d.ts +8 -0
- package/dist/components/data/chart/renderers/line.js +89 -0
- package/dist/components/data/chart/renderers/pie.d.ts +10 -0
- package/dist/components/data/chart/renderers/pie.js +55 -0
- package/dist/components/data/chart/scale.d.ts +22 -0
- package/dist/components/data/chart/scale.js +101 -0
- package/dist/components/data/chart/types.d.ts +84 -0
- package/dist/components/data/chart/types.js +1 -0
- package/dist/components/data/chart/validator.d.ts +10 -0
- package/dist/components/data/chart/validator.js +82 -0
- package/dist/components/forms/NuiCheckbox.svelte +218 -0
- package/dist/components/forms/NuiCheckbox.svelte.d.ts +37 -0
- package/dist/components/forms/NuiDatepicker.svelte +755 -0
- package/dist/components/forms/NuiDatepicker.svelte.d.ts +34 -0
- package/dist/components/forms/NuiFileInput.svelte +1047 -0
- package/dist/components/forms/NuiFileInput.svelte.d.ts +93 -0
- package/dist/components/forms/NuiFileInput.types.d.ts +28 -0
- package/dist/components/forms/NuiFileInput.types.js +1 -0
- package/dist/components/forms/NuiForm.svelte +391 -0
- package/dist/components/forms/NuiForm.svelte.d.ts +69 -0
- package/dist/components/forms/NuiInput.svelte +296 -0
- package/dist/components/forms/NuiInput.svelte.d.ts +62 -0
- package/dist/components/forms/NuiRadioGroup.svelte +238 -0
- package/dist/components/forms/NuiRadioGroup.svelte.d.ts +47 -0
- package/dist/components/forms/NuiSelect.svelte +819 -0
- package/dist/components/forms/NuiSelect.svelte.d.ts +49 -0
- package/dist/components/layout/NuiCard.svelte +314 -0
- package/dist/components/layout/NuiCard.svelte.d.ts +48 -0
- package/dist/components/layout/NuiCardGrid.svelte +98 -0
- package/dist/components/layout/NuiCardGrid.svelte.d.ts +22 -0
- package/dist/components/layout/NuiFlex.svelte +121 -0
- package/dist/components/layout/NuiFlex.svelte.d.ts +23 -0
- package/dist/components/layout/NuiGrid.svelte +66 -0
- package/dist/components/layout/NuiGrid.svelte.d.ts +14 -0
- package/dist/components/layout/NuiStack.svelte +138 -0
- package/dist/components/layout/NuiStack.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiAccordion.svelte +323 -0
- package/dist/components/navigation/NuiAccordion.svelte.d.ts +38 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte.d.ts +19 -0
- package/dist/components/navigation/NuiMenu.svelte +596 -0
- package/dist/components/navigation/NuiMenu.svelte.d.ts +28 -0
- package/dist/components/navigation/NuiPagination.svelte +306 -0
- package/dist/components/navigation/NuiPagination.svelte.d.ts +40 -0
- package/dist/components/navigation/NuiSidebar.svelte +186 -0
- package/dist/components/navigation/NuiSidebar.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiTabs.svelte +249 -0
- package/dist/components/navigation/NuiTabs.svelte.d.ts +31 -0
- package/dist/components/overlay/NuiDialog.svelte +118 -0
- package/dist/components/overlay/NuiDialog.svelte.d.ts +34 -0
- package/dist/components/overlay/NuiModal.svelte +992 -0
- package/dist/components/overlay/NuiModal.svelte.d.ts +72 -0
- package/dist/components/overlay/NuiTooltip.svelte +159 -0
- package/dist/components/overlay/NuiTooltip.svelte.d.ts +13 -0
- package/dist/composables/useClickOutside.d.ts +43 -0
- package/dist/composables/useClickOutside.js +73 -0
- package/dist/composables/useFormValidation.d.ts +23 -0
- package/dist/composables/useFormValidation.js +72 -0
- package/dist/composables/useIdle.svelte.d.ts +18 -0
- package/dist/composables/useIdle.svelte.js +144 -0
- package/dist/composables/useKeyboardShortcut.svelte.d.ts +114 -0
- package/dist/composables/useKeyboardShortcut.svelte.js +153 -0
- package/dist/composables/useLocalStorage.svelte.d.ts +22 -0
- package/dist/composables/useLocalStorage.svelte.js +105 -0
- package/dist/composables/useMediaQuery.svelte.d.ts +51 -0
- package/dist/composables/useMediaQuery.svelte.js +80 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +42 -0
- package/package.json +58 -3
- package/src/components/actions/NuiButton.svelte +292 -0
- package/src/components/data/NuiBadge.svelte +277 -0
- package/src/components/data/NuiCodeBlock.svelte +256 -0
- package/src/components/data/NuiList.svelte +341 -0
- package/src/components/data/NuiProgressBar.svelte +264 -0
- package/src/components/data/NuiRichText.svelte +260 -0
- package/src/components/data/NuiTable.svelte +470 -0
- package/src/components/data/chart/NuiChart.svelte +384 -0
- package/src/components/data/chart/color.ts +83 -0
- package/src/components/data/chart/index.ts +10 -0
- package/src/components/data/chart/registry.ts +71 -0
- package/src/components/data/chart/renderers/bar.ts +90 -0
- package/src/components/data/chart/renderers/base.ts +32 -0
- package/src/components/data/chart/renderers/line.ts +99 -0
- package/src/components/data/chart/renderers/pie.ts +66 -0
- package/src/components/data/chart/scale.ts +139 -0
- package/src/components/data/chart/types.ts +98 -0
- package/src/components/data/chart/validator.ts +98 -0
- package/src/components/forms/NuiCheckbox.svelte +218 -0
- package/src/components/forms/NuiDatepicker.svelte +755 -0
- package/src/components/forms/NuiFileInput.svelte +1047 -0
- package/src/components/forms/NuiFileInput.types.ts +29 -0
- package/src/components/forms/NuiForm.svelte +391 -0
- package/src/components/forms/NuiInput.svelte +296 -0
- package/src/components/forms/NuiRadioGroup.svelte +238 -0
- package/src/components/forms/NuiSelect.svelte +819 -0
- package/src/components/layout/NuiCard.svelte +314 -0
- package/src/components/layout/NuiCardGrid.svelte +98 -0
- package/src/components/layout/NuiFlex.svelte +121 -0
- package/src/components/layout/NuiGrid.svelte +66 -0
- package/src/components/layout/NuiStack.svelte +138 -0
- package/src/components/navigation/NuiAccordion.svelte +323 -0
- package/src/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/src/components/navigation/NuiMenu.svelte +596 -0
- package/src/components/navigation/NuiPagination.svelte +306 -0
- package/src/components/navigation/NuiSidebar.svelte +186 -0
- package/src/components/navigation/NuiTabs.svelte +249 -0
- package/src/components/overlay/NuiDialog.svelte +118 -0
- package/src/components/overlay/NuiModal.svelte +992 -0
- package/src/components/overlay/NuiTooltip.svelte +159 -0
- package/src/composables/useClickOutside.ts +132 -0
- package/src/composables/useFormValidation.ts +105 -0
- package/src/composables/useIdle.svelte.ts +191 -0
- package/src/composables/useKeyboardShortcut.svelte.ts +278 -0
- package/src/composables/useLocalStorage.svelte.ts +137 -0
- package/src/composables/useMediaQuery.svelte.ts +102 -0
- 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>
|