@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,159 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+
4
+ interface Props {
5
+ /** Tekst podpowiedzi */
6
+ text: string;
7
+ /** Pozycja podpowiedzi */
8
+ position?: 'top' | 'bottom' | 'left' | 'right';
9
+ /** Czas opóźnienia wyświetlania w ms */
10
+ delay?: number;
11
+ /** Element docelowy podpowiedzi */
12
+ children?: import('svelte').Snippet;
13
+ }
14
+
15
+ let {
16
+ text,
17
+ position = 'top',
18
+ delay = 150,
19
+ children
20
+ }: Props = $props();
21
+
22
+ let visible = $state(false);
23
+ let showTimer: ReturnType<typeof setTimeout> | null = null;
24
+
25
+ function show() {
26
+ if (showTimer) clearTimeout(showTimer);
27
+ if (delay > 0) {
28
+ showTimer = setTimeout(() => {
29
+ visible = true;
30
+ }, delay);
31
+ } else {
32
+ visible = true;
33
+ }
34
+ }
35
+
36
+ function hide() {
37
+ if (showTimer) {
38
+ clearTimeout(showTimer);
39
+ showTimer = null;
40
+ }
41
+ visible = false;
42
+ }
43
+
44
+ const cleanup = () => {
45
+ if (showTimer) clearTimeout(showTimer);
46
+ };
47
+
48
+ try {
49
+ onDestroy(cleanup);
50
+ } catch {
51
+ // Outside component lifecycle
52
+ }
53
+ </script>
54
+
55
+ <span
56
+ class="nui-tooltip-wrapper"
57
+ role="presentation"
58
+ onmouseenter={show}
59
+ onmouseleave={hide}
60
+ onfocusin={show}
61
+ onfocusout={hide}
62
+ >
63
+ {@render children?.()}
64
+
65
+ {#if visible && text}
66
+ <div class="nui-tooltip nui-tooltip--{position}" role="tooltip">
67
+ {text}
68
+ </div>
69
+ {/if}
70
+ </span>
71
+
72
+ <style>
73
+ .nui-tooltip-wrapper {
74
+ position: relative;
75
+ display: inline-flex;
76
+ align-items: center;
77
+ }
78
+
79
+ .nui-tooltip {
80
+ position: absolute;
81
+ padding: 0.375rem 0.625rem;
82
+ background: var(--nui-surface-alt, #18181b);
83
+ color: var(--nui-fg, #fafafa);
84
+ border: 1px solid var(--nui-border, #3f3f46);
85
+ border-radius: var(--nui-radius, 0.375rem);
86
+ font-size: 0.75rem;
87
+ line-height: 1.2;
88
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.35);
89
+ z-index: var(--nui-z-tooltip, 1100);
90
+ pointer-events: none;
91
+ white-space: nowrap;
92
+ animation: nui-tooltip-pop 0.15s ease-out;
93
+ }
94
+
95
+ .nui-tooltip--top {
96
+ bottom: calc(100% + 0.375rem);
97
+ left: 50%;
98
+ transform: translateX(-50%);
99
+ }
100
+
101
+ .nui-tooltip--bottom {
102
+ top: calc(100% + 0.375rem);
103
+ left: 50%;
104
+ transform: translateX(-50%);
105
+ }
106
+
107
+ .nui-tooltip--left {
108
+ right: calc(100% + 0.375rem);
109
+ top: 50%;
110
+ transform: translateY(-50%);
111
+ }
112
+
113
+ .nui-tooltip--right {
114
+ left: calc(100% + 0.375rem);
115
+ top: 50%;
116
+ transform: translateY(-50%);
117
+ }
118
+
119
+ @keyframes nui-tooltip-pop {
120
+ from {
121
+ opacity: 0;
122
+ transform: translate(-50%, 2px);
123
+ }
124
+ to {
125
+ opacity: 1;
126
+ transform: translate(-50%, 0);
127
+ }
128
+ }
129
+
130
+ .nui-tooltip--left {
131
+ animation-name: nui-tooltip-pop-left;
132
+ }
133
+
134
+ @keyframes nui-tooltip-pop-left {
135
+ from {
136
+ opacity: 0;
137
+ transform: translateY(-50%) translateX(2px);
138
+ }
139
+ to {
140
+ opacity: 1;
141
+ transform: translateY(-50%) translateX(0);
142
+ }
143
+ }
144
+
145
+ .nui-tooltip--right {
146
+ animation-name: nui-tooltip-pop-right;
147
+ }
148
+
149
+ @keyframes nui-tooltip-pop-right {
150
+ from {
151
+ opacity: 0;
152
+ transform: translateY(-50%) translateX(-2px);
153
+ }
154
+ to {
155
+ opacity: 1;
156
+ transform: translateY(-50%) translateX(0);
157
+ }
158
+ }
159
+ </style>
@@ -0,0 +1,132 @@
1
+ import type { Action } from 'svelte/action';
2
+ import {
3
+ nuiClickOutside,
4
+ useClickOutside as bridgeUseClickOutside,
5
+ type ClickOutsideTarget,
6
+ type NuiClickOutsideParameter
7
+ } from '@fivem-nui/svelte';
8
+
9
+ export interface ClickOutsideOptions {
10
+ /**
11
+ * Callback invoked when a click or touch occurs outside the target element.
12
+ */
13
+ callback: (event: MouseEvent | TouchEvent | PointerEvent) => void;
14
+ /**
15
+ * Reactive getter or boolean to control whether detection is active.
16
+ * @default true
17
+ */
18
+ enabled?: boolean | (() => boolean);
19
+ /**
20
+ * Elements, selectors, or getters to exclude from triggering outside click.
21
+ * Essential for toggle buttons and popover trigger elements.
22
+ */
23
+ ignore?: ClickOutsideTarget[];
24
+ /**
25
+ * Listen during capture phase.
26
+ * @default true
27
+ */
28
+ capture?: boolean;
29
+ }
30
+
31
+ export type ClickOutsideInput =
32
+ | (() => void)
33
+ | ((event: MouseEvent | TouchEvent | PointerEvent) => void)
34
+ | ClickOutsideOptions
35
+ | NuiClickOutsideParameter;
36
+
37
+ function mapOptions(param: ClickOutsideInput): NuiClickOutsideParameter {
38
+ if (typeof param === 'function') {
39
+ return {
40
+ callback: param as (event: PointerEvent) => void
41
+ };
42
+ }
43
+
44
+ const cb = 'callback' in param && param.callback
45
+ ? (param.callback as (event: PointerEvent) => void)
46
+ : 'onClickOutside' in param && param.onClickOutside
47
+ ? (param.onClickOutside as (event: PointerEvent) => void)
48
+ : undefined;
49
+
50
+ return {
51
+ callback: cb,
52
+ onClickOutside: cb,
53
+ enabled: param.enabled,
54
+ ignore: 'ignore' in param ? param.ignore : undefined,
55
+ capture: 'capture' in param ? param.capture : true
56
+ };
57
+ }
58
+
59
+ export interface ClickOutsideActionReturn {
60
+ update: (param: ClickOutsideInput) => void;
61
+ destroy: () => void;
62
+ }
63
+
64
+ export type ClickOutsideAction = (
65
+ node: HTMLElement,
66
+ param: ClickOutsideInput
67
+ ) => ClickOutsideActionReturn;
68
+
69
+ /**
70
+ * Action for detecting clicks outside an element.
71
+ *
72
+ * Backed by `@fivem-nui/svelte` with `event.composedPath()` (eliminating the detached
73
+ * DOM node bug in dynamic lists) and `AbortController` lifecycle management.
74
+ *
75
+ * @example
76
+ * ```svelte
77
+ * <div use:clickOutsideAction={() => (open = false)}>...</div>
78
+ * <div use:clickOutsideAction={{ callback: close, ignore: [btnEl] }}>...</div>
79
+ * ```
80
+ */
81
+ export const clickOutsideAction: ClickOutsideAction = (
82
+ node,
83
+ param
84
+ ) => {
85
+ const innerAction = nuiClickOutside(node, mapOptions(param));
86
+
87
+ return {
88
+ update(newParam) {
89
+ innerAction?.update?.(mapOptions(newParam));
90
+ },
91
+ destroy() {
92
+ innerAction?.destroy?.();
93
+ }
94
+ };
95
+ };
96
+
97
+ /**
98
+ * Functional composable to watch click outside for an element getter or reference.
99
+ * Automatically cleans up on component unmount via `$effect` in `@fivem-nui/svelte`.
100
+ */
101
+ function isOptionsGetter(fn: unknown): boolean {
102
+ if (typeof fn !== 'function') return false;
103
+ if (fn.length > 0) return false;
104
+ const str = Function.prototype.toString.call(fn);
105
+ return /(return\s*\{|\(\s*\{)[\s\S]*\b(callback|onClickOutside|enabled|ignore|capture)\b/.test(str);
106
+ }
107
+
108
+ export function useClickOutside(
109
+ getElement: () => HTMLElement | null | undefined,
110
+ callbackOrOptions: ClickOutsideInput | (() => ClickOutsideInput)
111
+ ): void {
112
+ const getOptions = (): NuiClickOutsideParameter => {
113
+ if (typeof callbackOrOptions !== 'function') {
114
+ return mapOptions(callbackOrOptions);
115
+ }
116
+
117
+ if (isOptionsGetter(callbackOrOptions)) {
118
+ const result = (callbackOrOptions as () => ClickOutsideInput)();
119
+ return mapOptions(result);
120
+ }
121
+
122
+ return {
123
+ callback: callbackOrOptions as (event: PointerEvent) => void,
124
+ onClickOutside: callbackOrOptions as (event: PointerEvent) => void,
125
+ capture: true
126
+ };
127
+ };
128
+
129
+ bridgeUseClickOutside(getElement, getOptions);
130
+ }
131
+
132
+ export const useClickOutsideElement = useClickOutside;
@@ -0,0 +1,105 @@
1
+ export interface ValidationRules {
2
+ /** Whether the field is required. Considered empty: null, undefined, '', whitespace-only, [], NaN */
3
+ required?: boolean;
4
+ /** Minimum length for strings/arrays, or minimum numeric value */
5
+ min?: number;
6
+ /** Maximum length for strings/arrays, or maximum numeric value */
7
+ max?: number;
8
+ /** Whether to validate the value as an email address */
9
+ email?: boolean;
10
+ /** Regular expression pattern for strings. Sticky (y) and global (g) flags are ignored */
11
+ pattern?: string | RegExp;
12
+ /** Synchronous custom validator. Returns true if valid, false for default error, or a custom error message string */
13
+ custom?: (value: unknown, values?: Record<string, unknown>) => boolean | string;
14
+ /** Field label used in validation messages instead of the field key */
15
+ label?: string;
16
+ /** Custom error message overrides for specific rules */
17
+ messages?: Partial<Record<'required' | 'min' | 'max' | 'email' | 'pattern' | 'custom', string>>;
18
+ }
19
+
20
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21
+
22
+ function isEmpty(value: unknown): boolean {
23
+ if (value === null || value === undefined) return true;
24
+ if (typeof value === 'string') return value.trim() === '';
25
+ if (typeof value === 'number') return Number.isNaN(value);
26
+ if (Array.isArray(value)) return value.length === 0;
27
+ return false;
28
+ }
29
+
30
+ function toRegExp(pattern: string | RegExp): RegExp {
31
+ // New instance without g/y flags: ensures test() is stateless
32
+ const flags = pattern instanceof RegExp ? pattern.flags.replace(/[gy]/g, '') : '';
33
+ return new RegExp(pattern instanceof RegExp ? pattern.source : pattern, flags);
34
+ }
35
+
36
+ export function validateField(
37
+ name: string,
38
+ value: unknown,
39
+ rules: ValidationRules,
40
+ values?: Record<string, unknown>,
41
+ ): string | null {
42
+ const label = rules.label ?? name;
43
+ const msg = rules.messages ?? {};
44
+
45
+ if (isEmpty(value)) {
46
+ return rules.required ? (msg.required ?? `${label} is required`) : null;
47
+ }
48
+
49
+ if (rules.min !== undefined) {
50
+ if (typeof value === 'string' && value.length < rules.min)
51
+ return msg.min ?? `${label} must be at least ${rules.min} characters`;
52
+ if (Array.isArray(value) && value.length < rules.min)
53
+ return msg.min ?? `${label} must have at least ${rules.min} items`;
54
+ if (typeof value === 'number' && value < rules.min)
55
+ return msg.min ?? `${label} must be at least ${rules.min}`;
56
+ }
57
+
58
+ if (rules.max !== undefined) {
59
+ if (typeof value === 'string' && value.length > rules.max)
60
+ return msg.max ?? `${label} must be at most ${rules.max} characters`;
61
+ if (Array.isArray(value) && value.length > rules.max)
62
+ return msg.max ?? `${label} must have at most ${rules.max} items`;
63
+ if (typeof value === 'number' && value > rules.max)
64
+ return msg.max ?? `${label} must be at most ${rules.max}`;
65
+ }
66
+
67
+ if (rules.email && typeof value === 'string' && !EMAIL_RE.test(value.trim())) {
68
+ return msg.email ?? `${label} must be a valid email address`;
69
+ }
70
+
71
+ if (rules.pattern && typeof value === 'string') {
72
+ // Invalid pattern is a configuration error: new RegExp will throw SyntaxError
73
+ if (!toRegExp(rules.pattern).test(value)) {
74
+ return msg.pattern ?? `${label} has an invalid format`;
75
+ }
76
+ }
77
+
78
+ if (rules.custom) {
79
+ const result = rules.custom(value, values);
80
+ if (result && typeof (result as unknown as Promise<unknown>).then === 'function') {
81
+ throw new TypeError(`[validateField] "${name}": async custom validators are not supported`);
82
+ }
83
+ if (typeof result === 'string') return result === '' ? null : result;
84
+ if (!result) return msg.custom ?? `${label} is invalid`;
85
+ }
86
+
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Validates form values against the provided rules and returns a map of field errors.
92
+ */
93
+ export function validateForm(
94
+ values: Record<string, unknown>,
95
+ rules: Record<string, ValidationRules>,
96
+ ): Record<string, string> {
97
+ const errors: Record<string, string> = {};
98
+
99
+ for (const [field, rule] of Object.entries(rules)) {
100
+ const error = validateField(field, values[field], rule, values);
101
+ if (error) errors[field] = error;
102
+ }
103
+
104
+ return errors;
105
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Hooks tracking user inactivity and idle state in FiveM NUI.
3
+ * Note: Must be called within a component lifecycle context due to `$effect`.
4
+ */
5
+
6
+ export interface IdleState {
7
+ readonly isIdle: boolean;
8
+ readonly idleTime: number;
9
+ reset(force?: boolean): void;
10
+ }
11
+
12
+ const IDLE_EVENTS = [
13
+ 'pointermove',
14
+ 'pointerdown',
15
+ 'keydown',
16
+ 'scroll',
17
+ 'wheel',
18
+ ] as const;
19
+
20
+ const THROTTLE_MS = 500;
21
+
22
+ /**
23
+ * Shared core helper managing event listeners with capture mode, throttle bypass, and timeout scheduling.
24
+ */
25
+ function createIdleManager(
26
+ timeoutMs: number,
27
+ onTimeout: () => void,
28
+ onActivity?: () => void,
29
+ shouldBypassThrottle?: () => boolean,
30
+ ) {
31
+ let timer: ReturnType<typeof setTimeout> | null = null;
32
+ let controller: AbortController | null = null;
33
+ let lastResetTime = 0;
34
+
35
+ function clearTimer() {
36
+ if (timer) {
37
+ clearTimeout(timer);
38
+ timer = null;
39
+ }
40
+ }
41
+
42
+ function schedule() {
43
+ clearTimer();
44
+ timer = setTimeout(onTimeout, timeoutMs);
45
+ }
46
+
47
+ function handleActivity(force = false) {
48
+ const now = Date.now();
49
+ const bypass = force || (shouldBypassThrottle?.() ?? false);
50
+ if (!bypass && now - lastResetTime < THROTTLE_MS) return;
51
+ lastResetTime = now;
52
+
53
+ onActivity?.();
54
+ schedule();
55
+ }
56
+
57
+ const listener = () => handleActivity(false);
58
+
59
+ function init() {
60
+ controller = new AbortController();
61
+ for (const event of IDLE_EVENTS) {
62
+ window.addEventListener(event, listener, {
63
+ passive: true,
64
+ capture: true, // Crucial for catching scrolls inside inner NUI containers
65
+ signal: controller.signal,
66
+ });
67
+ }
68
+
69
+ schedule();
70
+
71
+ return () => {
72
+ clearTimer();
73
+ controller?.abort();
74
+ controller = null;
75
+ };
76
+ }
77
+
78
+ return {
79
+ init,
80
+ reset: (force = true) => handleActivity(force), // Manual reset defaults to forcing bypass
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Tracks user inactivity and idle state in NUI.
86
+ */
87
+ export function useIdle(
88
+ timeoutMs = 30_000,
89
+ updateIntervalMs = 1_000,
90
+ ): IdleState {
91
+ let isIdle = $state(false);
92
+ let idleTime = $state(0);
93
+
94
+ let lastActive = Date.now();
95
+ let interval: ReturnType<typeof setInterval> | null = null;
96
+
97
+ function updateIdleTime() {
98
+ idleTime = Math.max(0, Date.now() - lastActive);
99
+ }
100
+
101
+ function startInterval() {
102
+ if (interval) return;
103
+ interval = setInterval(updateIdleTime, updateIntervalMs);
104
+ }
105
+
106
+ function stopInterval() {
107
+ if (interval) {
108
+ clearInterval(interval);
109
+ interval = null;
110
+ }
111
+ }
112
+
113
+ const manager = createIdleManager(
114
+ timeoutMs,
115
+ () => {
116
+ isIdle = true;
117
+ // Synchronize idleTime immediately when entering idle state
118
+ idleTime = Math.max(0, Date.now() - lastActive);
119
+ },
120
+ () => {
121
+ lastActive = Date.now();
122
+ idleTime = 0;
123
+ if (isIdle) {
124
+ isIdle = false;
125
+ }
126
+ startInterval();
127
+ },
128
+ () => isIdle, // Instantly bypass throttle when user is currently idle (waking up)
129
+ );
130
+
131
+ $effect(() => {
132
+ lastActive = Date.now();
133
+ idleTime = 0;
134
+ isIdle = false;
135
+ startInterval();
136
+ const cleanupManager = manager.init();
137
+
138
+ return () => {
139
+ cleanupManager();
140
+ stopInterval();
141
+ };
142
+ });
143
+
144
+ return {
145
+ get isIdle() {
146
+ return isIdle;
147
+ },
148
+ get idleTime() {
149
+ return idleTime;
150
+ },
151
+ reset: (force = true) => manager.reset(force),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * One-shot activity observer: executes callback exactly once when the user becomes idle.
157
+ * Returns a cancel function to abort observation.
158
+ */
159
+ export function useIdleUntil(
160
+ timeoutMs = 30_000,
161
+ callback?: () => void,
162
+ ): () => void {
163
+ let fired = false;
164
+ let cleanupManager: (() => void) | null = null;
165
+
166
+ const manager = createIdleManager(timeoutMs, () => {
167
+ if (!fired) {
168
+ fired = true;
169
+ callback?.();
170
+ cleanupManager?.();
171
+ cleanupManager = null;
172
+ }
173
+ });
174
+
175
+ $effect(() => {
176
+ fired = false; // Reset fired state on effect re-run/re-mount
177
+ cleanupManager = manager.init();
178
+
179
+ return () => {
180
+ cleanupManager?.();
181
+ cleanupManager = null;
182
+ };
183
+ });
184
+
185
+ const cancel = () => {
186
+ cleanupManager?.();
187
+ cleanupManager = null;
188
+ };
189
+
190
+ return cancel;
191
+ }