@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.
- package/CHANGELOG.md +17 -0
- package/README.md +32 -5
- package/cli/registry.json +14 -0
- package/dist/{chunk-L4PDJ6IB.mjs → chunk-44NX3DAZ.mjs} +3 -3
- package/dist/{chunk-L4PDJ6IB.mjs.map → chunk-44NX3DAZ.mjs.map} +1 -1
- package/dist/chunk-FAMHSJTK.js +19 -0
- package/dist/{chunk-AQHY4S33.js.map → chunk-FAMHSJTK.js.map} +1 -1
- package/dist/chunk-I42UYWYA.mjs +128 -0
- package/dist/chunk-I42UYWYA.mjs.map +1 -0
- package/dist/{chunk-5J6QMTES.js → chunk-IKXO5SJ4.js} +21 -5
- package/dist/chunk-IKXO5SJ4.js.map +1 -0
- package/dist/{chunk-OPUO55TO.mjs → chunk-JXSM2EHC.mjs} +3 -3
- package/dist/{chunk-OPUO55TO.mjs.map → chunk-JXSM2EHC.mjs.map} +1 -1
- package/dist/{chunk-LQPKZ5ZD.js → chunk-LS4GY2ZQ.js} +6 -6
- package/dist/{chunk-LQPKZ5ZD.js.map → chunk-LS4GY2ZQ.js.map} +1 -1
- package/dist/{chunk-VIKQGO4W.mjs → chunk-VQQHVKEU.mjs} +21 -5
- package/dist/{chunk-VIKQGO4W.mjs.map → chunk-VQQHVKEU.mjs.map} +1 -1
- package/dist/chunk-ZVRGLG35.js +144 -0
- package/dist/chunk-ZVRGLG35.js.map +1 -0
- package/dist/design-system/combobox.d.ts +124 -0
- package/dist/design-system/combobox.d.ts.map +1 -0
- package/dist/design-system/facade.js +6 -5
- package/dist/design-system/facade.js.map +1 -1
- package/dist/design-system/facade.mjs +5 -4
- package/dist/design-system/facade.mjs.map +1 -1
- package/dist/design-system/index.d.ts +1 -0
- package/dist/design-system/index.d.ts.map +1 -1
- package/dist/hooks/index.d.ts +13 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useCookie/index.d.ts +2 -0
- package/dist/hooks/useCookie/index.d.ts.map +1 -0
- package/dist/hooks/useCookie/useCookie.d.ts +36 -0
- package/dist/hooks/useCookie/useCookie.d.ts.map +1 -0
- package/dist/hooks/useCookie.js +82 -0
- package/dist/hooks/useCookie.js.map +1 -0
- package/dist/hooks/useCookie.mjs +80 -0
- package/dist/hooks/useCookie.mjs.map +1 -0
- package/dist/hooks/useCountdown/index.d.ts +2 -0
- package/dist/hooks/useCountdown/index.d.ts.map +1 -0
- package/dist/hooks/useCountdown/useCountdown.d.ts +40 -0
- package/dist/hooks/useCountdown/useCountdown.d.ts.map +1 -0
- package/dist/hooks/useCountdown.js +60 -0
- package/dist/hooks/useCountdown.js.map +1 -0
- package/dist/hooks/useCountdown.mjs +58 -0
- package/dist/hooks/useCountdown.mjs.map +1 -0
- package/dist/hooks/useEventListener/index.d.ts +2 -0
- package/dist/hooks/useEventListener/index.d.ts.map +1 -0
- package/dist/hooks/useEventListener/useEventListener.d.ts +22 -0
- package/dist/hooks/useEventListener/useEventListener.d.ts.map +1 -0
- package/dist/hooks/useEventListener.js +45 -0
- package/dist/hooks/useEventListener.js.map +1 -0
- package/dist/hooks/useEventListener.mjs +43 -0
- package/dist/hooks/useEventListener.mjs.map +1 -0
- package/dist/hooks/useGeolocation/index.d.ts +2 -0
- package/dist/hooks/useGeolocation/index.d.ts.map +1 -0
- package/dist/hooks/useGeolocation/useGeolocation.d.ts +48 -0
- package/dist/hooks/useGeolocation/useGeolocation.d.ts.map +1 -0
- package/dist/hooks/useGeolocation.js +111 -0
- package/dist/hooks/useGeolocation.js.map +1 -0
- package/dist/hooks/useGeolocation.mjs +109 -0
- package/dist/hooks/useGeolocation.mjs.map +1 -0
- package/dist/hooks/useHotkeys/index.d.ts +2 -0
- package/dist/hooks/useHotkeys/index.d.ts.map +1 -0
- package/dist/hooks/useHotkeys/useHotkeys.d.ts +24 -0
- package/dist/hooks/useHotkeys/useHotkeys.d.ts.map +1 -0
- package/dist/hooks/useHotkeys.js +86 -0
- package/dist/hooks/useHotkeys.js.map +1 -0
- package/dist/hooks/useHotkeys.mjs +84 -0
- package/dist/hooks/useHotkeys.mjs.map +1 -0
- package/dist/hooks/useIdleTimeout/index.d.ts +2 -0
- package/dist/hooks/useIdleTimeout/index.d.ts.map +1 -0
- package/dist/hooks/useIdleTimeout/useIdleTimeout.d.ts +31 -0
- package/dist/hooks/useIdleTimeout/useIdleTimeout.d.ts.map +1 -0
- package/dist/hooks/useIdleTimeout.js +77 -0
- package/dist/hooks/useIdleTimeout.js.map +1 -0
- package/dist/hooks/useIdleTimeout.mjs +75 -0
- package/dist/hooks/useIdleTimeout.mjs.map +1 -0
- package/dist/hooks/useInterval/index.d.ts +2 -0
- package/dist/hooks/useInterval/index.d.ts.map +1 -0
- package/dist/hooks/useInterval/useInterval.d.ts +12 -0
- package/dist/hooks/useInterval/useInterval.d.ts.map +1 -0
- package/dist/hooks/useInterval.js +27 -0
- package/dist/hooks/useInterval.js.map +1 -0
- package/dist/hooks/useInterval.mjs +25 -0
- package/dist/hooks/useInterval.mjs.map +1 -0
- package/dist/hooks/useKeyPress/index.d.ts +2 -0
- package/dist/hooks/useKeyPress/index.d.ts.map +1 -0
- package/dist/hooks/useKeyPress/useKeyPress.d.ts +15 -0
- package/dist/hooks/useKeyPress/useKeyPress.d.ts.map +1 -0
- package/dist/hooks/useKeyPress.js +47 -0
- package/dist/hooks/useKeyPress.js.map +1 -0
- package/dist/hooks/useKeyPress.mjs +45 -0
- package/dist/hooks/useKeyPress.mjs.map +1 -0
- package/dist/hooks/useLongPress/index.d.ts +2 -0
- package/dist/hooks/useLongPress/index.d.ts.map +1 -0
- package/dist/hooks/useLongPress/useLongPress.d.ts +46 -0
- package/dist/hooks/useLongPress/useLongPress.d.ts.map +1 -0
- package/dist/hooks/useLongPress.js +116 -0
- package/dist/hooks/useLongPress.js.map +1 -0
- package/dist/hooks/useLongPress.mjs +114 -0
- package/dist/hooks/useLongPress.mjs.map +1 -0
- package/dist/hooks/usePrevious/index.d.ts +2 -0
- package/dist/hooks/usePrevious/index.d.ts.map +1 -0
- package/dist/hooks/usePrevious/usePrevious.d.ts +13 -0
- package/dist/hooks/usePrevious/usePrevious.d.ts.map +1 -0
- package/dist/hooks/usePrevious.js +17 -0
- package/dist/hooks/usePrevious.js.map +1 -0
- package/dist/hooks/usePrevious.mjs +15 -0
- package/dist/hooks/usePrevious.mjs.map +1 -0
- package/dist/hooks/useScrollPosition/index.d.ts +2 -0
- package/dist/hooks/useScrollPosition/index.d.ts.map +1 -0
- package/dist/hooks/useScrollPosition/useScrollPosition.d.ts +37 -0
- package/dist/hooks/useScrollPosition/useScrollPosition.d.ts.map +1 -0
- package/dist/hooks/useScrollPosition.js +41 -0
- package/dist/hooks/useScrollPosition.js.map +1 -0
- package/dist/hooks/useScrollPosition.mjs +39 -0
- package/dist/hooks/useScrollPosition.mjs.map +1 -0
- package/dist/hooks/useTimeout/index.d.ts +2 -0
- package/dist/hooks/useTimeout/index.d.ts.map +1 -0
- package/dist/hooks/useTimeout/useTimeout.d.ts +19 -0
- package/dist/hooks/useTimeout/useTimeout.d.ts.map +1 -0
- package/dist/hooks/useTimeout.js +38 -0
- package/dist/hooks/useTimeout.js.map +1 -0
- package/dist/hooks/useTimeout.mjs +36 -0
- package/dist/hooks/useTimeout.mjs.map +1 -0
- package/dist/hooks/useVirtualList/index.d.ts +2 -0
- package/dist/hooks/useVirtualList/index.d.ts.map +1 -0
- package/dist/hooks/useVirtualList/useVirtualList.d.ts +47 -0
- package/dist/hooks/useVirtualList/useVirtualList.d.ts.map +1 -0
- package/dist/hooks/useVirtualList.js +87 -0
- package/dist/hooks/useVirtualList.js.map +1 -0
- package/dist/hooks/useVirtualList.mjs +85 -0
- package/dist/hooks/useVirtualList.mjs.map +1 -0
- package/dist/lib/facade.d.ts.map +1 -1
- package/dist/ui/buttons/animated.js +8 -7
- package/dist/ui/buttons/animated.js.map +1 -1
- package/dist/ui/buttons/animated.mjs +6 -5
- package/dist/ui/buttons/animated.mjs.map +1 -1
- package/dist/ui/buttons.js +9 -8
- package/dist/ui/buttons.mjs +7 -6
- package/dist/ui/combobox/combobox-base.d.ts +37 -0
- package/dist/ui/combobox/combobox-base.d.ts.map +1 -0
- package/dist/ui/combobox/combobox.d.ts +6 -0
- package/dist/ui/combobox/combobox.d.ts.map +1 -0
- package/dist/ui/combobox/index.d.ts +4 -0
- package/dist/ui/combobox/index.d.ts.map +1 -0
- package/dist/ui/combobox/types.d.ts +70 -0
- package/dist/ui/combobox/types.d.ts.map +1 -0
- package/dist/ui/combobox/variants.d.ts +17 -0
- package/dist/ui/combobox/variants.d.ts.map +1 -0
- package/dist/ui/combobox.js +510 -0
- package/dist/ui/combobox.js.map +1 -0
- package/dist/ui/combobox.mjs +495 -0
- package/dist/ui/combobox.mjs.map +1 -0
- package/dist/ui/dynamic-stepper.js +18 -17
- package/dist/ui/dynamic-stepper.js.map +1 -1
- package/dist/ui/dynamic-stepper.mjs +7 -6
- package/dist/ui/dynamic-stepper.mjs.map +1 -1
- package/dist/ui/pagination.js +14 -13
- package/dist/ui/pagination.js.map +1 -1
- package/dist/ui/pagination.mjs +6 -5
- package/dist/ui/pagination.mjs.map +1 -1
- package/package.json +1 -1
- package/src/design-system/combobox.ts +204 -0
- package/src/design-system/index.ts +1 -0
- package/src/hooks/index.ts +50 -0
- package/src/hooks/useCookie/index.ts +5 -0
- package/src/hooks/useCookie/useCookie.test.ts +57 -0
- package/src/hooks/useCookie/useCookie.ts +133 -0
- package/src/hooks/useCountdown/index.ts +5 -0
- package/src/hooks/useCountdown/useCountdown.test.ts +113 -0
- package/src/hooks/useCountdown/useCountdown.ts +106 -0
- package/src/hooks/useEventListener/index.ts +4 -0
- package/src/hooks/useEventListener/useEventListener.test.ts +60 -0
- package/src/hooks/useEventListener/useEventListener.ts +98 -0
- package/src/hooks/useGeolocation/index.ts +6 -0
- package/src/hooks/useGeolocation/useGeolocation.test.ts +108 -0
- package/src/hooks/useGeolocation/useGeolocation.ts +173 -0
- package/src/hooks/useHotkeys/index.ts +5 -0
- package/src/hooks/useHotkeys/useHotkeys.test.ts +82 -0
- package/src/hooks/useHotkeys/useHotkeys.ts +130 -0
- package/src/hooks/useIdleTimeout/index.ts +5 -0
- package/src/hooks/useIdleTimeout/useIdleTimeout.test.ts +97 -0
- package/src/hooks/useIdleTimeout/useIdleTimeout.ts +111 -0
- package/src/hooks/useInterval/index.ts +1 -0
- package/src/hooks/useInterval/useInterval.test.ts +56 -0
- package/src/hooks/useInterval/useInterval.ts +36 -0
- package/src/hooks/useKeyPress/index.ts +1 -0
- package/src/hooks/useKeyPress/useKeyPress.test.ts +67 -0
- package/src/hooks/useKeyPress/useKeyPress.ts +65 -0
- package/src/hooks/useLongPress/index.ts +5 -0
- package/src/hooks/useLongPress/useLongPress.test.ts +180 -0
- package/src/hooks/useLongPress/useLongPress.ts +177 -0
- package/src/hooks/usePrevious/index.ts +1 -0
- package/src/hooks/usePrevious/usePrevious.test.ts +33 -0
- package/src/hooks/usePrevious/usePrevious.ts +24 -0
- package/src/hooks/useScrollPosition/index.ts +5 -0
- package/src/hooks/useScrollPosition/useScrollPosition.test.ts +69 -0
- package/src/hooks/useScrollPosition/useScrollPosition.ts +88 -0
- package/src/hooks/useTimeout/index.ts +1 -0
- package/src/hooks/useTimeout/useTimeout.test.ts +63 -0
- package/src/hooks/useTimeout/useTimeout.ts +58 -0
- package/src/hooks/useVirtualList/index.ts +6 -0
- package/src/hooks/useVirtualList/useVirtualList.test.ts +102 -0
- package/src/hooks/useVirtualList/useVirtualList.ts +144 -0
- package/src/lib/facade.test.ts +7 -7
- package/src/lib/facade.ts +6 -2
- package/src/ui/combobox/combobox-base.tsx +552 -0
- package/src/ui/combobox/combobox.test.tsx +292 -0
- package/src/ui/combobox/combobox.tsx +8 -0
- package/src/ui/combobox/index.ts +33 -0
- package/src/ui/combobox/types.ts +91 -0
- package/src/ui/combobox/variants.ts +58 -0
- package/dist/chunk-5J6QMTES.js.map +0 -1
- package/dist/chunk-AQHY4S33.js +0 -19
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export type UseCountdownParams = {
|
|
6
|
+
/** Value the countdown starts from. */
|
|
7
|
+
countStart: number;
|
|
8
|
+
/** Value the countdown stops at (default `0`). */
|
|
9
|
+
countStop?: number;
|
|
10
|
+
/** Milliseconds between ticks (default `1000`). */
|
|
11
|
+
intervalMs?: number;
|
|
12
|
+
/** Start counting immediately on mount (default `false`). */
|
|
13
|
+
autoStart?: boolean;
|
|
14
|
+
/** Called once when the countdown reaches `countStop`. */
|
|
15
|
+
onComplete?: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type UseCountdownResult = {
|
|
19
|
+
/** Current count value. */
|
|
20
|
+
count: number;
|
|
21
|
+
/** Whether the countdown is actively ticking. */
|
|
22
|
+
isRunning: boolean;
|
|
23
|
+
/** Whether the countdown has reached `countStop`. */
|
|
24
|
+
isComplete: boolean;
|
|
25
|
+
/** Reset to `countStart` and begin ticking. */
|
|
26
|
+
start: () => void;
|
|
27
|
+
/** Stop ticking, keeping the current count. */
|
|
28
|
+
pause: () => void;
|
|
29
|
+
/** Continue ticking from the current count. No-op if the countdown is complete. */
|
|
30
|
+
resume: () => void;
|
|
31
|
+
/** Stop ticking and reset the count to `countStart`. */
|
|
32
|
+
reset: () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Countdown timer decrementing by one each tick, with start, pause, resume, and reset controls.
|
|
37
|
+
*
|
|
38
|
+
* - Counts from `countStart` down to `countStop` (default `0`) every `intervalMs` (default 1s).
|
|
39
|
+
* - `onComplete` fires once when the count reaches `countStop`; the timer stops automatically.
|
|
40
|
+
* - `resume` on a completed countdown is a no-op; call `start` or `reset` to run it again.
|
|
41
|
+
*
|
|
42
|
+
* @param params - {@link UseCountdownParams}
|
|
43
|
+
* @returns {@link UseCountdownResult}
|
|
44
|
+
*/
|
|
45
|
+
export function useCountdown({
|
|
46
|
+
countStart,
|
|
47
|
+
countStop = 0,
|
|
48
|
+
intervalMs = 1000,
|
|
49
|
+
autoStart = false,
|
|
50
|
+
onComplete,
|
|
51
|
+
}: UseCountdownParams): UseCountdownResult {
|
|
52
|
+
const [count, setCount] = useState(countStart);
|
|
53
|
+
const [isRunning, setIsRunning] = useState(autoStart);
|
|
54
|
+
const onCompleteRef = useRef(onComplete);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
onCompleteRef.current = onComplete;
|
|
58
|
+
}, [onComplete]);
|
|
59
|
+
|
|
60
|
+
const isComplete = count <= countStop;
|
|
61
|
+
|
|
62
|
+
// Interval effect: does NOT include `count` in deps so the timer is not recreated on every tick.
|
|
63
|
+
// Completion is handled by the separate effect below.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!isRunning) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const id = window.setInterval(() => {
|
|
69
|
+
setCount((previous) => Math.max(countStop, previous - 1));
|
|
70
|
+
}, intervalMs);
|
|
71
|
+
return () => {
|
|
72
|
+
window.clearInterval(id);
|
|
73
|
+
};
|
|
74
|
+
}, [countStop, intervalMs, isRunning]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (isRunning && count <= countStop) {
|
|
78
|
+
setIsRunning(false);
|
|
79
|
+
onCompleteRef.current?.();
|
|
80
|
+
}
|
|
81
|
+
}, [count, countStop, isRunning]);
|
|
82
|
+
|
|
83
|
+
const start = useCallback(() => {
|
|
84
|
+
setCount(countStart);
|
|
85
|
+
setIsRunning(true);
|
|
86
|
+
}, [countStart]);
|
|
87
|
+
|
|
88
|
+
const pause = useCallback(() => {
|
|
89
|
+
setIsRunning(false);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const resume = useCallback(() => {
|
|
93
|
+
// No-op when the countdown has already reached countStop to prevent retriggering onComplete.
|
|
94
|
+
if (isComplete) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setIsRunning(true);
|
|
98
|
+
}, [isComplete]);
|
|
99
|
+
|
|
100
|
+
const reset = useCallback(() => {
|
|
101
|
+
setIsRunning(false);
|
|
102
|
+
setCount(countStart);
|
|
103
|
+
}, [countStart]);
|
|
104
|
+
|
|
105
|
+
return { count, isRunning, isComplete, start, pause, resume, reset };
|
|
106
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { useEventListener } from "./useEventListener";
|
|
5
|
+
|
|
6
|
+
describe("useEventListener", () => {
|
|
7
|
+
it("should listen on window by default", () => {
|
|
8
|
+
const handler = vi.fn();
|
|
9
|
+
renderHook(() => useEventListener("focus", handler));
|
|
10
|
+
window.dispatchEvent(new Event("focus"));
|
|
11
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("should listen on an element target", () => {
|
|
15
|
+
const element = document.createElement("button");
|
|
16
|
+
const handler = vi.fn();
|
|
17
|
+
renderHook(() => useEventListener("click", handler, element));
|
|
18
|
+
element.dispatchEvent(new Event("click"));
|
|
19
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should listen on a ref target resolved after mount", () => {
|
|
23
|
+
const element = document.createElement("div");
|
|
24
|
+
const ref = { current: element };
|
|
25
|
+
const handler = vi.fn();
|
|
26
|
+
renderHook(() => useEventListener("click", handler, ref));
|
|
27
|
+
element.dispatchEvent(new Event("click"));
|
|
28
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should always invoke the latest handler without re-subscribing", () => {
|
|
32
|
+
const first = vi.fn();
|
|
33
|
+
const second = vi.fn();
|
|
34
|
+
const addSpy = vi.spyOn(window, "addEventListener");
|
|
35
|
+
const { rerender } = renderHook(
|
|
36
|
+
({ handler }: { handler: () => void }) =>
|
|
37
|
+
useEventListener("focus", handler),
|
|
38
|
+
{ initialProps: { handler: first } },
|
|
39
|
+
);
|
|
40
|
+
const subscriptions = addSpy.mock.calls.filter(
|
|
41
|
+
([type]) => type === "focus",
|
|
42
|
+
).length;
|
|
43
|
+
rerender({ handler: second });
|
|
44
|
+
window.dispatchEvent(new Event("focus"));
|
|
45
|
+
expect(first).not.toHaveBeenCalled();
|
|
46
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
47
|
+
expect(
|
|
48
|
+
addSpy.mock.calls.filter(([type]) => type === "focus").length,
|
|
49
|
+
).toBe(subscriptions);
|
|
50
|
+
addSpy.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should remove the listener on unmount", () => {
|
|
54
|
+
const handler = vi.fn();
|
|
55
|
+
const { unmount } = renderHook(() => useEventListener("focus", handler));
|
|
56
|
+
unmount();
|
|
57
|
+
window.dispatchEvent(new Event("focus"));
|
|
58
|
+
expect(handler).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { RefObject } from "react";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
|
|
6
|
+
export type UseEventListenerTarget =
|
|
7
|
+
| EventTarget
|
|
8
|
+
| RefObject<EventTarget | null>
|
|
9
|
+
| null;
|
|
10
|
+
|
|
11
|
+
function resolveTarget(
|
|
12
|
+
target: UseEventListenerTarget | undefined,
|
|
13
|
+
): EventTarget | null {
|
|
14
|
+
if (target === undefined) {
|
|
15
|
+
// No target specified — default to window.
|
|
16
|
+
return typeof window === "undefined" ? null : window;
|
|
17
|
+
}
|
|
18
|
+
if (target === null) {
|
|
19
|
+
// Explicit null — caller opts out; do not attach any listener.
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (target instanceof EventTarget) {
|
|
23
|
+
return target;
|
|
24
|
+
}
|
|
25
|
+
return target.current;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Attaches a DOM event listener with automatic cleanup and a stable wrapper around the latest handler.
|
|
30
|
+
*
|
|
31
|
+
* - Defaults to `window`; pass an element, `document`, or a React ref object as `target`.
|
|
32
|
+
* - Pass explicit `null` as `target` to skip attaching (useful for conditional subscriptions).
|
|
33
|
+
* - The handler is kept in a ref, so passing a new inline function each render does not re-subscribe.
|
|
34
|
+
* - Ref targets are resolved when the effect runs (after mount); if the ref is retargeted later,
|
|
35
|
+
* re-render with a state-backed node (or key the component) to re-subscribe.
|
|
36
|
+
* - Pass `options` as individual booleans or a stable object reference to avoid re-subscribing on
|
|
37
|
+
* every render. Internally, `capture`, `passive`, and `once` are compared individually.
|
|
38
|
+
*
|
|
39
|
+
* @param eventName - DOM event name (typed against `WindowEventMap` / `DocumentEventMap` / `HTMLElementEventMap`).
|
|
40
|
+
* @param handler - Listener invoked with the native event.
|
|
41
|
+
* @param target - Event target, React ref to one, `undefined` for `window`, or `null` to skip.
|
|
42
|
+
* @param options - Standard `addEventListener` options.
|
|
43
|
+
*/
|
|
44
|
+
export function useEventListener<K extends keyof WindowEventMap>(
|
|
45
|
+
eventName: K,
|
|
46
|
+
handler: (event: WindowEventMap[K]) => void,
|
|
47
|
+
target?: Window | null,
|
|
48
|
+
options?: boolean | AddEventListenerOptions,
|
|
49
|
+
): void;
|
|
50
|
+
export function useEventListener<K extends keyof DocumentEventMap>(
|
|
51
|
+
eventName: K,
|
|
52
|
+
handler: (event: DocumentEventMap[K]) => void,
|
|
53
|
+
target: Document | RefObject<Document | null>,
|
|
54
|
+
options?: boolean | AddEventListenerOptions,
|
|
55
|
+
): void;
|
|
56
|
+
export function useEventListener<
|
|
57
|
+
K extends keyof HTMLElementEventMap,
|
|
58
|
+
T extends HTMLElement = HTMLElement,
|
|
59
|
+
>(
|
|
60
|
+
eventName: K,
|
|
61
|
+
handler: (event: HTMLElementEventMap[K]) => void,
|
|
62
|
+
target: T | RefObject<T | null> | null,
|
|
63
|
+
options?: boolean | AddEventListenerOptions,
|
|
64
|
+
): void;
|
|
65
|
+
export function useEventListener(
|
|
66
|
+
eventName: string,
|
|
67
|
+
handler: (event: Event) => void,
|
|
68
|
+
target?: UseEventListenerTarget,
|
|
69
|
+
options?: boolean | AddEventListenerOptions,
|
|
70
|
+
): void {
|
|
71
|
+
const handlerRef = useRef(handler);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
handlerRef.current = handler;
|
|
75
|
+
}, [handler]);
|
|
76
|
+
|
|
77
|
+
// Destructure options to stable primitives so an inline `{ passive: true }` object literal
|
|
78
|
+
// passed by callers does not cause the listener to be removed and re-added on every render.
|
|
79
|
+
const capture =
|
|
80
|
+
typeof options === "boolean" ? options : (options?.capture ?? false);
|
|
81
|
+
const passive = typeof options === "object" ? (options?.passive ?? false) : false;
|
|
82
|
+
const once = typeof options === "object" ? (options?.once ?? false) : false;
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const node = resolveTarget(target);
|
|
86
|
+
if (node == null) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const listenerOptions = { capture, passive, once };
|
|
90
|
+
const listener = (event: Event) => {
|
|
91
|
+
handlerRef.current(event);
|
|
92
|
+
};
|
|
93
|
+
node.addEventListener(eventName, listener, listenerOptions);
|
|
94
|
+
return () => {
|
|
95
|
+
node.removeEventListener(eventName, listener, listenerOptions);
|
|
96
|
+
};
|
|
97
|
+
}, [eventName, target, capture, passive, once]);
|
|
98
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { useGeolocation } from "./useGeolocation";
|
|
5
|
+
|
|
6
|
+
type SuccessCallback = (position: GeolocationPosition) => void;
|
|
7
|
+
type ErrorCallback = (error: GeolocationPositionError) => void;
|
|
8
|
+
|
|
9
|
+
function stubGeolocation() {
|
|
10
|
+
const callbacks: { success?: SuccessCallback; error?: ErrorCallback } = {};
|
|
11
|
+
const geolocation = {
|
|
12
|
+
getCurrentPosition: vi.fn((success: SuccessCallback, error: ErrorCallback) => {
|
|
13
|
+
callbacks.success = success;
|
|
14
|
+
callbacks.error = error;
|
|
15
|
+
}),
|
|
16
|
+
watchPosition: vi.fn((success: SuccessCallback, error: ErrorCallback) => {
|
|
17
|
+
callbacks.success = success;
|
|
18
|
+
callbacks.error = error;
|
|
19
|
+
return 1;
|
|
20
|
+
}),
|
|
21
|
+
clearWatch: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
Object.defineProperty(navigator, "geolocation", {
|
|
24
|
+
configurable: true,
|
|
25
|
+
value: geolocation,
|
|
26
|
+
});
|
|
27
|
+
return { geolocation, callbacks };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makePosition(latitude: number, longitude: number) {
|
|
31
|
+
return {
|
|
32
|
+
coords: {
|
|
33
|
+
latitude,
|
|
34
|
+
longitude,
|
|
35
|
+
accuracy: 10,
|
|
36
|
+
altitude: null,
|
|
37
|
+
altitudeAccuracy: null,
|
|
38
|
+
heading: null,
|
|
39
|
+
speed: null,
|
|
40
|
+
},
|
|
41
|
+
timestamp: 1700000000000,
|
|
42
|
+
} as GeolocationPosition;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("useGeolocation", () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
// @ts-expect-error cleanup of the test-defined property
|
|
48
|
+
delete navigator.geolocation;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should report unsupported environments", () => {
|
|
52
|
+
const { result } = renderHook(() => useGeolocation());
|
|
53
|
+
expect(result.current.isSupported).toBe(false);
|
|
54
|
+
expect(result.current.loading).toBe(false);
|
|
55
|
+
expect(result.current.position).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("should not request position while disabled", () => {
|
|
59
|
+
const { geolocation } = stubGeolocation();
|
|
60
|
+
const { result } = renderHook(() => useGeolocation({ enabled: false }));
|
|
61
|
+
expect(geolocation.watchPosition).not.toHaveBeenCalled();
|
|
62
|
+
expect(result.current.loading).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should watch position and expose the snapshot", async () => {
|
|
66
|
+
const { geolocation, callbacks } = stubGeolocation();
|
|
67
|
+
const { result } = renderHook(() => useGeolocation());
|
|
68
|
+
expect(geolocation.watchPosition).toHaveBeenCalledTimes(1);
|
|
69
|
+
expect(result.current.loading).toBe(true);
|
|
70
|
+
act(() => {
|
|
71
|
+
callbacks.success?.(makePosition(48.85, 2.35));
|
|
72
|
+
});
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(result.current.position?.latitude).toBe(48.85);
|
|
75
|
+
});
|
|
76
|
+
expect(result.current.position?.longitude).toBe(2.35);
|
|
77
|
+
expect(result.current.loading).toBe(false);
|
|
78
|
+
expect(result.current.error).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should use a one-shot read when watch is false", () => {
|
|
82
|
+
const { geolocation } = stubGeolocation();
|
|
83
|
+
renderHook(() => useGeolocation({ watch: false }));
|
|
84
|
+
expect(geolocation.getCurrentPosition).toHaveBeenCalledTimes(1);
|
|
85
|
+
expect(geolocation.watchPosition).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should surface errors and clear loading", () => {
|
|
89
|
+
const { callbacks } = stubGeolocation();
|
|
90
|
+
const { result } = renderHook(() => useGeolocation());
|
|
91
|
+
const positionError = {
|
|
92
|
+
code: 1,
|
|
93
|
+
message: "denied",
|
|
94
|
+
} as GeolocationPositionError;
|
|
95
|
+
act(() => {
|
|
96
|
+
callbacks.error?.(positionError);
|
|
97
|
+
});
|
|
98
|
+
expect(result.current.error).toBe(positionError);
|
|
99
|
+
expect(result.current.loading).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should clear the watch on unmount", () => {
|
|
103
|
+
const { geolocation } = stubGeolocation();
|
|
104
|
+
const { unmount } = renderHook(() => useGeolocation());
|
|
105
|
+
unmount();
|
|
106
|
+
expect(geolocation.clearWatch).toHaveBeenCalledWith(1);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export type GeolocationCoordinatesSnapshot = {
|
|
6
|
+
latitude: number;
|
|
7
|
+
longitude: number;
|
|
8
|
+
accuracy: number;
|
|
9
|
+
altitude: number | null;
|
|
10
|
+
altitudeAccuracy: number | null;
|
|
11
|
+
heading: number | null;
|
|
12
|
+
speed: number | null;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type UseGeolocationParams = {
|
|
17
|
+
/** Start requesting the position (default `true`). Set `false` to defer the permission prompt. */
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
/** Keep watching for position updates instead of a one-shot read (default `true`). */
|
|
20
|
+
watch?: boolean;
|
|
21
|
+
/** `PositionOptions.enableHighAccuracy`. */
|
|
22
|
+
enableHighAccuracy?: boolean;
|
|
23
|
+
/** `PositionOptions.maximumAge` in milliseconds. */
|
|
24
|
+
maximumAge?: number;
|
|
25
|
+
/** `PositionOptions.timeout` in milliseconds. */
|
|
26
|
+
timeout?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type UseGeolocationResult = {
|
|
30
|
+
/** Whether `navigator.geolocation` exists in this browser. */
|
|
31
|
+
isSupported: boolean;
|
|
32
|
+
/** True while enabled and no position or error has arrived yet. */
|
|
33
|
+
loading: boolean;
|
|
34
|
+
/** Permission state from the Permissions API (`"unknown"` where unsupported or before resolution). */
|
|
35
|
+
permission: PermissionState | "unknown";
|
|
36
|
+
/** Latest position snapshot, or `null` before the first fix. */
|
|
37
|
+
position: GeolocationCoordinatesSnapshot | null;
|
|
38
|
+
/** Latest geolocation error, cleared when a new fix succeeds. */
|
|
39
|
+
error: GeolocationPositionError | null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function toSnapshot(position: GeolocationPosition): GeolocationCoordinatesSnapshot {
|
|
43
|
+
const { coords } = position;
|
|
44
|
+
return {
|
|
45
|
+
latitude: coords.latitude,
|
|
46
|
+
longitude: coords.longitude,
|
|
47
|
+
accuracy: coords.accuracy,
|
|
48
|
+
altitude: coords.altitude,
|
|
49
|
+
altitudeAccuracy: coords.altitudeAccuracy,
|
|
50
|
+
heading: coords.heading,
|
|
51
|
+
speed: coords.speed,
|
|
52
|
+
timestamp: position.timestamp,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Browser geolocation with loading, error, and Permissions API state.
|
|
58
|
+
*
|
|
59
|
+
* - Requesting the position triggers the browser permission prompt; pass `enabled: false`
|
|
60
|
+
* and flip it from a user gesture to avoid prompting on mount.
|
|
61
|
+
* - `watch: true` (default) uses `watchPosition` for live updates; `false` reads once.
|
|
62
|
+
* - `permission` mirrors `navigator.permissions.query({ name: "geolocation" })` including
|
|
63
|
+
* change events, independent of whether a request is active.
|
|
64
|
+
*
|
|
65
|
+
* @param params - {@link UseGeolocationParams}
|
|
66
|
+
* @returns {@link UseGeolocationResult}
|
|
67
|
+
*/
|
|
68
|
+
export function useGeolocation(
|
|
69
|
+
params: UseGeolocationParams = {},
|
|
70
|
+
): UseGeolocationResult {
|
|
71
|
+
const {
|
|
72
|
+
enabled = true,
|
|
73
|
+
watch = true,
|
|
74
|
+
enableHighAccuracy,
|
|
75
|
+
maximumAge,
|
|
76
|
+
timeout,
|
|
77
|
+
} = params;
|
|
78
|
+
|
|
79
|
+
const isSupported =
|
|
80
|
+
typeof navigator !== "undefined" && "geolocation" in navigator;
|
|
81
|
+
|
|
82
|
+
const [permission, setPermission] = useState<PermissionState | "unknown">(
|
|
83
|
+
"unknown",
|
|
84
|
+
);
|
|
85
|
+
const [position, setPosition] =
|
|
86
|
+
useState<GeolocationCoordinatesSnapshot | null>(null);
|
|
87
|
+
const [error, setError] = useState<GeolocationPositionError | null>(null);
|
|
88
|
+
const [settled, setSettled] = useState(false);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (
|
|
92
|
+
typeof navigator === "undefined" ||
|
|
93
|
+
navigator.permissions?.query == null
|
|
94
|
+
) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
let active = true;
|
|
98
|
+
let status: PermissionStatus | undefined;
|
|
99
|
+
const onChange = () => {
|
|
100
|
+
if (active && status) {
|
|
101
|
+
setPermission(status.state);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
navigator.permissions
|
|
105
|
+
.query({ name: "geolocation" })
|
|
106
|
+
.then((result) => {
|
|
107
|
+
// Guard against the component unmounting before this promise resolves,
|
|
108
|
+
// which would otherwise attach a listener that can never be cleaned up.
|
|
109
|
+
if (!active) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
status = result;
|
|
113
|
+
setPermission(result.state);
|
|
114
|
+
result.addEventListener("change", onChange);
|
|
115
|
+
})
|
|
116
|
+
.catch(() => {
|
|
117
|
+
/* permissions API may reject for geolocation in some browsers */
|
|
118
|
+
});
|
|
119
|
+
return () => {
|
|
120
|
+
active = false;
|
|
121
|
+
status?.removeEventListener("change", onChange);
|
|
122
|
+
};
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!enabled || !isSupported) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const geolocation = navigator.geolocation;
|
|
130
|
+
let active = true;
|
|
131
|
+
setSettled(false);
|
|
132
|
+
const onSuccess = (next: GeolocationPosition) => {
|
|
133
|
+
if (!active) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
setPosition(toSnapshot(next));
|
|
137
|
+
setError(null);
|
|
138
|
+
setSettled(true);
|
|
139
|
+
};
|
|
140
|
+
const onError = (nextError: GeolocationPositionError) => {
|
|
141
|
+
if (!active) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
setError(nextError);
|
|
145
|
+
setSettled(true);
|
|
146
|
+
};
|
|
147
|
+
const options: PositionOptions = {
|
|
148
|
+
enableHighAccuracy,
|
|
149
|
+
maximumAge,
|
|
150
|
+
timeout,
|
|
151
|
+
};
|
|
152
|
+
let watchId: number | undefined;
|
|
153
|
+
if (watch) {
|
|
154
|
+
watchId = geolocation.watchPosition(onSuccess, onError, options);
|
|
155
|
+
} else {
|
|
156
|
+
geolocation.getCurrentPosition(onSuccess, onError, options);
|
|
157
|
+
}
|
|
158
|
+
return () => {
|
|
159
|
+
active = false;
|
|
160
|
+
if (watchId !== undefined) {
|
|
161
|
+
geolocation.clearWatch(watchId);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}, [enableHighAccuracy, enabled, isSupported, maximumAge, timeout, watch]);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
isSupported,
|
|
168
|
+
loading: enabled && isSupported && !settled,
|
|
169
|
+
permission,
|
|
170
|
+
position,
|
|
171
|
+
error,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { useHotkeys } from "./useHotkeys";
|
|
5
|
+
|
|
6
|
+
function dispatchKeydown(
|
|
7
|
+
key: string,
|
|
8
|
+
init: KeyboardEventInit = {},
|
|
9
|
+
target: EventTarget = window,
|
|
10
|
+
) {
|
|
11
|
+
const event = new KeyboardEvent("keydown", {
|
|
12
|
+
key,
|
|
13
|
+
bubbles: true,
|
|
14
|
+
cancelable: true,
|
|
15
|
+
...init,
|
|
16
|
+
});
|
|
17
|
+
target.dispatchEvent(event);
|
|
18
|
+
return event;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("useHotkeys", () => {
|
|
22
|
+
it("should fire a plain key binding", () => {
|
|
23
|
+
const handler = vi.fn();
|
|
24
|
+
renderHook(() => useHotkeys({ escape: handler }));
|
|
25
|
+
dispatchKeydown("Escape");
|
|
26
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should require all listed modifiers", () => {
|
|
30
|
+
const handler = vi.fn();
|
|
31
|
+
renderHook(() => useHotkeys({ "ctrl+shift+k": handler }));
|
|
32
|
+
dispatchKeydown("k", { ctrlKey: true });
|
|
33
|
+
expect(handler).not.toHaveBeenCalled();
|
|
34
|
+
dispatchKeydown("k", { ctrlKey: true, shiftKey: true });
|
|
35
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should reject extra modifiers", () => {
|
|
39
|
+
const handler = vi.fn();
|
|
40
|
+
renderHook(() => useHotkeys({ k: handler }));
|
|
41
|
+
dispatchKeydown("k", { ctrlKey: true });
|
|
42
|
+
expect(handler).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should match mod against ctrl or meta", () => {
|
|
46
|
+
const handler = vi.fn();
|
|
47
|
+
renderHook(() => useHotkeys({ "mod+k": handler }));
|
|
48
|
+
dispatchKeydown("k", { ctrlKey: true });
|
|
49
|
+
dispatchKeydown("k", { metaKey: true });
|
|
50
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should prevent default on match by default", () => {
|
|
54
|
+
renderHook(() => useHotkeys({ "mod+k": vi.fn() }));
|
|
55
|
+
const event = dispatchKeydown("k", { metaKey: true });
|
|
56
|
+
expect(event.defaultPrevented).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should skip events from editable targets unless allowed", () => {
|
|
60
|
+
const handler = vi.fn();
|
|
61
|
+
const input = document.createElement("input");
|
|
62
|
+
document.body.appendChild(input);
|
|
63
|
+
const { rerender } = renderHook(
|
|
64
|
+
({ allowInInputs }: { allowInInputs: boolean }) =>
|
|
65
|
+
useHotkeys({ escape: handler }, { allowInInputs }),
|
|
66
|
+
{ initialProps: { allowInInputs: false } },
|
|
67
|
+
);
|
|
68
|
+
dispatchKeydown("Escape", {}, input);
|
|
69
|
+
expect(handler).not.toHaveBeenCalled();
|
|
70
|
+
rerender({ allowInInputs: true });
|
|
71
|
+
dispatchKeydown("Escape", {}, input);
|
|
72
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
73
|
+
input.remove();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should do nothing when disabled", () => {
|
|
77
|
+
const handler = vi.fn();
|
|
78
|
+
renderHook(() => useHotkeys({ escape: handler }, { enabled: false }));
|
|
79
|
+
dispatchKeydown("Escape");
|
|
80
|
+
expect(handler).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
});
|