@zentauri-ui/zentauri-components 1.9.2 → 2.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 (215) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +32 -5
  3. package/cli/registry.json +14 -0
  4. package/dist/{chunk-L4PDJ6IB.mjs → chunk-44NX3DAZ.mjs} +3 -3
  5. package/dist/{chunk-L4PDJ6IB.mjs.map → chunk-44NX3DAZ.mjs.map} +1 -1
  6. package/dist/chunk-FAMHSJTK.js +19 -0
  7. package/dist/{chunk-AQHY4S33.js.map → chunk-FAMHSJTK.js.map} +1 -1
  8. package/dist/chunk-I42UYWYA.mjs +128 -0
  9. package/dist/chunk-I42UYWYA.mjs.map +1 -0
  10. package/dist/{chunk-5J6QMTES.js → chunk-IKXO5SJ4.js} +21 -5
  11. package/dist/chunk-IKXO5SJ4.js.map +1 -0
  12. package/dist/{chunk-OPUO55TO.mjs → chunk-JXSM2EHC.mjs} +3 -3
  13. package/dist/{chunk-OPUO55TO.mjs.map → chunk-JXSM2EHC.mjs.map} +1 -1
  14. package/dist/{chunk-LQPKZ5ZD.js → chunk-LS4GY2ZQ.js} +6 -6
  15. package/dist/{chunk-LQPKZ5ZD.js.map → chunk-LS4GY2ZQ.js.map} +1 -1
  16. package/dist/{chunk-VIKQGO4W.mjs → chunk-VQQHVKEU.mjs} +21 -5
  17. package/dist/{chunk-VIKQGO4W.mjs.map → chunk-VQQHVKEU.mjs.map} +1 -1
  18. package/dist/chunk-ZVRGLG35.js +144 -0
  19. package/dist/chunk-ZVRGLG35.js.map +1 -0
  20. package/dist/design-system/combobox.d.ts +124 -0
  21. package/dist/design-system/combobox.d.ts.map +1 -0
  22. package/dist/design-system/facade.js +6 -5
  23. package/dist/design-system/facade.js.map +1 -1
  24. package/dist/design-system/facade.mjs +5 -4
  25. package/dist/design-system/facade.mjs.map +1 -1
  26. package/dist/design-system/index.d.ts +1 -0
  27. package/dist/design-system/index.d.ts.map +1 -1
  28. package/dist/hooks/index.d.ts +13 -0
  29. package/dist/hooks/index.d.ts.map +1 -1
  30. package/dist/hooks/useCookie/index.d.ts +2 -0
  31. package/dist/hooks/useCookie/index.d.ts.map +1 -0
  32. package/dist/hooks/useCookie/useCookie.d.ts +36 -0
  33. package/dist/hooks/useCookie/useCookie.d.ts.map +1 -0
  34. package/dist/hooks/useCookie.js +82 -0
  35. package/dist/hooks/useCookie.js.map +1 -0
  36. package/dist/hooks/useCookie.mjs +80 -0
  37. package/dist/hooks/useCookie.mjs.map +1 -0
  38. package/dist/hooks/useCountdown/index.d.ts +2 -0
  39. package/dist/hooks/useCountdown/index.d.ts.map +1 -0
  40. package/dist/hooks/useCountdown/useCountdown.d.ts +40 -0
  41. package/dist/hooks/useCountdown/useCountdown.d.ts.map +1 -0
  42. package/dist/hooks/useCountdown.js +60 -0
  43. package/dist/hooks/useCountdown.js.map +1 -0
  44. package/dist/hooks/useCountdown.mjs +58 -0
  45. package/dist/hooks/useCountdown.mjs.map +1 -0
  46. package/dist/hooks/useEventListener/index.d.ts +2 -0
  47. package/dist/hooks/useEventListener/index.d.ts.map +1 -0
  48. package/dist/hooks/useEventListener/useEventListener.d.ts +22 -0
  49. package/dist/hooks/useEventListener/useEventListener.d.ts.map +1 -0
  50. package/dist/hooks/useEventListener.js +45 -0
  51. package/dist/hooks/useEventListener.js.map +1 -0
  52. package/dist/hooks/useEventListener.mjs +43 -0
  53. package/dist/hooks/useEventListener.mjs.map +1 -0
  54. package/dist/hooks/useGeolocation/index.d.ts +2 -0
  55. package/dist/hooks/useGeolocation/index.d.ts.map +1 -0
  56. package/dist/hooks/useGeolocation/useGeolocation.d.ts +48 -0
  57. package/dist/hooks/useGeolocation/useGeolocation.d.ts.map +1 -0
  58. package/dist/hooks/useGeolocation.js +111 -0
  59. package/dist/hooks/useGeolocation.js.map +1 -0
  60. package/dist/hooks/useGeolocation.mjs +109 -0
  61. package/dist/hooks/useGeolocation.mjs.map +1 -0
  62. package/dist/hooks/useHotkeys/index.d.ts +2 -0
  63. package/dist/hooks/useHotkeys/index.d.ts.map +1 -0
  64. package/dist/hooks/useHotkeys/useHotkeys.d.ts +24 -0
  65. package/dist/hooks/useHotkeys/useHotkeys.d.ts.map +1 -0
  66. package/dist/hooks/useHotkeys.js +86 -0
  67. package/dist/hooks/useHotkeys.js.map +1 -0
  68. package/dist/hooks/useHotkeys.mjs +84 -0
  69. package/dist/hooks/useHotkeys.mjs.map +1 -0
  70. package/dist/hooks/useIdleTimeout/index.d.ts +2 -0
  71. package/dist/hooks/useIdleTimeout/index.d.ts.map +1 -0
  72. package/dist/hooks/useIdleTimeout/useIdleTimeout.d.ts +31 -0
  73. package/dist/hooks/useIdleTimeout/useIdleTimeout.d.ts.map +1 -0
  74. package/dist/hooks/useIdleTimeout.js +77 -0
  75. package/dist/hooks/useIdleTimeout.js.map +1 -0
  76. package/dist/hooks/useIdleTimeout.mjs +75 -0
  77. package/dist/hooks/useIdleTimeout.mjs.map +1 -0
  78. package/dist/hooks/useInterval/index.d.ts +2 -0
  79. package/dist/hooks/useInterval/index.d.ts.map +1 -0
  80. package/dist/hooks/useInterval/useInterval.d.ts +12 -0
  81. package/dist/hooks/useInterval/useInterval.d.ts.map +1 -0
  82. package/dist/hooks/useInterval.js +27 -0
  83. package/dist/hooks/useInterval.js.map +1 -0
  84. package/dist/hooks/useInterval.mjs +25 -0
  85. package/dist/hooks/useInterval.mjs.map +1 -0
  86. package/dist/hooks/useKeyPress/index.d.ts +2 -0
  87. package/dist/hooks/useKeyPress/index.d.ts.map +1 -0
  88. package/dist/hooks/useKeyPress/useKeyPress.d.ts +15 -0
  89. package/dist/hooks/useKeyPress/useKeyPress.d.ts.map +1 -0
  90. package/dist/hooks/useKeyPress.js +47 -0
  91. package/dist/hooks/useKeyPress.js.map +1 -0
  92. package/dist/hooks/useKeyPress.mjs +45 -0
  93. package/dist/hooks/useKeyPress.mjs.map +1 -0
  94. package/dist/hooks/useLongPress/index.d.ts +2 -0
  95. package/dist/hooks/useLongPress/index.d.ts.map +1 -0
  96. package/dist/hooks/useLongPress/useLongPress.d.ts +46 -0
  97. package/dist/hooks/useLongPress/useLongPress.d.ts.map +1 -0
  98. package/dist/hooks/useLongPress.js +116 -0
  99. package/dist/hooks/useLongPress.js.map +1 -0
  100. package/dist/hooks/useLongPress.mjs +114 -0
  101. package/dist/hooks/useLongPress.mjs.map +1 -0
  102. package/dist/hooks/usePrevious/index.d.ts +2 -0
  103. package/dist/hooks/usePrevious/index.d.ts.map +1 -0
  104. package/dist/hooks/usePrevious/usePrevious.d.ts +13 -0
  105. package/dist/hooks/usePrevious/usePrevious.d.ts.map +1 -0
  106. package/dist/hooks/usePrevious.js +17 -0
  107. package/dist/hooks/usePrevious.js.map +1 -0
  108. package/dist/hooks/usePrevious.mjs +15 -0
  109. package/dist/hooks/usePrevious.mjs.map +1 -0
  110. package/dist/hooks/useScrollPosition/index.d.ts +2 -0
  111. package/dist/hooks/useScrollPosition/index.d.ts.map +1 -0
  112. package/dist/hooks/useScrollPosition/useScrollPosition.d.ts +37 -0
  113. package/dist/hooks/useScrollPosition/useScrollPosition.d.ts.map +1 -0
  114. package/dist/hooks/useScrollPosition.js +41 -0
  115. package/dist/hooks/useScrollPosition.js.map +1 -0
  116. package/dist/hooks/useScrollPosition.mjs +39 -0
  117. package/dist/hooks/useScrollPosition.mjs.map +1 -0
  118. package/dist/hooks/useTimeout/index.d.ts +2 -0
  119. package/dist/hooks/useTimeout/index.d.ts.map +1 -0
  120. package/dist/hooks/useTimeout/useTimeout.d.ts +19 -0
  121. package/dist/hooks/useTimeout/useTimeout.d.ts.map +1 -0
  122. package/dist/hooks/useTimeout.js +38 -0
  123. package/dist/hooks/useTimeout.js.map +1 -0
  124. package/dist/hooks/useTimeout.mjs +36 -0
  125. package/dist/hooks/useTimeout.mjs.map +1 -0
  126. package/dist/hooks/useVirtualList/index.d.ts +2 -0
  127. package/dist/hooks/useVirtualList/index.d.ts.map +1 -0
  128. package/dist/hooks/useVirtualList/useVirtualList.d.ts +47 -0
  129. package/dist/hooks/useVirtualList/useVirtualList.d.ts.map +1 -0
  130. package/dist/hooks/useVirtualList.js +87 -0
  131. package/dist/hooks/useVirtualList.js.map +1 -0
  132. package/dist/hooks/useVirtualList.mjs +85 -0
  133. package/dist/hooks/useVirtualList.mjs.map +1 -0
  134. package/dist/lib/facade.d.ts.map +1 -1
  135. package/dist/ui/buttons/animated.js +8 -7
  136. package/dist/ui/buttons/animated.js.map +1 -1
  137. package/dist/ui/buttons/animated.mjs +6 -5
  138. package/dist/ui/buttons/animated.mjs.map +1 -1
  139. package/dist/ui/buttons.js +9 -8
  140. package/dist/ui/buttons.mjs +7 -6
  141. package/dist/ui/combobox/combobox-base.d.ts +37 -0
  142. package/dist/ui/combobox/combobox-base.d.ts.map +1 -0
  143. package/dist/ui/combobox/combobox.d.ts +6 -0
  144. package/dist/ui/combobox/combobox.d.ts.map +1 -0
  145. package/dist/ui/combobox/index.d.ts +4 -0
  146. package/dist/ui/combobox/index.d.ts.map +1 -0
  147. package/dist/ui/combobox/types.d.ts +70 -0
  148. package/dist/ui/combobox/types.d.ts.map +1 -0
  149. package/dist/ui/combobox/variants.d.ts +17 -0
  150. package/dist/ui/combobox/variants.d.ts.map +1 -0
  151. package/dist/ui/combobox.js +510 -0
  152. package/dist/ui/combobox.js.map +1 -0
  153. package/dist/ui/combobox.mjs +495 -0
  154. package/dist/ui/combobox.mjs.map +1 -0
  155. package/dist/ui/dynamic-stepper.js +18 -17
  156. package/dist/ui/dynamic-stepper.js.map +1 -1
  157. package/dist/ui/dynamic-stepper.mjs +7 -6
  158. package/dist/ui/dynamic-stepper.mjs.map +1 -1
  159. package/dist/ui/pagination.js +14 -13
  160. package/dist/ui/pagination.js.map +1 -1
  161. package/dist/ui/pagination.mjs +6 -5
  162. package/dist/ui/pagination.mjs.map +1 -1
  163. package/package.json +1 -1
  164. package/src/design-system/combobox.ts +204 -0
  165. package/src/design-system/index.ts +1 -0
  166. package/src/hooks/index.ts +50 -0
  167. package/src/hooks/useCookie/index.ts +5 -0
  168. package/src/hooks/useCookie/useCookie.test.ts +57 -0
  169. package/src/hooks/useCookie/useCookie.ts +133 -0
  170. package/src/hooks/useCountdown/index.ts +5 -0
  171. package/src/hooks/useCountdown/useCountdown.test.ts +113 -0
  172. package/src/hooks/useCountdown/useCountdown.ts +106 -0
  173. package/src/hooks/useEventListener/index.ts +4 -0
  174. package/src/hooks/useEventListener/useEventListener.test.ts +60 -0
  175. package/src/hooks/useEventListener/useEventListener.ts +98 -0
  176. package/src/hooks/useGeolocation/index.ts +6 -0
  177. package/src/hooks/useGeolocation/useGeolocation.test.ts +108 -0
  178. package/src/hooks/useGeolocation/useGeolocation.ts +173 -0
  179. package/src/hooks/useHotkeys/index.ts +5 -0
  180. package/src/hooks/useHotkeys/useHotkeys.test.ts +82 -0
  181. package/src/hooks/useHotkeys/useHotkeys.ts +130 -0
  182. package/src/hooks/useIdleTimeout/index.ts +5 -0
  183. package/src/hooks/useIdleTimeout/useIdleTimeout.test.ts +97 -0
  184. package/src/hooks/useIdleTimeout/useIdleTimeout.ts +111 -0
  185. package/src/hooks/useInterval/index.ts +1 -0
  186. package/src/hooks/useInterval/useInterval.test.ts +56 -0
  187. package/src/hooks/useInterval/useInterval.ts +36 -0
  188. package/src/hooks/useKeyPress/index.ts +1 -0
  189. package/src/hooks/useKeyPress/useKeyPress.test.ts +67 -0
  190. package/src/hooks/useKeyPress/useKeyPress.ts +65 -0
  191. package/src/hooks/useLongPress/index.ts +5 -0
  192. package/src/hooks/useLongPress/useLongPress.test.ts +180 -0
  193. package/src/hooks/useLongPress/useLongPress.ts +177 -0
  194. package/src/hooks/usePrevious/index.ts +1 -0
  195. package/src/hooks/usePrevious/usePrevious.test.ts +33 -0
  196. package/src/hooks/usePrevious/usePrevious.ts +24 -0
  197. package/src/hooks/useScrollPosition/index.ts +5 -0
  198. package/src/hooks/useScrollPosition/useScrollPosition.test.ts +69 -0
  199. package/src/hooks/useScrollPosition/useScrollPosition.ts +88 -0
  200. package/src/hooks/useTimeout/index.ts +1 -0
  201. package/src/hooks/useTimeout/useTimeout.test.ts +63 -0
  202. package/src/hooks/useTimeout/useTimeout.ts +58 -0
  203. package/src/hooks/useVirtualList/index.ts +6 -0
  204. package/src/hooks/useVirtualList/useVirtualList.test.ts +102 -0
  205. package/src/hooks/useVirtualList/useVirtualList.ts +144 -0
  206. package/src/lib/facade.test.ts +7 -7
  207. package/src/lib/facade.ts +6 -2
  208. package/src/ui/combobox/combobox-base.tsx +552 -0
  209. package/src/ui/combobox/combobox.test.tsx +292 -0
  210. package/src/ui/combobox/combobox.tsx +8 -0
  211. package/src/ui/combobox/index.ts +33 -0
  212. package/src/ui/combobox/types.ts +91 -0
  213. package/src/ui/combobox/variants.ts +58 -0
  214. package/dist/chunk-5J6QMTES.js.map +0 -1
  215. package/dist/chunk-AQHY4S33.js +0 -19
@@ -0,0 +1,130 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ export type HotkeyHandler = (event: KeyboardEvent) => void;
6
+
7
+ export type UseHotkeysOptions = {
8
+ /** Disable all bindings without unmounting (default `true`). */
9
+ enabled?: boolean;
10
+ /** Call `event.preventDefault()` on a match (default `true`). */
11
+ preventDefault?: boolean;
12
+ /** Fire even when typing in inputs, textareas, selects, or contentEditable (default `false`). */
13
+ allowInInputs?: boolean;
14
+ };
15
+
16
+ type ParsedHotkey = {
17
+ key: string;
18
+ ctrl: boolean;
19
+ meta: boolean;
20
+ alt: boolean;
21
+ shift: boolean;
22
+ mod: boolean;
23
+ };
24
+
25
+ function parseHotkey(combo: string): ParsedHotkey {
26
+ const parsed: ParsedHotkey = {
27
+ key: "",
28
+ ctrl: false,
29
+ meta: false,
30
+ alt: false,
31
+ shift: false,
32
+ mod: false,
33
+ };
34
+ for (const raw of combo.split("+")) {
35
+ const token = raw.trim().toLowerCase();
36
+ if (token === "ctrl" || token === "control") {
37
+ parsed.ctrl = true;
38
+ } else if (token === "meta" || token === "cmd" || token === "command") {
39
+ parsed.meta = true;
40
+ } else if (token === "alt" || token === "option") {
41
+ parsed.alt = true;
42
+ } else if (token === "shift") {
43
+ parsed.shift = true;
44
+ } else if (token === "mod") {
45
+ parsed.mod = true;
46
+ } else {
47
+ parsed.key = token === "space" ? " " : token;
48
+ }
49
+ }
50
+ return parsed;
51
+ }
52
+
53
+ function matchesHotkey(event: KeyboardEvent, hotkey: ParsedHotkey): boolean {
54
+ if (hotkey.key !== event.key.toLowerCase()) {
55
+ return false;
56
+ }
57
+ if (hotkey.mod) {
58
+ if (!event.ctrlKey && !event.metaKey) {
59
+ return false;
60
+ }
61
+ } else if (
62
+ hotkey.ctrl !== event.ctrlKey ||
63
+ hotkey.meta !== event.metaKey
64
+ ) {
65
+ return false;
66
+ }
67
+ return hotkey.alt === event.altKey && hotkey.shift === event.shiftKey;
68
+ }
69
+
70
+ function isEditableTarget(target: EventTarget | null): boolean {
71
+ if (!(target instanceof HTMLElement)) {
72
+ return false;
73
+ }
74
+ return (
75
+ target.tagName === "INPUT" ||
76
+ target.tagName === "TEXTAREA" ||
77
+ target.tagName === "SELECT" ||
78
+ target.isContentEditable
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Binds keyboard shortcut combos (e.g. `"mod+k"`, `"ctrl+shift+p"`, `"escape"`) to handlers on `window`.
84
+ *
85
+ * - Combo syntax: modifier tokens (`ctrl`/`control`, `meta`/`cmd`/`command`, `alt`/`option`, `shift`,
86
+ * `mod`) joined with `+` around a final key matched against `event.key` (use `space` for the spacebar).
87
+ * - `mod` matches Cmd on macOS *or* Ctrl elsewhere (it accepts either modifier).
88
+ * - Bindings are read from a ref on every keydown, so inline objects and closures stay fresh
89
+ * without re-subscribing.
90
+ * - Shortcuts are suppressed while typing in form fields or contentEditable unless `allowInInputs`.
91
+ *
92
+ * @param bindings - Map of combo string → handler. The first matching combo wins per event.
93
+ * @param options - {@link UseHotkeysOptions}
94
+ */
95
+ export function useHotkeys(
96
+ bindings: Record<string, HotkeyHandler>,
97
+ options: UseHotkeysOptions = {},
98
+ ): void {
99
+ const { enabled = true, preventDefault = true, allowInInputs = false } =
100
+ options;
101
+ const bindingsRef = useRef(bindings);
102
+
103
+ useEffect(() => {
104
+ bindingsRef.current = bindings;
105
+ }, [bindings]);
106
+
107
+ useEffect(() => {
108
+ if (!enabled) {
109
+ return;
110
+ }
111
+ const onKeyDown = (event: KeyboardEvent) => {
112
+ if (!allowInInputs && isEditableTarget(event.target)) {
113
+ return;
114
+ }
115
+ for (const [combo, handler] of Object.entries(bindingsRef.current)) {
116
+ if (matchesHotkey(event, parseHotkey(combo))) {
117
+ if (preventDefault) {
118
+ event.preventDefault();
119
+ }
120
+ handler(event);
121
+ return;
122
+ }
123
+ }
124
+ };
125
+ window.addEventListener("keydown", onKeyDown);
126
+ return () => {
127
+ window.removeEventListener("keydown", onKeyDown);
128
+ };
129
+ }, [allowInInputs, enabled, preventDefault]);
130
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ useIdleTimeout,
3
+ type UseIdleTimeoutParams,
4
+ type UseIdleTimeoutResult,
5
+ } from "./useIdleTimeout";
@@ -0,0 +1,97 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { useIdleTimeout } from "./useIdleTimeout";
5
+
6
+ describe("useIdleTimeout", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it("should start active and become idle after the timeout", () => {
16
+ const { result } = renderHook(() => useIdleTimeout({ timeoutMs: 1000 }));
17
+ expect(result.current.isIdle).toBe(false);
18
+ act(() => {
19
+ vi.advanceTimersByTime(1000);
20
+ });
21
+ expect(result.current.isIdle).toBe(true);
22
+ });
23
+
24
+ it("should stay active while activity events occur", () => {
25
+ const { result } = renderHook(() => useIdleTimeout({ timeoutMs: 1000 }));
26
+ act(() => {
27
+ vi.advanceTimersByTime(800);
28
+ window.dispatchEvent(new Event("keydown"));
29
+ vi.advanceTimersByTime(800);
30
+ window.dispatchEvent(new Event("pointermove"));
31
+ vi.advanceTimersByTime(800);
32
+ });
33
+ expect(result.current.isIdle).toBe(false);
34
+ act(() => {
35
+ vi.advanceTimersByTime(200);
36
+ });
37
+ expect(result.current.isIdle).toBe(true);
38
+ });
39
+
40
+ it("should flip back to active on activity after idling", () => {
41
+ const onIdle = vi.fn();
42
+ const onActive = vi.fn();
43
+ const { result } = renderHook(() =>
44
+ useIdleTimeout({ timeoutMs: 500, onIdle, onActive }),
45
+ );
46
+ act(() => {
47
+ vi.advanceTimersByTime(500);
48
+ });
49
+ expect(result.current.isIdle).toBe(true);
50
+ expect(onIdle).toHaveBeenCalledTimes(1);
51
+ act(() => {
52
+ window.dispatchEvent(new Event("pointerdown"));
53
+ });
54
+ expect(result.current.isIdle).toBe(false);
55
+ expect(onActive).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ it("should not fire callbacks on mount", () => {
59
+ const onIdle = vi.fn();
60
+ const onActive = vi.fn();
61
+ renderHook(() =>
62
+ useIdleTimeout({ timeoutMs: 500, onIdle, onActive }),
63
+ );
64
+ expect(onIdle).not.toHaveBeenCalled();
65
+ expect(onActive).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("should respect a custom events list", () => {
69
+ const { result } = renderHook(() =>
70
+ useIdleTimeout({ timeoutMs: 500, events: ["click"] }),
71
+ );
72
+ act(() => {
73
+ vi.advanceTimersByTime(400);
74
+ window.dispatchEvent(new Event("keydown"));
75
+ vi.advanceTimersByTime(100);
76
+ });
77
+ expect(result.current.isIdle).toBe(true);
78
+ act(() => {
79
+ window.dispatchEvent(new Event("click"));
80
+ });
81
+ expect(result.current.isIdle).toBe(false);
82
+ });
83
+
84
+ it("should restart the timer via reset", () => {
85
+ const { result } = renderHook(() => useIdleTimeout({ timeoutMs: 500 }));
86
+ act(() => {
87
+ vi.advanceTimersByTime(400);
88
+ result.current.reset();
89
+ vi.advanceTimersByTime(400);
90
+ });
91
+ expect(result.current.isIdle).toBe(false);
92
+ act(() => {
93
+ vi.advanceTimersByTime(100);
94
+ });
95
+ expect(result.current.isIdle).toBe(true);
96
+ });
97
+ });
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ const DEFAULT_ACTIVITY_EVENTS = [
6
+ "pointerdown",
7
+ "pointermove",
8
+ "keydown",
9
+ "wheel",
10
+ "touchstart",
11
+ "scroll",
12
+ ] as const;
13
+
14
+ export type UseIdleTimeoutParams = {
15
+ /** Inactivity duration in milliseconds before the user counts as idle. */
16
+ timeoutMs: number;
17
+ /** Window events treated as activity (default: pointer, key, wheel, touch, scroll). */
18
+ events?: readonly string[];
19
+ /** Start in the idle state (default `false`). */
20
+ initiallyIdle?: boolean;
21
+ /** Called when the user becomes idle. */
22
+ onIdle?: () => void;
23
+ /** Called when activity resumes after being idle. */
24
+ onActive?: () => void;
25
+ };
26
+
27
+ export type UseIdleTimeoutResult = {
28
+ /** Whether the user is currently idle. */
29
+ isIdle: boolean;
30
+ /** Mark the user active and restart the inactivity timer (e.g. after programmatic activity). */
31
+ reset: () => void;
32
+ };
33
+
34
+ /**
35
+ * Detects user inactivity: `isIdle` flips to `true` after `timeoutMs` without any of the
36
+ * activity events on `window`, and back to `false` on the next activity.
37
+ *
38
+ * - `onIdle` / `onActive` fire on transitions only (not on mount), and are read from refs
39
+ * so inline callbacks stay fresh.
40
+ * - Useful for session expiry warnings, pausing media or polling, and presence indicators.
41
+ *
42
+ * @param params - {@link UseIdleTimeoutParams}
43
+ * @returns {@link UseIdleTimeoutResult}
44
+ */
45
+ export function useIdleTimeout({
46
+ timeoutMs,
47
+ events,
48
+ initiallyIdle = false,
49
+ onIdle,
50
+ onActive,
51
+ }: UseIdleTimeoutParams): UseIdleTimeoutResult {
52
+ const [isIdle, setIsIdle] = useState(initiallyIdle);
53
+ const onIdleRef = useRef(onIdle);
54
+ const onActiveRef = useRef(onActive);
55
+ const restartRef = useRef<() => void>(() => {});
56
+ const firstRunRef = useRef(true);
57
+
58
+ useEffect(() => {
59
+ onIdleRef.current = onIdle;
60
+ onActiveRef.current = onActive;
61
+ }, [onActive, onIdle]);
62
+
63
+ useEffect(() => {
64
+ if (firstRunRef.current) {
65
+ firstRunRef.current = false;
66
+ return;
67
+ }
68
+ if (isIdle) {
69
+ onIdleRef.current?.();
70
+ } else {
71
+ onActiveRef.current?.();
72
+ }
73
+ }, [isIdle]);
74
+
75
+ const eventsKey = (events ?? DEFAULT_ACTIVITY_EVENTS).join(" ");
76
+
77
+ useEffect(() => {
78
+ const eventNames = eventsKey.split(" ").filter(Boolean);
79
+ let timeoutId: number | undefined;
80
+
81
+ const startTimer = () => {
82
+ window.clearTimeout(timeoutId);
83
+ timeoutId = window.setTimeout(() => {
84
+ setIsIdle(true);
85
+ }, timeoutMs);
86
+ };
87
+
88
+ const onActivity = () => {
89
+ setIsIdle(false);
90
+ startTimer();
91
+ };
92
+
93
+ restartRef.current = onActivity;
94
+ startTimer();
95
+ for (const name of eventNames) {
96
+ window.addEventListener(name, onActivity, { passive: true });
97
+ }
98
+ return () => {
99
+ window.clearTimeout(timeoutId);
100
+ for (const name of eventNames) {
101
+ window.removeEventListener(name, onActivity);
102
+ }
103
+ };
104
+ }, [eventsKey, timeoutMs]);
105
+
106
+ const reset = useCallback(() => {
107
+ restartRef.current();
108
+ }, []);
109
+
110
+ return { isIdle, reset };
111
+ }
@@ -0,0 +1 @@
1
+ export { useInterval } from "./useInterval";
@@ -0,0 +1,56 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { useInterval } from "./useInterval";
5
+
6
+ describe("useInterval", () => {
7
+ beforeEach(() => {
8
+ vi.useFakeTimers();
9
+ });
10
+
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it("should invoke the callback on every tick", () => {
16
+ const callback = vi.fn();
17
+ renderHook(() => useInterval(callback, 100));
18
+ vi.advanceTimersByTime(350);
19
+ expect(callback).toHaveBeenCalledTimes(3);
20
+ });
21
+
22
+ it("should pause when delay is null", () => {
23
+ const callback = vi.fn();
24
+ const { rerender } = renderHook(
25
+ ({ delay }: { delay: number | null }) => useInterval(callback, delay),
26
+ { initialProps: { delay: 100 as number | null } },
27
+ );
28
+ vi.advanceTimersByTime(100);
29
+ expect(callback).toHaveBeenCalledTimes(1);
30
+ rerender({ delay: null });
31
+ vi.advanceTimersByTime(500);
32
+ expect(callback).toHaveBeenCalledTimes(1);
33
+ });
34
+
35
+ it("should use the latest callback without restarting the timer", () => {
36
+ const first = vi.fn();
37
+ const second = vi.fn();
38
+ const { rerender } = renderHook(
39
+ ({ callback }: { callback: () => void }) => useInterval(callback, 100),
40
+ { initialProps: { callback: first } },
41
+ );
42
+ vi.advanceTimersByTime(50);
43
+ rerender({ callback: second });
44
+ vi.advanceTimersByTime(50);
45
+ expect(first).not.toHaveBeenCalled();
46
+ expect(second).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ it("should stop on unmount", () => {
50
+ const callback = vi.fn();
51
+ const { unmount } = renderHook(() => useInterval(callback, 100));
52
+ unmount();
53
+ vi.advanceTimersByTime(500);
54
+ expect(callback).not.toHaveBeenCalled();
55
+ });
56
+ });
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ /**
6
+ * Declarative `setInterval`: runs `callback` every `delayMs` milliseconds with automatic cleanup.
7
+ *
8
+ * - The latest callback is kept in a ref, so a new inline function each render does not restart the timer.
9
+ * - Pass `null` as the delay to pause the interval; pass a number again to resume.
10
+ * - Changing `delayMs` clears the previous interval and starts a fresh one.
11
+ *
12
+ * @param callback - Function invoked on every tick.
13
+ * @param delayMs - Interval in milliseconds, or `null` to pause.
14
+ */
15
+ export function useInterval(
16
+ callback: () => void,
17
+ delayMs: number | null,
18
+ ): void {
19
+ const callbackRef = useRef(callback);
20
+
21
+ useEffect(() => {
22
+ callbackRef.current = callback;
23
+ }, [callback]);
24
+
25
+ useEffect(() => {
26
+ if (delayMs == null) {
27
+ return;
28
+ }
29
+ const id = window.setInterval(() => {
30
+ callbackRef.current();
31
+ }, delayMs);
32
+ return () => {
33
+ window.clearInterval(id);
34
+ };
35
+ }, [delayMs]);
36
+ }
@@ -0,0 +1 @@
1
+ export { useKeyPress } from "./useKeyPress";
@@ -0,0 +1,67 @@
1
+ import { act, renderHook } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { useKeyPress } from "./useKeyPress";
5
+
6
+ function dispatchKey(type: "keydown" | "keyup", key: string) {
7
+ window.dispatchEvent(new KeyboardEvent(type, { key }));
8
+ }
9
+
10
+ describe("useKeyPress", () => {
11
+ it("should be false initially", () => {
12
+ const { result } = renderHook(() => useKeyPress("k"));
13
+ expect(result.current).toBe(false);
14
+ });
15
+
16
+ it("should track keydown and keyup for the target key", () => {
17
+ const { result } = renderHook(() => useKeyPress("k"));
18
+ act(() => {
19
+ dispatchKey("keydown", "k");
20
+ });
21
+ expect(result.current).toBe(true);
22
+ act(() => {
23
+ dispatchKey("keyup", "k");
24
+ });
25
+ expect(result.current).toBe(false);
26
+ });
27
+
28
+ it("should ignore other keys", () => {
29
+ const { result } = renderHook(() => useKeyPress("k"));
30
+ act(() => {
31
+ dispatchKey("keydown", "j");
32
+ });
33
+ expect(result.current).toBe(false);
34
+ });
35
+
36
+ it("should match case-insensitively", () => {
37
+ const { result } = renderHook(() => useKeyPress("escape"));
38
+ act(() => {
39
+ dispatchKey("keydown", "Escape");
40
+ });
41
+ expect(result.current).toBe(true);
42
+ });
43
+
44
+ it("should accept an array of keys", () => {
45
+ const { result } = renderHook(() => useKeyPress(["ArrowUp", "ArrowDown"]));
46
+ act(() => {
47
+ dispatchKey("keydown", "ArrowDown");
48
+ });
49
+ expect(result.current).toBe(true);
50
+ act(() => {
51
+ dispatchKey("keyup", "ArrowDown");
52
+ });
53
+ expect(result.current).toBe(false);
54
+ });
55
+
56
+ it("should clear on window blur", () => {
57
+ const { result } = renderHook(() => useKeyPress("k"));
58
+ act(() => {
59
+ dispatchKey("keydown", "k");
60
+ });
61
+ expect(result.current).toBe(true);
62
+ act(() => {
63
+ window.dispatchEvent(new Event("blur"));
64
+ });
65
+ expect(result.current).toBe(false);
66
+ });
67
+ });
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ /**
6
+ * Tracks whether a keyboard key (or any of several keys) is currently held down.
7
+ *
8
+ * - Matches against `event.key`, case-insensitively (`"k"`, `"Escape"`, `"ArrowUp"`, …).
9
+ * - Listens on `window` for `keydown` / `keyup`, and clears on window `blur` so the
10
+ * state cannot get stuck when focus leaves the page mid-press.
11
+ * - When an array of keys is watched, `pressed` remains `true` as long as ANY of the
12
+ * target keys are still held — releasing one watched key while another is held does
13
+ * not reset the state.
14
+ *
15
+ * @param targetKey - A key name or array of key names to watch.
16
+ * @returns `true` while one of the target keys is pressed.
17
+ */
18
+ export function useKeyPress(targetKey: string | string[]): boolean {
19
+ const [pressed, setPressed] = useState(false);
20
+
21
+ // Build a stable string key from the sorted target array.
22
+ // Use \x00 as separator (not "|") so the literal "|" key is handled correctly.
23
+ const normalizedKey = (Array.isArray(targetKey) ? targetKey : [targetKey])
24
+ .map((k) => k.toLowerCase())
25
+ .sort()
26
+ .join("\x00");
27
+
28
+ useEffect(() => {
29
+ const keys = normalizedKey.split("\x00");
30
+ // Local Set tracks which target keys are currently pressed so that releasing
31
+ // one key while another is still held does not incorrectly clear `pressed`.
32
+ const pressedKeys = new Set<string>();
33
+
34
+ const onKeyDown = (event: KeyboardEvent) => {
35
+ const key = event.key.toLowerCase();
36
+ if (keys.includes(key)) {
37
+ pressedKeys.add(key);
38
+ setPressed(true);
39
+ }
40
+ };
41
+ const onKeyUp = (event: KeyboardEvent) => {
42
+ const key = event.key.toLowerCase();
43
+ if (keys.includes(key)) {
44
+ pressedKeys.delete(key);
45
+ setPressed(pressedKeys.size > 0);
46
+ }
47
+ };
48
+ const onBlur = () => {
49
+ pressedKeys.clear();
50
+ setPressed(false);
51
+ };
52
+ window.addEventListener("keydown", onKeyDown);
53
+ window.addEventListener("keyup", onKeyUp);
54
+ window.addEventListener("blur", onBlur);
55
+ return () => {
56
+ pressedKeys.clear();
57
+ setPressed(false);
58
+ window.removeEventListener("keydown", onKeyDown);
59
+ window.removeEventListener("keyup", onKeyUp);
60
+ window.removeEventListener("blur", onBlur);
61
+ };
62
+ }, [normalizedKey]);
63
+
64
+ return pressed;
65
+ }
@@ -0,0 +1,5 @@
1
+ export {
2
+ useLongPress,
3
+ type UseLongPressHandlers,
4
+ type UseLongPressOptions,
5
+ } from "./useLongPress";