@nikala-ui/hooks 0.8.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.
@@ -0,0 +1,94 @@
1
+ import { createEffect, onCleanup, type Accessor } from "solid-js";
2
+
3
+ export interface CreateFocusTrapOptions {
4
+ /** Whether focus trap is actively enabled. Defaults to true. */
5
+ enabled?: boolean | Accessor<boolean>;
6
+ /** Whether to return focus to previously focused element on cleanup. Defaults to true. */
7
+ returnFocusOnDeactivate?: boolean;
8
+ }
9
+
10
+ const FOCUSABLE_SELECTOR =
11
+ 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable]';
12
+
13
+ /**
14
+ * SolidJS reactive primitive for trapping keyboard focus inside target container element.
15
+ *
16
+ * @param target Target element or accessor returning HTML element.
17
+ * @param options Configuration options for focus trap behavior.
18
+ */
19
+ export function createFocusTrap(
20
+ target: HTMLElement | Accessor<HTMLElement | undefined>,
21
+ options: CreateFocusTrapOptions = {}
22
+ ): void {
23
+ const getTarget = (): HTMLElement | undefined => {
24
+ if (typeof target === "function") {
25
+ return (target as Accessor<HTMLElement | undefined>)();
26
+ }
27
+ return target;
28
+ };
29
+
30
+ const isEnabled = (): boolean => {
31
+ if (typeof options.enabled === "function") {
32
+ return options.enabled();
33
+ }
34
+ return options.enabled ?? true;
35
+ };
36
+
37
+ createEffect(() => {
38
+ if (typeof window === "undefined" || !isEnabled()) return;
39
+
40
+ const container = getTarget();
41
+ if (!container) return;
42
+
43
+ const previousActiveElement = document.activeElement as HTMLElement | null;
44
+
45
+ const getFocusableElements = (): HTMLElement[] => {
46
+ return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
47
+ (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0
48
+ );
49
+ };
50
+
51
+ const focusable = getFocusableElements();
52
+ if (focusable.length > 0) {
53
+ focusable[0].focus();
54
+ } else {
55
+ container.focus();
56
+ }
57
+
58
+ const handleKeyDown = (event: KeyboardEvent) => {
59
+ if (event.key !== "Tab") return;
60
+
61
+ const elements = getFocusableElements();
62
+ if (elements.length === 0) {
63
+ event.preventDefault();
64
+ return;
65
+ }
66
+
67
+ const firstElement = elements[0];
68
+ const lastElement = elements[elements.length - 1];
69
+ const activeElement = document.activeElement;
70
+
71
+ if (event.shiftKey) {
72
+ if (activeElement === firstElement || !container.contains(activeElement)) {
73
+ event.preventDefault();
74
+ lastElement.focus();
75
+ }
76
+ } else {
77
+ if (activeElement === lastElement || !container.contains(activeElement)) {
78
+ event.preventDefault();
79
+ firstElement.focus();
80
+ }
81
+ }
82
+ };
83
+
84
+ document.addEventListener("keydown", handleKeyDown);
85
+
86
+ onCleanup(() => {
87
+ document.removeEventListener("keydown", handleKeyDown);
88
+
89
+ if (options.returnFocusOnDeactivate !== false && previousActiveElement) {
90
+ previousActiveElement.focus?.();
91
+ }
92
+ });
93
+ });
94
+ }
@@ -0,0 +1,152 @@
1
+ import { createSignal, createMemo, type Accessor } from "solid-js";
2
+
3
+ export type FormErrors<T> = Partial<Record<keyof T, string>>;
4
+ export type FormTouched<T> = Partial<Record<keyof T, boolean>>;
5
+
6
+ export interface CreateFormOptions<T extends Record<string, any>> {
7
+ /** Initial form field values object */
8
+ initialValues: T;
9
+ /** Custom validation function returning error messages object */
10
+ validate?: (values: T) => FormErrors<T> | Promise<FormErrors<T>>;
11
+ /** Submit handler callback invoked when validation succeeds */
12
+ onSubmit?: (values: T) => void | Promise<void>;
13
+ }
14
+
15
+ export interface CreateFormReturn<T extends Record<string, any>> {
16
+ /** Accessor for current form field values */
17
+ values: Accessor<T>;
18
+ /** Accessor for form field validation error messages */
19
+ errors: Accessor<FormErrors<T>>;
20
+ /** Accessor for form field touched states */
21
+ touched: Accessor<FormTouched<T>>;
22
+ /** Accessor indicating if form is currently submitting */
23
+ isSubmitting: Accessor<boolean>;
24
+ /** Accessor indicating if form has zero validation errors */
25
+ isValid: Accessor<boolean>;
26
+ /** Accessor indicating if form values differ from initial values */
27
+ isDirty: Accessor<boolean>;
28
+ /** Update specific form field value */
29
+ setFieldValue: <K extends keyof T>(field: K, value: T[K]) => void;
30
+ /** Set specific form field validation error */
31
+ setFieldError: <K extends keyof T>(field: K, error: string | undefined) => void;
32
+ /** Set specific form field touched state */
33
+ setFieldTouched: <K extends keyof T>(field: K, isTouched?: boolean) => void;
34
+ /** Input change event listener helper factory function */
35
+ handleChange: <K extends keyof T>(field: K) => (e: Event & { currentTarget: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement }) => void;
36
+ /** Input blur event listener helper factory function */
37
+ handleBlur: <K extends keyof T>(field: K) => () => void;
38
+ /** Form onSubmit event handler */
39
+ handleSubmit: (e?: Event) => void;
40
+ /** Reset form values, errors, and touched states to initial values */
41
+ resetForm: () => void;
42
+ }
43
+
44
+ /**
45
+ * SolidJS reactive primitive for managing form field state, validation, errors, and submission.
46
+ *
47
+ * @param options Form configuration options including initialValues and validate function.
48
+ */
49
+ export function createForm<T extends Record<string, any>>(
50
+ options: CreateFormOptions<T>
51
+ ): CreateFormReturn<T> {
52
+ const initialValues = { ...options.initialValues };
53
+
54
+ const [values, setValues] = createSignal<T>({ ...initialValues });
55
+ const [errors, setErrors] = createSignal<FormErrors<T>>({});
56
+ const [touched, setTouched] = createSignal<FormTouched<T>>({});
57
+ const [isSubmitting, setIsSubmitting] = createSignal(false);
58
+
59
+ const isDirty = createMemo(() => {
60
+ const current = values();
61
+ return Object.keys(initialValues).some((key) => current[key] !== initialValues[key]);
62
+ });
63
+
64
+ const isValid = createMemo(() => {
65
+ const errs = errors();
66
+ return Object.keys(errs).length === 0;
67
+ });
68
+
69
+ const runValidation = async (currentValues: T): Promise<FormErrors<T>> => {
70
+ if (!options.validate) return {};
71
+ const result = await options.validate(currentValues);
72
+ const newErrors = result || {};
73
+ setErrors(() => newErrors);
74
+ return newErrors;
75
+ };
76
+
77
+ const setFieldValue = <K extends keyof T>(field: K, value: T[K]) => {
78
+ const next = { ...values(), [field]: value };
79
+ setValues(() => next);
80
+ runValidation(next);
81
+ };
82
+
83
+ const setFieldError = <K extends keyof T>(field: K, error: string | undefined) => {
84
+ setErrors((prev) => {
85
+ const next = { ...prev };
86
+ if (error) next[field] = error;
87
+ else delete next[field];
88
+ return next;
89
+ });
90
+ };
91
+
92
+ const setFieldTouched = <K extends keyof T>(field: K, isTouched = true) => {
93
+ setTouched((prev) => ({ ...prev, [field]: isTouched }));
94
+ };
95
+
96
+ const handleChange = <K extends keyof T>(field: K) => {
97
+ return (e: Event & { currentTarget: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement }) => {
98
+ const val = e.currentTarget.value;
99
+ setFieldValue(field, val as any);
100
+ };
101
+ };
102
+
103
+ const handleBlur = <K extends keyof T>(field: K) => {
104
+ return () => {
105
+ setFieldTouched(field, true);
106
+ };
107
+ };
108
+
109
+ const handleSubmit = async (e?: Event) => {
110
+ e?.preventDefault();
111
+ setIsSubmitting(true);
112
+
113
+ // Mark all fields as touched on submit
114
+ const allTouched = Object.keys(values()).reduce((acc, key) => {
115
+ acc[key as keyof T] = true;
116
+ return acc;
117
+ }, {} as FormTouched<T>);
118
+ setTouched(() => allTouched);
119
+
120
+ const validationErrors = await runValidation(values());
121
+ if (Object.keys(validationErrors).length === 0) {
122
+ if (options.onSubmit) {
123
+ await options.onSubmit(values());
124
+ }
125
+ }
126
+
127
+ setIsSubmitting(false);
128
+ };
129
+
130
+ const resetForm = () => {
131
+ setValues(() => ({ ...initialValues }));
132
+ setErrors(() => ({}));
133
+ setTouched(() => ({}));
134
+ setIsSubmitting(false);
135
+ };
136
+
137
+ return {
138
+ values,
139
+ errors,
140
+ touched,
141
+ isSubmitting,
142
+ isValid,
143
+ isDirty,
144
+ setFieldValue,
145
+ setFieldError,
146
+ setFieldTouched,
147
+ handleChange,
148
+ handleBlur,
149
+ handleSubmit,
150
+ resetForm,
151
+ };
152
+ }
@@ -0,0 +1,88 @@
1
+ import { createSignal, onCleanup, type Accessor } from "solid-js";
2
+
3
+ export interface CreateHoverOptions {
4
+ /** Delay in milliseconds before setting hover state to true */
5
+ delayEnter?: number;
6
+ /** Delay in milliseconds before setting hover state to false */
7
+ delayLeave?: number;
8
+ /** Callback fired when hover state transitions to true */
9
+ onHoverStart?: () => void;
10
+ /** Callback fired when hover state transitions to false */
11
+ onHoverEnd?: () => void;
12
+ }
13
+
14
+ export interface CreateHoverReturn {
15
+ /** Accessor indicating whether target element is hovered */
16
+ isHovered: Accessor<boolean>;
17
+ /** Event listeners props to spread onto target JSX element */
18
+ props: {
19
+ onPointerEnter: (e: PointerEvent) => void;
20
+ onPointerLeave: (e: PointerEvent) => void;
21
+ };
22
+ }
23
+
24
+ /**
25
+ * SolidJS reactive primitive for tracking element hover state with optional entrance/exit delays.
26
+ *
27
+ * @param options Configuration options for hover delays and callbacks.
28
+ */
29
+ export function createHover(options: CreateHoverOptions = {}): CreateHoverReturn {
30
+ const [isHovered, setIsHovered] = createSignal(false);
31
+ let enterTimer: ReturnType<typeof setTimeout> | undefined;
32
+ let leaveTimer: ReturnType<typeof setTimeout> | undefined;
33
+
34
+ const clearTimers = () => {
35
+ if (enterTimer) {
36
+ clearTimeout(enterTimer);
37
+ enterTimer = undefined;
38
+ }
39
+ if (leaveTimer) {
40
+ clearTimeout(leaveTimer);
41
+ leaveTimer = undefined;
42
+ }
43
+ };
44
+
45
+ const onPointerEnter = () => {
46
+ clearTimers();
47
+ const delay = options.delayEnter ?? 0;
48
+
49
+ if (delay > 0) {
50
+ enterTimer = setTimeout(() => {
51
+ setIsHovered(true);
52
+ options.onHoverStart?.();
53
+ enterTimer = undefined;
54
+ }, delay);
55
+ } else {
56
+ setIsHovered(true);
57
+ options.onHoverStart?.();
58
+ }
59
+ };
60
+
61
+ const onPointerLeave = () => {
62
+ clearTimers();
63
+ const delay = options.delayLeave ?? 0;
64
+
65
+ if (delay > 0) {
66
+ leaveTimer = setTimeout(() => {
67
+ setIsHovered(false);
68
+ options.onHoverEnd?.();
69
+ leaveTimer = undefined;
70
+ }, delay);
71
+ } else {
72
+ setIsHovered(false);
73
+ options.onHoverEnd?.();
74
+ }
75
+ };
76
+
77
+ onCleanup(() => {
78
+ clearTimers();
79
+ });
80
+
81
+ return {
82
+ isHovered,
83
+ props: {
84
+ onPointerEnter,
85
+ onPointerLeave,
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,109 @@
1
+ import { createSignal, onMount, onCleanup, type Accessor } from "solid-js";
2
+ import { isServer } from "solid-js/web";
3
+
4
+ export interface CreateIdleOptions {
5
+ /** Timeout in milliseconds before user is considered idle (default: 60000 = 60s) */
6
+ timeout?: number;
7
+ /** Initial idle state (default: false) */
8
+ initialState?: boolean;
9
+ /** DOM events to listen for user activity */
10
+ events?: string[];
11
+ /** Callback fired when user becomes idle */
12
+ onIdle?: () => void;
13
+ /** Callback fired when user becomes active after being idle */
14
+ onActive?: () => void;
15
+ }
16
+
17
+ export interface CreateIdleReturn {
18
+ /** Accessor returning true if user has been inactive for timeout period */
19
+ isIdle: Accessor<boolean>;
20
+ /** Accessor returning timestamp in ms of last detected user interaction */
21
+ lastActive: Accessor<number>;
22
+ /** Reset idle state and restart timer */
23
+ reset: () => void;
24
+ }
25
+
26
+ const DEFAULT_EVENTS = [
27
+ "mousemove",
28
+ "mousedown",
29
+ "keydown",
30
+ "touchstart",
31
+ "scroll",
32
+ "wheel",
33
+ ];
34
+
35
+ /**
36
+ * SolidJS reactive primitive for detecting user inactivity (idle state) with customizable timeout and event triggers.
37
+ *
38
+ * @param options Configuration options including timeout in ms and event handlers.
39
+ */
40
+ export function createIdle(options: CreateIdleOptions = {}): CreateIdleReturn {
41
+ const initialIdle = options.initialState ?? false;
42
+
43
+ // SSR: return static defaults — no timers, no listeners
44
+ if (isServer) {
45
+ const [isIdle] = createSignal(initialIdle);
46
+ const [lastActive] = createSignal(0);
47
+ return { isIdle, lastActive, reset: () => {} };
48
+ }
49
+
50
+ const timeoutMs = options.timeout ?? 60000;
51
+ const events = options.events ?? DEFAULT_EVENTS;
52
+
53
+ const [isIdle, setIsIdle] = createSignal(initialIdle);
54
+ const [lastActive, setLastActive] = createSignal(0);
55
+
56
+ let timerId: any = null;
57
+
58
+ const startTimer = () => {
59
+ if (timerId) clearTimeout(timerId);
60
+ timerId = setTimeout(() => {
61
+ if (!isIdle()) {
62
+ setIsIdle(true);
63
+ if (options.onIdle) options.onIdle();
64
+ }
65
+ }, timeoutMs);
66
+ };
67
+
68
+ const handleUserActivity = () => {
69
+ setLastActive(Date.now());
70
+
71
+ if (isIdle()) {
72
+ setIsIdle(false);
73
+ if (options.onActive) options.onActive();
74
+ }
75
+
76
+ startTimer();
77
+ };
78
+
79
+ const reset = () => {
80
+ setLastActive(Date.now());
81
+ setIsIdle(false);
82
+ startTimer();
83
+ };
84
+
85
+ onMount(() => {
86
+ // Set initial lastActive to now on client mount
87
+ setLastActive(Date.now());
88
+
89
+ events.forEach((evt) => {
90
+ window.addEventListener(evt, handleUserActivity, { passive: true });
91
+ });
92
+
93
+ startTimer();
94
+
95
+ onCleanup(() => {
96
+ if (timerId) clearTimeout(timerId);
97
+ events.forEach((evt) => {
98
+ window.removeEventListener(evt, handleUserActivity);
99
+ });
100
+ });
101
+ });
102
+
103
+ return {
104
+ isIdle,
105
+ lastActive,
106
+ reset,
107
+ };
108
+ }
109
+
@@ -0,0 +1,109 @@
1
+ import { createSignal, type Accessor } from "solid-js";
2
+
3
+ export interface CreateInputMaskOptions {
4
+ /** Mask pattern template (e.g. '+995 ### ##-##-##' or '#### #### #### ####') */
5
+ mask: string;
6
+ /** Initial default value */
7
+ defaultValue?: string;
8
+ }
9
+
10
+ export interface CreateInputMaskReturn {
11
+ /** Accessor returning formatted masked input string */
12
+ value: Accessor<string>;
13
+ /** Accessor returning raw unmasked user digits string */
14
+ unmaskedValue: Accessor<string>;
15
+ /** Function to programmatically update input value */
16
+ setValue: (val: string) => void;
17
+ /** JSX props object to spread onto target HTMLInputElement */
18
+ props: {
19
+ value: () => string;
20
+ onInput: (e: Event & { currentTarget: HTMLInputElement }) => void;
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Format raw unmasked text against a mask pattern template.
26
+ */
27
+ export function formatMask(rawInput: string, pattern: string): { masked: string; unmasked: string } {
28
+ if (!rawInput) {
29
+ return { masked: "", unmasked: "" };
30
+ }
31
+
32
+ // Find static prefix of pattern before first slot placeholder (#, 0, X)
33
+ let staticPrefix = "";
34
+ for (let i = 0; i < pattern.length; i++) {
35
+ const char = pattern[i];
36
+ if (char === "#" || char === "0" || char === "X") break;
37
+ staticPrefix += char;
38
+ }
39
+ const staticPrefixDigits = staticPrefix.replace(/\D/g, "");
40
+
41
+ let digits = rawInput.replace(/\D/g, "");
42
+
43
+ // Strip static prefix digits if present at the start
44
+ if (staticPrefixDigits && digits.startsWith(staticPrefixDigits)) {
45
+ digits = digits.slice(staticPrefixDigits.length);
46
+ }
47
+
48
+ if (!digits) {
49
+ return { masked: "", unmasked: "" };
50
+ }
51
+
52
+ let masked = "";
53
+ let digitIndex = 0;
54
+
55
+ for (let i = 0; i < pattern.length; i++) {
56
+ const char = pattern[i];
57
+ if (char === "#" || char === "0" || char === "X") {
58
+ if (digitIndex < digits.length) {
59
+ masked += digits[digitIndex++];
60
+ } else {
61
+ break;
62
+ }
63
+ } else {
64
+ if (digitIndex < digits.length) {
65
+ masked += char;
66
+ } else {
67
+ break;
68
+ }
69
+ }
70
+ }
71
+
72
+ return { masked, unmasked: digits };
73
+ }
74
+
75
+ /**
76
+ * SolidJS reactive primitive for input value masking (phone numbers, credit cards, dates).
77
+ *
78
+ * @param options Configuration options including mask pattern.
79
+ */
80
+ export function createInputMask(options: CreateInputMaskOptions): CreateInputMaskReturn {
81
+ const initial = formatMask(options.defaultValue || "", options.mask);
82
+ const [value, setFormattedValue] = createSignal(initial.masked);
83
+ const [unmaskedValue, setRawValue] = createSignal(initial.unmasked);
84
+
85
+ const setValue = (newVal: string) => {
86
+ const formatted = formatMask(newVal, options.mask);
87
+ setFormattedValue(formatted.masked);
88
+ setRawValue(formatted.unmasked);
89
+ };
90
+
91
+ const onInput = (e: Event & { currentTarget: HTMLInputElement }) => {
92
+ const inputVal = e.currentTarget.value;
93
+ const formatted = formatMask(inputVal, options.mask);
94
+
95
+ setFormattedValue(formatted.masked);
96
+ setRawValue(formatted.unmasked);
97
+ e.currentTarget.value = formatted.masked;
98
+ };
99
+
100
+ return {
101
+ value,
102
+ unmaskedValue,
103
+ setValue,
104
+ props: {
105
+ value: () => value(),
106
+ onInput,
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,82 @@
1
+ import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js";
2
+
3
+ export interface CreateIntersectionObserverOptions extends IntersectionObserverInit {
4
+ /** Whether the observer is active. Defaults to true. */
5
+ enabled?: boolean | Accessor<boolean>;
6
+ }
7
+
8
+ /**
9
+ * SolidJS reactive primitive for observing element visibility and intersection with viewport or root element.
10
+ *
11
+ * @param target Target element or accessor returning HTML element.
12
+ * @param callback Observer callback invoked on intersection state change.
13
+ * @param options IntersectionObserver options (root, rootMargin, threshold, enabled).
14
+ */
15
+ export function createIntersectionObserver(
16
+ target: HTMLElement | Accessor<HTMLElement | undefined>,
17
+ callback: IntersectionObserverCallback,
18
+ options: CreateIntersectionObserverOptions = {}
19
+ ): void {
20
+ const getTarget = (): HTMLElement | undefined => {
21
+ if (typeof target === "function") {
22
+ return (target as Accessor<HTMLElement | undefined>)();
23
+ }
24
+ return target;
25
+ };
26
+
27
+ const isEnabled = (): boolean => {
28
+ if (typeof options.enabled === "function") {
29
+ return options.enabled();
30
+ }
31
+ return options.enabled ?? true;
32
+ };
33
+
34
+ createEffect(() => {
35
+ if (typeof window === "undefined" || !window.IntersectionObserver) {
36
+ return;
37
+ }
38
+
39
+ if (!isEnabled()) return;
40
+
41
+ const el = getTarget();
42
+ if (!el) return;
43
+
44
+ const observer = new IntersectionObserver(callback, {
45
+ root: options.root,
46
+ rootMargin: options.rootMargin,
47
+ threshold: options.threshold,
48
+ });
49
+
50
+ observer.observe(el);
51
+
52
+ onCleanup(() => {
53
+ observer.disconnect();
54
+ });
55
+ });
56
+ }
57
+
58
+ /**
59
+ * SolidJS reactive primitive returning a boolean accessor indicating if element is currently visible in viewport.
60
+ *
61
+ * @param target Target element or accessor returning HTML element.
62
+ * @param options IntersectionObserver options.
63
+ */
64
+ export function createInView(
65
+ target: HTMLElement | Accessor<HTMLElement | undefined>,
66
+ options: CreateIntersectionObserverOptions = {}
67
+ ): Accessor<boolean> {
68
+ const [isInView, setIsInView] = createSignal(false);
69
+
70
+ createIntersectionObserver(
71
+ target,
72
+ (entries) => {
73
+ const entry = entries[0];
74
+ if (entry) {
75
+ setIsInView(entry.isIntersecting);
76
+ }
77
+ },
78
+ options
79
+ );
80
+
81
+ return isInView;
82
+ }