@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,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI representation of a selected file, including optional browser preview URL.
|
|
3
|
+
*/
|
|
4
|
+
export interface FileInfo {
|
|
5
|
+
/** Unique stable identifier for the file entry. */
|
|
6
|
+
id: string;
|
|
7
|
+
/** File name including extension. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** File size in bytes. */
|
|
10
|
+
size: number;
|
|
11
|
+
/** MIME type of the file. */
|
|
12
|
+
type: string;
|
|
13
|
+
/** Browser-only temporary ObjectURL for image preview (if preview enabled). */
|
|
14
|
+
url?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Serialized metadata payload transmitted over FiveM NUI (omits ephemeral browser URLs).
|
|
19
|
+
*/
|
|
20
|
+
export interface FileMetadata {
|
|
21
|
+
/** Unique stable identifier for the file entry. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** File name including extension. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** File size in bytes. */
|
|
26
|
+
size: number;
|
|
27
|
+
/** MIME type of the file. */
|
|
28
|
+
type: string;
|
|
29
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component NuiForm
|
|
3
|
+
Accessible form container for FiveM NUI applications with client-side validation rules,
|
|
4
|
+
asynchronous submission locking, CEF-safe confirmation dialog support, resilient error
|
|
5
|
+
handling, and dual event synchronization.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import { onDestroy } from "svelte";
|
|
9
|
+
import { nui, isBrowser } from "@fivem-nui/svelte";
|
|
10
|
+
import type { SvelteNuiClient } from "@fivem-nui/svelte";
|
|
11
|
+
// Universal form validation engine
|
|
12
|
+
import { validateForm } from "../../composables/useFormValidation";
|
|
13
|
+
import type { ValidationRules } from "../../composables/useFormValidation";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Main event sent on submit with {@link payload} (or {@link values}). */
|
|
17
|
+
eventName?: string;
|
|
18
|
+
/** Secondary or pre-submit event sent just before {@link eventName} with {@link onSubmitPayload} (or {@link values}). */
|
|
19
|
+
onSubmit?: string;
|
|
20
|
+
/** Event sent when form submission succeeds. */
|
|
21
|
+
onSubmitSuccess?: string;
|
|
22
|
+
/** Event sent when form submission fails. */
|
|
23
|
+
onSubmitError?: string;
|
|
24
|
+
/** Specific payload sent with {@link onSubmit} instead of values. Defaults to {@link values}. */
|
|
25
|
+
onSubmitPayload?: unknown;
|
|
26
|
+
/** Specific payload sent with {@link eventName} instead of values. Defaults to {@link values}. */
|
|
27
|
+
payload?: unknown;
|
|
28
|
+
/** SvelteNuiClient instance for event transport. */
|
|
29
|
+
client?: SvelteNuiClient;
|
|
30
|
+
/**
|
|
31
|
+
* Whether network transport is currently in progress. Supports two-way binding.
|
|
32
|
+
*
|
|
33
|
+
* The component manages this flag only during its own transport phase.
|
|
34
|
+
* It will not reset `loading` to `false` unless it was the one that set it to `true`.
|
|
35
|
+
* If a parent binds `loading` for independent operations, those will not be clobbered.
|
|
36
|
+
*/
|
|
37
|
+
loading?: boolean;
|
|
38
|
+
/** Whether the form is disabled. Supports two-way binding. */
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether to automatically reset validation error display status on success.
|
|
42
|
+
* Note: This only resets the submission visibility flag (`submitted`),
|
|
43
|
+
* it does not reset field `values` or native input DOM values.
|
|
44
|
+
* Defaults to false.
|
|
45
|
+
*/
|
|
46
|
+
autoReset?: boolean;
|
|
47
|
+
/** Whether confirmation is required before submitting. Defaults to false. */
|
|
48
|
+
confirm?: boolean;
|
|
49
|
+
/** Confirmation message prompt text. Defaults to 'Are you sure you want to submit?'. */
|
|
50
|
+
confirmMessage?: string;
|
|
51
|
+
/** Optional async confirmation handler (e.g. opens custom NuiDialog) safe for FiveM CEF. */
|
|
52
|
+
confirmCallback?: (message: string) => boolean | Promise<boolean>;
|
|
53
|
+
/** Field values validated against rules before the submission event is sent. */
|
|
54
|
+
values?: Record<string, unknown>;
|
|
55
|
+
/** Validation rules mapped by field name. */
|
|
56
|
+
rules?: Record<string, ValidationRules>;
|
|
57
|
+
/**
|
|
58
|
+
* Called locally after a successful submit.
|
|
59
|
+
* Receives the primary event payload (`payload ?? values ?? {}`).
|
|
60
|
+
*/
|
|
61
|
+
onSubmitted?: (payload: unknown) => void | Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Called locally when submission is rejected due to validation failure,
|
|
64
|
+
* validation rule exception, CEF confirmation unavailability,
|
|
65
|
+
* confirmation callback exception, or backend transport error.
|
|
66
|
+
* Supports synchronous or asynchronous handlers.
|
|
67
|
+
*/
|
|
68
|
+
onRejected?: (error: unknown) => void | Promise<void>;
|
|
69
|
+
children?: import("svelte").Snippet;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let {
|
|
73
|
+
eventName = "",
|
|
74
|
+
onSubmit = "",
|
|
75
|
+
onSubmitSuccess = "",
|
|
76
|
+
onSubmitError = "",
|
|
77
|
+
onSubmitPayload = undefined,
|
|
78
|
+
payload = undefined,
|
|
79
|
+
client = nui,
|
|
80
|
+
loading = $bindable(false),
|
|
81
|
+
disabled = $bindable(false),
|
|
82
|
+
autoReset = false,
|
|
83
|
+
confirm = false,
|
|
84
|
+
confirmMessage = "Are you sure you want to submit?",
|
|
85
|
+
confirmCallback,
|
|
86
|
+
values = {},
|
|
87
|
+
rules = {},
|
|
88
|
+
onSubmitted,
|
|
89
|
+
onRejected,
|
|
90
|
+
children,
|
|
91
|
+
}: Props = $props();
|
|
92
|
+
|
|
93
|
+
let submitted = $state(false);
|
|
94
|
+
let isSubmitting = false;
|
|
95
|
+
let isDestroyed = false;
|
|
96
|
+
|
|
97
|
+
onDestroy(() => {
|
|
98
|
+
isDestroyed = true;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Tracks whether the component itself activated `loading`.
|
|
103
|
+
* Prevents `finally` from clobbering a parent-controlled `loading` binding.
|
|
104
|
+
*/
|
|
105
|
+
let ownLoading = false;
|
|
106
|
+
|
|
107
|
+
// Real-time validation errors derived from current values and rules
|
|
108
|
+
const errors = $derived(
|
|
109
|
+
Object.keys(rules).length
|
|
110
|
+
? validateForm(values, rules)
|
|
111
|
+
: ({} as Record<string, string>),
|
|
112
|
+
);
|
|
113
|
+
const isValid = $derived(Object.keys(errors).length === 0);
|
|
114
|
+
|
|
115
|
+
// Live active errors displayed to the user once submission has been attempted
|
|
116
|
+
const activeErrors = $derived(
|
|
117
|
+
submitted ? errors : ({} as Record<string, string>),
|
|
118
|
+
);
|
|
119
|
+
const hasErrors = $derived(submitted && Object.keys(errors).length > 0);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resilient event dispatcher that safely captures synchronous exceptions
|
|
123
|
+
* and unhandled Promise rejections from custom or asynchronous NUI clients.
|
|
124
|
+
*/
|
|
125
|
+
function safeSend(event: string, data: unknown) {
|
|
126
|
+
if (!event) return;
|
|
127
|
+
try {
|
|
128
|
+
const res = client.send(event, data) as unknown;
|
|
129
|
+
if (res && typeof (res as Promise<unknown>).catch === "function") {
|
|
130
|
+
(res as Promise<unknown>).catch((err: unknown) => {
|
|
131
|
+
console.error(
|
|
132
|
+
`[fivem-nui-components] Async error sending form event '${event}':`,
|
|
133
|
+
err,
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(
|
|
139
|
+
`[fivem-nui-components] Failed to send form event '${event}':`,
|
|
140
|
+
err,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Safely invokes the onRejected callback, protecting against unhandled rejections
|
|
147
|
+
* or exceptions thrown by consumer application code.
|
|
148
|
+
*/
|
|
149
|
+
function safeReject(reason: unknown) {
|
|
150
|
+
try {
|
|
151
|
+
const res = onRejected?.(reason);
|
|
152
|
+
if (res && typeof (res as Promise<void>).catch === "function") {
|
|
153
|
+
(res as Promise<void>).catch((err: unknown) => {
|
|
154
|
+
console.error(
|
|
155
|
+
"[fivem-nui-components] NuiForm: Unhandled rejection in onRejected callback:",
|
|
156
|
+
err,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error(
|
|
162
|
+
"[fivem-nui-components] NuiForm: Exception in onRejected callback:",
|
|
163
|
+
err,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Safely invokes the onSubmitted callback, ensuring consumer application errors
|
|
170
|
+
* are isolated and never misclassified as transport failures.
|
|
171
|
+
*/
|
|
172
|
+
function safeSubmitted(body: unknown) {
|
|
173
|
+
try {
|
|
174
|
+
const res = onSubmitted?.(body);
|
|
175
|
+
if (res && typeof (res as Promise<void>).catch === "function") {
|
|
176
|
+
(res as Promise<void>).catch((err: unknown) => {
|
|
177
|
+
console.error(
|
|
178
|
+
"[fivem-nui-components] NuiForm: Unhandled rejection in onSubmitted callback:",
|
|
179
|
+
err,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
} catch (callbackError) {
|
|
184
|
+
console.error(
|
|
185
|
+
"[fivem-nui-components] NuiForm: Exception in onSubmitted callback:",
|
|
186
|
+
callbackError,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resets the submission and error display state of the form (`submitted = false`).
|
|
193
|
+
* Does not mutate or clear field `values`.
|
|
194
|
+
*/
|
|
195
|
+
export function reset() {
|
|
196
|
+
submitted = false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function handleSubmit(event: SubmitEvent) {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
// 1. Immediate lock against concurrent / double submissions
|
|
202
|
+
if (disabled || loading || isSubmitting) return;
|
|
203
|
+
|
|
204
|
+
submitted = true;
|
|
205
|
+
|
|
206
|
+
// 2. Validate fields — wrapped as an error boundary so a broken custom
|
|
207
|
+
// rule cannot crash handleSubmit with an unhandled exception.
|
|
208
|
+
let validationFailed = false;
|
|
209
|
+
try {
|
|
210
|
+
validationFailed = !isValid;
|
|
211
|
+
} catch (validationError) {
|
|
212
|
+
console.error(
|
|
213
|
+
"[fivem-nui-components] NuiForm: Exception during validation:",
|
|
214
|
+
validationError,
|
|
215
|
+
);
|
|
216
|
+
safeReject(validationError);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (validationFailed) {
|
|
221
|
+
safeReject(errors);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Lock submission lifecycle immediately
|
|
226
|
+
isSubmitting = true;
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
// 3. CEF-safe confirmation handling
|
|
230
|
+
if (confirm) {
|
|
231
|
+
let confirmed = false;
|
|
232
|
+
try {
|
|
233
|
+
if (typeof confirmCallback === "function") {
|
|
234
|
+
confirmed = await confirmCallback(confirmMessage);
|
|
235
|
+
} else if (
|
|
236
|
+
isBrowser() &&
|
|
237
|
+
typeof window !== "undefined" &&
|
|
238
|
+
typeof window.confirm === "function"
|
|
239
|
+
) {
|
|
240
|
+
confirmed = window.confirm(confirmMessage);
|
|
241
|
+
} else {
|
|
242
|
+
const cefError = new Error(
|
|
243
|
+
"window.confirm() is unavailable in FiveM CEF and confirmCallback was not provided.",
|
|
244
|
+
);
|
|
245
|
+
console.warn(
|
|
246
|
+
"[fivem-nui-components] NuiForm:",
|
|
247
|
+
cefError.message,
|
|
248
|
+
"Aborting submission.",
|
|
249
|
+
);
|
|
250
|
+
confirmed = false;
|
|
251
|
+
safeReject(cefError);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
} catch (confirmError) {
|
|
255
|
+
console.error(
|
|
256
|
+
"[fivem-nui-components] NuiForm: Exception in confirmCallback:",
|
|
257
|
+
confirmError,
|
|
258
|
+
);
|
|
259
|
+
confirmed = false;
|
|
260
|
+
safeReject(confirmError);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Re-check state after async confirmation:
|
|
265
|
+
// - component unmounted while dialog was open
|
|
266
|
+
// - user cancelled
|
|
267
|
+
// - parent set disabled = true while dialog was open
|
|
268
|
+
// - parent set loading = true for an independent operation
|
|
269
|
+
if (isDestroyed || !confirmed || disabled || loading) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 4. Mark loading as true only during actual backend transport.
|
|
275
|
+
// Track ownership so finally does not clobber a parent-controlled binding.
|
|
276
|
+
loading = true;
|
|
277
|
+
ownLoading = true;
|
|
278
|
+
|
|
279
|
+
// Decouple onSubmitPayload vs payload, falling back to values.
|
|
280
|
+
const submitBody = onSubmitPayload ?? values ?? {};
|
|
281
|
+
const eventBody = payload ?? values ?? {};
|
|
282
|
+
|
|
283
|
+
// 5. Isolated backend transport execution.
|
|
284
|
+
// Events are sent sequentially, NOT atomically — if onSubmit succeeds
|
|
285
|
+
// but eventName fails, the first operation's side effects persist.
|
|
286
|
+
// For atomic semantics, handle both steps in a single C# callback.
|
|
287
|
+
try {
|
|
288
|
+
if (onSubmit) await client.sendAndWait(onSubmit, submitBody);
|
|
289
|
+
if (isDestroyed) return;
|
|
290
|
+
if (eventName) await client.sendAndWait(eventName, eventBody);
|
|
291
|
+
if (isDestroyed) return;
|
|
292
|
+
} catch (transportError) {
|
|
293
|
+
if (isDestroyed) return;
|
|
294
|
+
const message =
|
|
295
|
+
transportError instanceof Error
|
|
296
|
+
? transportError.message
|
|
297
|
+
: String(transportError);
|
|
298
|
+
if (onSubmitError) safeSend(onSubmitError, { error: message });
|
|
299
|
+
safeReject(transportError);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 6. Transport succeeded: emit success event safely if still mounted
|
|
304
|
+
if (!isDestroyed && onSubmitSuccess) safeSend(onSubmitSuccess, { success: true });
|
|
305
|
+
|
|
306
|
+
if (!isDestroyed && autoReset) {
|
|
307
|
+
submitted = false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 7. Invoke local onSubmitted callback with eventBody (main event payload)
|
|
311
|
+
if (!isDestroyed) safeSubmitted(eventBody);
|
|
312
|
+
} finally {
|
|
313
|
+
if (!isDestroyed) {
|
|
314
|
+
if (ownLoading) {
|
|
315
|
+
loading = false;
|
|
316
|
+
ownLoading = false;
|
|
317
|
+
}
|
|
318
|
+
isSubmitting = false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
</script>
|
|
323
|
+
|
|
324
|
+
<form
|
|
325
|
+
class="nui-form"
|
|
326
|
+
class:is-invalid={hasErrors}
|
|
327
|
+
class:is-disabled={disabled}
|
|
328
|
+
class:is-loading={loading}
|
|
329
|
+
onsubmit={handleSubmit}
|
|
330
|
+
>
|
|
331
|
+
<div class="nui-form-content">
|
|
332
|
+
{@render children?.()}
|
|
333
|
+
</div>
|
|
334
|
+
|
|
335
|
+
{#if hasErrors}
|
|
336
|
+
<ul class="nui-form-errors" role="alert" aria-live="polite">
|
|
337
|
+
{#each Object.entries(activeErrors) as [field, message] (field)}
|
|
338
|
+
<li class="nui-form-error-item" data-field={field}>
|
|
339
|
+
<span class="nui-form-error-field">{field}:</span>
|
|
340
|
+
{message}
|
|
341
|
+
</li>
|
|
342
|
+
{/each}
|
|
343
|
+
</ul>
|
|
344
|
+
{/if}
|
|
345
|
+
</form>
|
|
346
|
+
|
|
347
|
+
<style>
|
|
348
|
+
.nui-form {
|
|
349
|
+
display: flex;
|
|
350
|
+
flex-direction: column;
|
|
351
|
+
gap: 0.75rem;
|
|
352
|
+
width: 100%;
|
|
353
|
+
font-family: inherit;
|
|
354
|
+
box-sizing: border-box;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.nui-form-content {
|
|
358
|
+
display: flex;
|
|
359
|
+
flex-direction: column;
|
|
360
|
+
gap: 0.75rem;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.nui-form.is-disabled {
|
|
364
|
+
opacity: 0.5;
|
|
365
|
+
pointer-events: none;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.nui-form.is-loading {
|
|
369
|
+
cursor: wait;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.nui-form-errors {
|
|
373
|
+
margin: 0;
|
|
374
|
+
padding-left: 1.25rem;
|
|
375
|
+
color: var(--nui-danger, #ef4444);
|
|
376
|
+
font-size: 0.8125rem;
|
|
377
|
+
line-height: 1.4;
|
|
378
|
+
display: flex;
|
|
379
|
+
flex-direction: column;
|
|
380
|
+
gap: 0.25rem;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.nui-form-error-item {
|
|
384
|
+
word-break: break-word;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.nui-form-error-field {
|
|
388
|
+
font-weight: 600;
|
|
389
|
+
text-transform: capitalize;
|
|
390
|
+
}
|
|
391
|
+
</style>
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
import { nui } from '@fivem-nui/svelte';
|
|
4
|
+
import type { SvelteNuiClient } from '@fivem-nui/svelte';
|
|
5
|
+
|
|
6
|
+
type DelayUnit = 'ms' | 's' | 'm';
|
|
7
|
+
|
|
8
|
+
/** Maximum allowed debounce delay (60 seconds). */
|
|
9
|
+
const MAX_DELAY = 60_000;
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
eventName?: string;
|
|
13
|
+
client?: SvelteNuiClient;
|
|
14
|
+
value?: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
type?: 'text' | 'number' | 'password' | 'email' | 'search' | 'textarea';
|
|
18
|
+
disabled?: boolean;
|
|
19
|
+
readonly?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Native HTML `min` attribute — effective for `number`, `range`, `date`, `time`, etc.
|
|
22
|
+
* Passed through as-is; no type enforcement beyond what the browser validates.
|
|
23
|
+
*/
|
|
24
|
+
min?: number | string;
|
|
25
|
+
/**
|
|
26
|
+
* Native HTML `max` attribute — effective for `number`, `range`, `date`, `time`, etc.
|
|
27
|
+
* Passed through as-is; no type enforcement beyond what the browser validates.
|
|
28
|
+
*/
|
|
29
|
+
max?: number | string;
|
|
30
|
+
/** Native HTML `step` attribute — effective for `number`, `range`, `date`, `time`, etc. */
|
|
31
|
+
step?: number | string;
|
|
32
|
+
/**
|
|
33
|
+
* Native HTML `minlength` attribute — constrains text length, not numeric value.
|
|
34
|
+
* Effective for `text`, `search`, `email`, `password`, `tel`, `url`, and `textarea`.
|
|
35
|
+
*/
|
|
36
|
+
minlength?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Native HTML `maxlength` attribute — constrains text length, not numeric value.
|
|
39
|
+
* Effective for `text`, `search`, `email`, `password`, `tel`, `url`, and `textarea`.
|
|
40
|
+
*/
|
|
41
|
+
maxlength?: number;
|
|
42
|
+
/** Native HTML `pattern` attribute — only effective for input types that support it (e.g. `text`, `search`). */
|
|
43
|
+
pattern?: string;
|
|
44
|
+
required?: boolean;
|
|
45
|
+
autofocus?: boolean;
|
|
46
|
+
autocomplete?: HTMLInputElement['autocomplete'];
|
|
47
|
+
name?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Keep the field in sync with `eventName` events coming from the game.
|
|
50
|
+
*
|
|
51
|
+
* When enabled, incoming values are treated as authoritative state from the game
|
|
52
|
+
* and will overwrite the current field value, including text being actively edited.
|
|
53
|
+
* Any pending debounced `onValueChange` is cancelled to prevent stale payloads.
|
|
54
|
+
*/
|
|
55
|
+
twoWay?: boolean;
|
|
56
|
+
/** Event sent when the value settles (immediately, or after {@link debounce}). */
|
|
57
|
+
onValueChange?: string;
|
|
58
|
+
onInput?: string;
|
|
59
|
+
onBlur?: string;
|
|
60
|
+
onFocus?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Debounce delay for {@link onValueChange}, e.g. `'300ms'`, `'2s'`, `'1m'`.
|
|
63
|
+
* Clamped to a maximum of 60 seconds. Invalid formats result in no delay.
|
|
64
|
+
*/
|
|
65
|
+
debounce?: string;
|
|
66
|
+
/** Transform applied to every incoming value when {@link twoWay} is set. */
|
|
67
|
+
valueProcessor?: ((value: unknown) => string) | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let {
|
|
71
|
+
eventName = '',
|
|
72
|
+
client = nui,
|
|
73
|
+
value = $bindable(''),
|
|
74
|
+
label = '',
|
|
75
|
+
placeholder = '',
|
|
76
|
+
type = 'text',
|
|
77
|
+
disabled = false,
|
|
78
|
+
readonly = false,
|
|
79
|
+
min = '',
|
|
80
|
+
max = '',
|
|
81
|
+
step = '',
|
|
82
|
+
minlength = undefined,
|
|
83
|
+
maxlength = undefined,
|
|
84
|
+
pattern = '',
|
|
85
|
+
required = false,
|
|
86
|
+
autofocus = false,
|
|
87
|
+
autocomplete = 'off',
|
|
88
|
+
name = '',
|
|
89
|
+
twoWay = false,
|
|
90
|
+
onValueChange = '',
|
|
91
|
+
onInput = '',
|
|
92
|
+
onBlur = '',
|
|
93
|
+
onFocus = '',
|
|
94
|
+
debounce = '',
|
|
95
|
+
valueProcessor = null
|
|
96
|
+
}: Props = $props();
|
|
97
|
+
|
|
98
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parses a human-readable delay specification into milliseconds.
|
|
102
|
+
* Returns 0 for invalid formats. Result is clamped to {@link MAX_DELAY}.
|
|
103
|
+
*/
|
|
104
|
+
function parseDelay(spec: string): number {
|
|
105
|
+
const match = /^(\d+)(ms|s|m)$/.exec(spec.trim());
|
|
106
|
+
if (!match) return 0;
|
|
107
|
+
const amount = Number.parseInt(match[1], 10);
|
|
108
|
+
if (!Number.isSafeInteger(amount)) return 0;
|
|
109
|
+
const unit = match[2] as DelayUnit;
|
|
110
|
+
const delay = unit === 'ms' ? amount : unit === 's' ? amount * 1000 : amount * 60_000;
|
|
111
|
+
return Math.min(delay, MAX_DELAY);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Safely sends an event via the NUI client, catching synchronous exceptions
|
|
116
|
+
* and attaching a rejection handler to any returned Promise.
|
|
117
|
+
*/
|
|
118
|
+
function safeSend(event: string, data: unknown) {
|
|
119
|
+
if (!event) return;
|
|
120
|
+
try {
|
|
121
|
+
const res = client.send(event, data) as unknown;
|
|
122
|
+
if (res && typeof (res as Promise<unknown>).catch === 'function') {
|
|
123
|
+
(res as Promise<unknown>).catch((err: unknown) => {
|
|
124
|
+
console.error(
|
|
125
|
+
`[fivem-nui-components] NuiInput: Async error sending event '${event}':`,
|
|
126
|
+
err,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
} catch (err) {
|
|
131
|
+
console.error(
|
|
132
|
+
`[fivem-nui-components] NuiInput: Failed to send event '${event}':`,
|
|
133
|
+
err,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Cancels any pending debounce timer. */
|
|
139
|
+
function cancelDebounce() {
|
|
140
|
+
if (timer !== null) {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
timer = null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Immediately flushes the current value through {@link onValueChange},
|
|
148
|
+
* cancelling any pending debounce timer first.
|
|
149
|
+
*/
|
|
150
|
+
function flushValueChange() {
|
|
151
|
+
cancelDebounce();
|
|
152
|
+
if (onValueChange) safeSend(onValueChange, { value });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Two-way sync: incoming game values cancel pending debounce and override local state
|
|
156
|
+
$effect(() => {
|
|
157
|
+
if (!twoWay || !eventName) return;
|
|
158
|
+
return client.on(eventName, (data) => {
|
|
159
|
+
if (data === null || data === undefined) return;
|
|
160
|
+
|
|
161
|
+
let processed: string;
|
|
162
|
+
try {
|
|
163
|
+
processed = valueProcessor ? valueProcessor(data) : String(data);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
console.error(
|
|
166
|
+
'[fivem-nui-components] NuiInput: Exception in valueProcessor:',
|
|
167
|
+
err,
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Cancel any pending debounce to prevent stale local value from being sent
|
|
173
|
+
cancelDebounce();
|
|
174
|
+
value = processed;
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
function handleInput(event: Event) {
|
|
179
|
+
const next = (event.currentTarget as HTMLInputElement | HTMLTextAreaElement).value;
|
|
180
|
+
value = next;
|
|
181
|
+
|
|
182
|
+
if (onInput) safeSend(onInput, { value: next });
|
|
183
|
+
if (!onValueChange) return;
|
|
184
|
+
|
|
185
|
+
cancelDebounce();
|
|
186
|
+
const delay = parseDelay(debounce);
|
|
187
|
+
if (delay > 0) {
|
|
188
|
+
timer = setTimeout(() => {
|
|
189
|
+
timer = null;
|
|
190
|
+
safeSend(onValueChange, { value: next });
|
|
191
|
+
}, delay);
|
|
192
|
+
} else {
|
|
193
|
+
safeSend(onValueChange, { value: next });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function handleBlur() {
|
|
198
|
+
// Flush any pending debounced value change with the current (latest) value
|
|
199
|
+
if (timer !== null) {
|
|
200
|
+
flushValueChange();
|
|
201
|
+
}
|
|
202
|
+
if (onBlur) safeSend(onBlur, { value });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function handleFocus() {
|
|
206
|
+
if (onFocus) safeSend(onFocus, { value });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
onDestroy(() => {
|
|
210
|
+
cancelDebounce();
|
|
211
|
+
});
|
|
212
|
+
</script>
|
|
213
|
+
|
|
214
|
+
<label class="nui-field">
|
|
215
|
+
{#if label}<span class="nui-field__label">{label}</span>{/if}
|
|
216
|
+
|
|
217
|
+
{#if type === 'textarea'}
|
|
218
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
219
|
+
<textarea
|
|
220
|
+
class="nui-field__control"
|
|
221
|
+
{placeholder}
|
|
222
|
+
{disabled}
|
|
223
|
+
{readonly}
|
|
224
|
+
{required}
|
|
225
|
+
{minlength}
|
|
226
|
+
{maxlength}
|
|
227
|
+
{autofocus}
|
|
228
|
+
{autocomplete}
|
|
229
|
+
{name}
|
|
230
|
+
bind:value
|
|
231
|
+
oninput={handleInput}
|
|
232
|
+
onblur={handleBlur}
|
|
233
|
+
onfocus={handleFocus}
|
|
234
|
+
></textarea>
|
|
235
|
+
{:else}
|
|
236
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
237
|
+
<input
|
|
238
|
+
class="nui-field__control"
|
|
239
|
+
{type}
|
|
240
|
+
{placeholder}
|
|
241
|
+
{disabled}
|
|
242
|
+
{readonly}
|
|
243
|
+
{required}
|
|
244
|
+
{min}
|
|
245
|
+
{max}
|
|
246
|
+
{step}
|
|
247
|
+
{minlength}
|
|
248
|
+
{maxlength}
|
|
249
|
+
{pattern}
|
|
250
|
+
{autofocus}
|
|
251
|
+
{autocomplete}
|
|
252
|
+
{name}
|
|
253
|
+
bind:value
|
|
254
|
+
oninput={handleInput}
|
|
255
|
+
onblur={handleBlur}
|
|
256
|
+
onfocus={handleFocus}
|
|
257
|
+
/>
|
|
258
|
+
{/if}
|
|
259
|
+
</label>
|
|
260
|
+
|
|
261
|
+
<style>
|
|
262
|
+
.nui-field {
|
|
263
|
+
display: flex;
|
|
264
|
+
flex-direction: column;
|
|
265
|
+
gap: 0.3rem;
|
|
266
|
+
width: 100%;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.nui-field__label {
|
|
270
|
+
font-size: 0.8125rem;
|
|
271
|
+
font-weight: 600;
|
|
272
|
+
opacity: 0.75;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.nui-field__control {
|
|
276
|
+
width: 100%;
|
|
277
|
+
box-sizing: border-box;
|
|
278
|
+
font-family: inherit;
|
|
279
|
+
font-size: 0.9375rem;
|
|
280
|
+
padding: 0.5rem 0.75rem;
|
|
281
|
+
border: 1px solid var(--nui-border, #3f3f46);
|
|
282
|
+
border-radius: var(--nui-radius, 0.375rem);
|
|
283
|
+
background: var(--nui-surface-alt, #101012);
|
|
284
|
+
color: var(--nui-fg, #fafafa);
|
|
285
|
+
transition: border-color 0.15s ease;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.nui-field__control:focus {
|
|
289
|
+
outline: none;
|
|
290
|
+
border-color: var(--nui-primary, #3b82f6);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.nui-field__control:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
294
|
+
|
|
295
|
+
textarea.nui-field__control { min-height: 5rem; resize: vertical; }
|
|
296
|
+
</style>
|