@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,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
|
+
}
|