@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,264 @@
1
+ <!--
2
+ @component NuiProgressBar
3
+ Accessible progress bar component with real-time NUI sync, custom colors, and percentage indicators.
4
+
5
+ Data Precedence & Synchronization:
6
+ - If `eventName` is specified, the component listens for incoming progress payloads (number or `{ value, max }`).
7
+ - Incoming NUI updates take precedence over initial `value`/`max` props.
8
+ - If the parent component changes `value`, `max`, or `eventName` props, remote overrides are reset.
9
+
10
+ Completion Contract:
11
+ - Completion is detected when `safeValue >= safeMax` (not by rounded percent).
12
+ - Fires `onComplete` and emits `completeEvent` once when reaching 100%.
13
+ - If progress drops below 100% and reaches 100% again, the completion handlers are re-triggered.
14
+
15
+ Label Behavior:
16
+ - `labelPosition === 'top'` renders the label above the track on the left, with percentage on the right.
17
+ - `labelPosition === 'inside'` renders the text label inside the progress fill, while percentage (if enabled) remains above the track.
18
+ -->
19
+ <script lang="ts">
20
+ import { nui } from '@fivem-nui/svelte';
21
+ import type { SvelteNuiClient } from '@fivem-nui/svelte';
22
+
23
+ type MaybePromise<T> = T | Promise<T>;
24
+
25
+ interface Props {
26
+ /** Current progress value. */
27
+ value?: number;
28
+ /** Maximum progress value. Defaults to 100 if <= 0, NaN, or non-finite. */
29
+ max?: number;
30
+ /** Text label describing the progress (e.g. "Downloading resources"). */
31
+ label?: string;
32
+ /** Custom CSS color override for the fill bar. */
33
+ color?: string;
34
+ /** Whether to display the percentage number. */
35
+ showPercentage?: boolean;
36
+ /** Position of the text label ('top' above track, 'inside' within fill bar). */
37
+ labelPosition?: 'top' | 'inside';
38
+ /** Sizing density. */
39
+ size?: 'sm' | 'md' | 'lg';
40
+ /** Color palette variant. */
41
+ variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
42
+ /** Optional NUI event name to listen for progress updates from the game backend. */
43
+ eventName?: string;
44
+ /** Optional NUI event name sent to the game backend once progress reaches 100%. */
45
+ completeEvent?: string;
46
+ /** Callback executed when progress reaches 100% (safe for sync or async). */
47
+ onComplete?: () => MaybePromise<void>;
48
+ /** Accessible label for screen readers (defaults to label or "Progress"). */
49
+ ariaLabel?: string;
50
+ /** SvelteNuiClient instance for event transport. */
51
+ client?: SvelteNuiClient;
52
+ }
53
+
54
+ let {
55
+ value = 0,
56
+ max = 100,
57
+ label,
58
+ color,
59
+ showPercentage = false,
60
+ labelPosition = 'top',
61
+ size = 'md',
62
+ variant = 'primary',
63
+ eventName,
64
+ completeEvent,
65
+ onComplete,
66
+ ariaLabel,
67
+ client = nui
68
+ }: Props = $props();
69
+
70
+ let remoteValue = $state<number | null>(null);
71
+ let remoteMax = $state<number | null>(null);
72
+
73
+ let lastPropsValue = value;
74
+ let lastPropsMax = max;
75
+ let lastEventName = eventName;
76
+
77
+ // Reset remote overrides when parent updates props or switches eventName
78
+ $effect(() => {
79
+ if (value !== lastPropsValue || max !== lastPropsMax || eventName !== lastEventName) {
80
+ lastPropsValue = value;
81
+ lastPropsMax = max;
82
+ lastEventName = eventName;
83
+ remoteValue = null;
84
+ remoteMax = null;
85
+ }
86
+ });
87
+
88
+ // Listen for real-time progress updates from NUI game client
89
+ $effect(() => {
90
+ if (!eventName || !client) return;
91
+ const unsub = client.on(eventName, (payload: unknown) => {
92
+ if (typeof payload === 'number' && Number.isFinite(payload)) {
93
+ remoteValue = payload;
94
+ } else if (payload && typeof payload === 'object') {
95
+ const obj = payload as Record<string, unknown>;
96
+ if (typeof obj.value === 'number' && Number.isFinite(obj.value)) {
97
+ remoteValue = obj.value;
98
+ }
99
+ if (typeof obj.max === 'number' && Number.isFinite(obj.max)) {
100
+ remoteMax = obj.max;
101
+ }
102
+ }
103
+ });
104
+ return () => unsub();
105
+ });
106
+
107
+ const activeValue = $derived(remoteValue ?? value);
108
+ const activeMax = $derived(remoteMax ?? max);
109
+
110
+ const safeMax = $derived(
111
+ typeof activeMax === 'number' && Number.isFinite(activeMax) && activeMax > 0
112
+ ? activeMax
113
+ : 100
114
+ );
115
+
116
+ const safeValue = $derived(
117
+ typeof activeValue === 'number' && Number.isFinite(activeValue)
118
+ ? Math.max(0, Math.min(safeMax, activeValue))
119
+ : 0
120
+ );
121
+
122
+ const clampedPercent = $derived(
123
+ safeMax > 0 ? Math.min(100, Math.max(0, Math.round((safeValue / safeMax) * 100))) : 0
124
+ );
125
+
126
+ const isComplete = $derived(safeValue >= safeMax);
127
+
128
+ let hasCompleted = $state(false);
129
+
130
+ function safeCall(fn: (() => unknown) | undefined, errorPrefix: string): void {
131
+ if (!fn) return;
132
+ try {
133
+ const result = fn();
134
+ if (result && typeof (result as Promise<unknown>).catch === 'function') {
135
+ (result as Promise<unknown>).catch((err) => {
136
+ console.error(errorPrefix, err);
137
+ });
138
+ }
139
+ } catch (err) {
140
+ console.error(errorPrefix, err);
141
+ }
142
+ }
143
+
144
+ $effect(() => {
145
+ if (isComplete) {
146
+ if (!hasCompleted) {
147
+ hasCompleted = true;
148
+ if (completeEvent) {
149
+ try {
150
+ client.send(completeEvent, { value: safeValue, max: safeMax });
151
+ } catch (err) {
152
+ console.error(`[fivem-nui-components] Failed to send completeEvent '${completeEvent}':`, err);
153
+ }
154
+ }
155
+ safeCall(() => onComplete?.(), '[fivem-nui-components] onComplete callback error:');
156
+ }
157
+ } else {
158
+ hasCompleted = false;
159
+ }
160
+ });
161
+
162
+ const resolvedAriaLabel = $derived(ariaLabel || label || 'Progress');
163
+ const resolvedAriaValueText = $derived(`${clampedPercent}%`);
164
+ </script>
165
+
166
+ <div class="nui-progress-bar nui-progress-bar--{size}">
167
+ {#if (label && labelPosition === 'top') || showPercentage}
168
+ <div class="nui-progress-bar__label">
169
+ {#if label && labelPosition === 'top'}
170
+ <span>{label}</span>
171
+ {/if}
172
+ {#if showPercentage}
173
+ <span class="nui-progress-bar__percent">{clampedPercent}%</span>
174
+ {/if}
175
+ </div>
176
+ {/if}
177
+
178
+ <div
179
+ class="nui-progress-bar__track"
180
+ role="progressbar"
181
+ aria-label={resolvedAriaLabel}
182
+ aria-valuenow={safeValue}
183
+ aria-valuemin={0}
184
+ aria-valuemax={safeMax}
185
+ aria-valuetext={resolvedAriaValueText}
186
+ >
187
+ <div
188
+ class="nui-progress-bar__fill nui-progress-bar__fill--{variant}"
189
+ style:width="{clampedPercent}%"
190
+ style:background={color || undefined}
191
+ aria-hidden="true"
192
+ >
193
+ {#if label && labelPosition === 'inside'}
194
+ <span class="nui-progress-bar__inner-label">
195
+ {label}
196
+ </span>
197
+ {/if}
198
+ </div>
199
+ </div>
200
+ </div>
201
+
202
+ <style>
203
+ .nui-progress-bar {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: 0.35rem;
207
+ font-family: inherit;
208
+ width: 100%;
209
+ box-sizing: border-box;
210
+ }
211
+
212
+ .nui-progress-bar__label {
213
+ display: flex;
214
+ justify-content: space-between;
215
+ align-items: center;
216
+ font-size: 0.8125rem;
217
+ color: var(--nui-fg, #fafafa);
218
+ opacity: 0.85;
219
+ line-height: 1.2;
220
+ }
221
+
222
+ .nui-progress-bar__percent {
223
+ font-weight: 600;
224
+ margin-left: auto;
225
+ }
226
+
227
+ .nui-progress-bar__track {
228
+ width: 100%;
229
+ background: var(--nui-surface-alt, #27272a);
230
+ border: 1px solid var(--nui-border, #3f3f46);
231
+ border-radius: var(--nui-radius, 0.375rem);
232
+ overflow: hidden;
233
+ position: relative;
234
+ box-sizing: border-box;
235
+ }
236
+
237
+ .nui-progress-bar--sm .nui-progress-bar__track { height: 0.75rem; }
238
+ .nui-progress-bar--md .nui-progress-bar__track { height: 1.25rem; }
239
+ .nui-progress-bar--lg .nui-progress-bar__track { height: 1.75rem; }
240
+
241
+ .nui-progress-bar__fill {
242
+ height: 100%;
243
+ display: flex;
244
+ align-items: center;
245
+ justify-content: center;
246
+ transition: width 0.3s ease;
247
+ border-radius: inherit;
248
+ }
249
+
250
+ .nui-progress-bar__fill--primary { background: var(--nui-primary, #3b82f6); }
251
+ .nui-progress-bar__fill--secondary { background: var(--nui-secondary, #8b8b99); }
252
+ .nui-progress-bar__fill--success { background: var(--nui-success, #22c55e); }
253
+ .nui-progress-bar__fill--warning { background: var(--nui-warning, #f59e0b); }
254
+ .nui-progress-bar__fill--danger { background: var(--nui-danger, #ef4444); }
255
+
256
+ .nui-progress-bar__inner-label {
257
+ color: #fff;
258
+ font-size: 0.75rem;
259
+ font-weight: 600;
260
+ padding: 0 0.5rem;
261
+ white-space: nowrap;
262
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
263
+ }
264
+ </style>
@@ -0,0 +1,260 @@
1
+ <!--
2
+ @component NuiRichText
3
+ Renders formatted text or sanitized HTML content.
4
+
5
+ Security Policy & Sanitization:
6
+ - Default Mode (`parseHtml = false`):
7
+ Safe by default. Content is rendered via Svelte's native text binding inside `<pre>`,
8
+ preventing any HTML or script execution.
9
+ - HTML Mode (`parseHtml = true`):
10
+ Enables HTML parsing. Applies an allowlist of safe formatting tags/attributes, protocol
11
+ filtering on links (http, https, mailto, safe relative URLs), unwraps benign wrapper tags,
12
+ and drops dangerous elements (script, style, iframe, etc.).
13
+ - Custom Sanitizer:
14
+ For untrusted or user-submitted content (e.g. player chat, server announcements, external API data),
15
+ pass an enterprise-grade sanitizer (e.g. `DOMPurify.sanitize`) via the `sanitizer` prop.
16
+ The provided sanitizer function must return an XSS-safe HTML string.
17
+ -->
18
+ <script lang="ts">
19
+ interface Props {
20
+ /** Text or HTML content to display. */
21
+ content: string;
22
+ /** Whether to parse and render content as HTML. Defaults to false. */
23
+ parseHtml?: boolean;
24
+ /**
25
+ * Optional custom sanitizer function (e.g. DOMPurify.sanitize).
26
+ * Must return an XSS-safe HTML string.
27
+ */
28
+ sanitizer?: ((dirty: string) => string) | null;
29
+ /** Optional additional CSS class for root container. */
30
+ className?: string;
31
+ }
32
+
33
+ let {
34
+ content,
35
+ parseHtml = false,
36
+ sanitizer = null,
37
+ className = ''
38
+ }: Props = $props();
39
+
40
+ const ALLOWED_TAGS = new Set([
41
+ 'a', 'b', 'blockquote', 'br', 'code', 'dd', 'del', 'div', 'dl', 'dt',
42
+ 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'ins', 'kbd',
43
+ 'li', 'mark', 'ol', 'p', 'pre', 's', 'small', 'span', 'strike',
44
+ 'strong', 'sub', 'sup', 'time', 'u', 'ul'
45
+ ]);
46
+
47
+ const DROP_CONTENT_TAGS = new Set([
48
+ 'script', 'style', 'iframe', 'noembed', 'noframes', 'noscript',
49
+ 'object', 'embed', 'template', 'svg', 'canvas', 'audio', 'video',
50
+ 'input', 'textarea', 'select', 'button', 'form'
51
+ ]);
52
+
53
+ const ALLOWED_ATTRS: Record<string, Set<string>> = {
54
+ a: new Set(['href', 'title', 'target', 'rel']),
55
+ time: new Set(['datetime']),
56
+ '*': new Set(['class', 'title', 'aria-label', 'aria-hidden'])
57
+ };
58
+
59
+ // Strict ISO-8601 date/time (e.g. 2026-10-17, 2026-10-17T14:30:00Z)
60
+ const ISO_DATETIME_REGEX = /^\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
61
+
62
+ function escapeHtml(str: string): string {
63
+ return str
64
+ .replace(/&/g, '&amp;')
65
+ .replace(/</g, '&lt;')
66
+ .replace(/>/g, '&gt;')
67
+ .replace(/"/g, '&quot;')
68
+ .replace(/'/g, '&#039;');
69
+ }
70
+
71
+ /**
72
+ * Validates whether a URL uses a safe protocol (http, https, mailto, or relative).
73
+ * Rejects control characters, protocol-relative URLs (//...), and dangerous schemes.
74
+ */
75
+ function isSafeUrl(urlStr: string): boolean {
76
+ const value = urlStr.trim();
77
+
78
+ // Reject control characters rather than trying to normalize them
79
+ if (/[\u0000-\u001F\u007F-\u009F]/.test(value)) {
80
+ return false;
81
+ }
82
+
83
+ // Allow ordinary relative URLs, but reject protocol-relative URLs (//...)
84
+ if (value.startsWith('/') && !value.startsWith('//')) {
85
+ return true;
86
+ }
87
+
88
+ // Allow anchors and query parameters
89
+ if (value.startsWith('#') || value.startsWith('?')) {
90
+ return true;
91
+ }
92
+
93
+ try {
94
+ const parsed = new URL(value);
95
+ return (
96
+ parsed.protocol === 'https:' ||
97
+ parsed.protocol === 'http:' ||
98
+ parsed.protocol === 'mailto:'
99
+ );
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function cleanNode(node: Node): void {
106
+ const children = Array.from(node.childNodes);
107
+
108
+ for (const child of children) {
109
+ if (child.nodeType === Node.ELEMENT_NODE) {
110
+ const el = child as HTMLElement;
111
+ const tagName = el.tagName.toLowerCase();
112
+
113
+ // Completely discard dangerous elements and their subtrees
114
+ if (DROP_CONTENT_TAGS.has(tagName)) {
115
+ node.removeChild(child);
116
+ continue;
117
+ }
118
+
119
+ // Unwrap benign wrapper elements not in allowlist, preserving their sanitized text/children
120
+ if (!ALLOWED_TAGS.has(tagName)) {
121
+ cleanNode(child);
122
+ while (child.firstChild) {
123
+ node.insertBefore(child.firstChild, child);
124
+ }
125
+ node.removeChild(child);
126
+ continue;
127
+ }
128
+
129
+ const attrs = Array.from(el.attributes);
130
+ for (const attr of attrs) {
131
+ const attrName = attr.name.toLowerCase();
132
+ const allowedForTag = ALLOWED_ATTRS[tagName];
133
+ const allowedGlobal = ALLOWED_ATTRS['*'];
134
+ const isAllowed =
135
+ (allowedForTag && allowedForTag.has(attrName)) ||
136
+ (allowedGlobal && allowedGlobal.has(attrName));
137
+
138
+ if (!isAllowed || attrName.startsWith('on')) {
139
+ el.removeAttribute(attr.name);
140
+ continue;
141
+ }
142
+
143
+ if (attrName === 'href' && !isSafeUrl(attr.value)) {
144
+ el.removeAttribute(attr.name);
145
+ continue;
146
+ }
147
+
148
+ if (attrName === 'datetime' && !ISO_DATETIME_REGEX.test(attr.value.trim())) {
149
+ el.removeAttribute(attr.name);
150
+ continue;
151
+ }
152
+ }
153
+
154
+ if (tagName === 'a') {
155
+ el.setAttribute('rel', 'noopener noreferrer');
156
+ }
157
+
158
+ cleanNode(child);
159
+ } else if (
160
+ child.nodeType !== Node.TEXT_NODE &&
161
+ child.nodeType !== Node.COMMENT_NODE
162
+ ) {
163
+ node.removeChild(child);
164
+ }
165
+ }
166
+ }
167
+
168
+ function defaultSanitize(dirtyHtml: string): string {
169
+ if (typeof window === 'undefined' || typeof DOMParser === 'undefined') {
170
+ return escapeHtml(dirtyHtml);
171
+ }
172
+ try {
173
+ const parser = new DOMParser();
174
+ const doc = parser.parseFromString(dirtyHtml, 'text/html');
175
+ cleanNode(doc.body);
176
+ return doc.body.innerHTML;
177
+ } catch {
178
+ return escapeHtml(dirtyHtml);
179
+ }
180
+ }
181
+
182
+ const safeContent = $derived.by(() => {
183
+ if (!content) return '';
184
+ if (!parseHtml) return content;
185
+
186
+ try {
187
+ return sanitizer ? sanitizer(content) : defaultSanitize(content);
188
+ } catch (error) {
189
+ console.error('[fivem-nui-components] Failed to sanitize rich text:', error);
190
+ return escapeHtml(content);
191
+ }
192
+ });
193
+ </script>
194
+
195
+ <div
196
+ class="nui-rich-text {className}"
197
+ class:nui-rich-text--html={parseHtml}
198
+ >
199
+ {#if parseHtml}
200
+ {@html safeContent}
201
+ {:else}
202
+ <pre class="nui-rich-text__pre">{content}</pre>
203
+ {/if}
204
+ </div>
205
+
206
+ <style>
207
+ .nui-rich-text {
208
+ font-family: inherit;
209
+ line-height: 1.6;
210
+ color: var(--nui-fg, #fafafa);
211
+ }
212
+
213
+ .nui-rich-text__pre {
214
+ background: var(--nui-surface-alt, #101012);
215
+ border: 1px solid var(--nui-border, #27272a);
216
+ border-radius: var(--nui-radius, 0.375rem);
217
+ padding: 1rem;
218
+ overflow-x: auto;
219
+ font-family: 'Consolas', 'Monaco', monospace;
220
+ font-size: 0.875rem;
221
+ margin: 0;
222
+ white-space: pre-wrap;
223
+ word-break: break-word;
224
+ }
225
+
226
+ .nui-rich-text :global(pre),
227
+ .nui-rich-text :global(code) {
228
+ font-family: 'Consolas', 'Monaco', monospace;
229
+ font-size: 0.875rem;
230
+ }
231
+
232
+ .nui-rich-text :global(p) {
233
+ margin: 0 0 1rem 0;
234
+ }
235
+
236
+ .nui-rich-text :global(ul),
237
+ .nui-rich-text :global(ol) {
238
+ margin: 0 0 1rem 0;
239
+ padding-left: 1.5rem;
240
+ }
241
+
242
+ .nui-rich-text :global(li) {
243
+ margin: 0 0 0.25rem 0;
244
+ }
245
+
246
+ .nui-rich-text :global(h1),
247
+ .nui-rich-text :global(h2),
248
+ .nui-rich-text :global(h3),
249
+ .nui-rich-text :global(h4),
250
+ .nui-rich-text :global(h5),
251
+ .nui-rich-text :global(h6) {
252
+ margin: 1rem 0 0.5rem 0;
253
+ font-weight: 600;
254
+ }
255
+
256
+ .nui-rich-text :global(h1) { font-size: 1.5rem; }
257
+ .nui-rich-text :global(h2) { font-size: 1.25rem; }
258
+ .nui-rich-text :global(h3) { font-size: 1.125rem; }
259
+ .nui-rich-text :global(h4) { font-size: 1rem; }
260
+ </style>