@musecat/functionkit 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/.gitattributes +2 -0
- package/AGENTS.md +398 -0
- package/components/ScrolltoTop.tsx +9 -0
- package/components/SwitchCase.tsx +15 -0
- package/components/ViewportPortal.tsx +40 -0
- package/cookie/cookie.shared.ts +142 -0
- package/datetime/dateTime.client.ts +108 -0
- package/datetime/dateTime.server.ts +108 -0
- package/datetime/dateTime.shared.ts +358 -0
- package/hooks/useAvoidKeyboard.ts +28 -0
- package/hooks/useCheckInvisible.ts +25 -0
- package/hooks/useCheckScroll.ts +19 -0
- package/hooks/useClientDateTime.ts +49 -0
- package/hooks/useDebounce.ts +118 -0
- package/hooks/useDebouncedCallback.ts +69 -0
- package/hooks/useDoubleClick.ts +53 -0
- package/hooks/useGeolocation.ts +146 -0
- package/hooks/useHasMounted.ts +13 -0
- package/hooks/useIntersectionObserver.ts +34 -0
- package/hooks/useInterval.ts +55 -0
- package/hooks/useKeyboardHeight.ts +18 -0
- package/hooks/useKeyboardListNavigation.ts +170 -0
- package/hooks/useLongPress.ts +120 -0
- package/hooks/usePreservedCallback.ts +21 -0
- package/hooks/usePreservedReference.ts +21 -0
- package/hooks/useRefEffect.ts +35 -0
- package/hooks/useRelativeDateTime.ts +54 -0
- package/hooks/useTimeout.ts +56 -0
- package/hooks/useToggleState.ts +13 -0
- package/hooks/useViewportHeight.ts +23 -0
- package/hooks/useViewportMatch.ts +33 -0
- package/index.ts +111 -0
- package/package.json +15 -0
- package/tsconfig.json +18 -0
- package/utils/browserStorage.ts +59 -0
- package/utils/buildContext.tsx +19 -0
- package/utils/checkDevice.ts +74 -0
- package/utils/clipboardShare.tsx +47 -0
- package/utils/clipboardShare.types.ts +18 -0
- package/utils/floatingMotion.ts +99 -0
- package/utils/keyboardTarget.ts +13 -0
- package/utils/mergeRefs.ts +15 -0
- package/utils/seen.ts +29 -0
- package/utils/subscribeKeyboardHeight.ts +54 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
import { usePreservedCallback } from "./usePreservedCallback";
|
|
6
|
+
|
|
7
|
+
export function debounce<F extends (...args: unknown[]) => void>(
|
|
8
|
+
func: F,
|
|
9
|
+
debounceMs: number,
|
|
10
|
+
{
|
|
11
|
+
edges = ["leading", "trailing"],
|
|
12
|
+
}: { edges?: Array<"leading" | "trailing"> } = {},
|
|
13
|
+
): {
|
|
14
|
+
(...args: Parameters<F>): void;
|
|
15
|
+
cancel: () => void;
|
|
16
|
+
} {
|
|
17
|
+
let pendingThis: ThisParameterType<F> | undefined;
|
|
18
|
+
let pendingArgs: Parameters<F> | null = null;
|
|
19
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
20
|
+
|
|
21
|
+
const leading = edges.includes("leading");
|
|
22
|
+
const trailing = edges.includes("trailing");
|
|
23
|
+
|
|
24
|
+
const invoke = () => {
|
|
25
|
+
if (pendingArgs !== null) {
|
|
26
|
+
func.apply(pendingThis, pendingArgs);
|
|
27
|
+
pendingThis = undefined;
|
|
28
|
+
pendingArgs = null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const cancelTimer = () => {
|
|
33
|
+
if (timeoutId !== null) {
|
|
34
|
+
clearTimeout(timeoutId);
|
|
35
|
+
timeoutId = null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const cancel = () => {
|
|
40
|
+
cancelTimer();
|
|
41
|
+
pendingThis = undefined;
|
|
42
|
+
pendingArgs = null;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const schedule = () => {
|
|
46
|
+
cancelTimer();
|
|
47
|
+
timeoutId = setTimeout(() => {
|
|
48
|
+
timeoutId = null;
|
|
49
|
+
if (trailing) invoke();
|
|
50
|
+
cancel();
|
|
51
|
+
}, debounceMs);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const debounced = function (
|
|
55
|
+
this: ThisParameterType<F>,
|
|
56
|
+
...args: Parameters<F>
|
|
57
|
+
) {
|
|
58
|
+
pendingThis = this;
|
|
59
|
+
pendingArgs = args;
|
|
60
|
+
|
|
61
|
+
const isFirstCall = timeoutId == null;
|
|
62
|
+
|
|
63
|
+
schedule();
|
|
64
|
+
|
|
65
|
+
if (leading && isFirstCall) {
|
|
66
|
+
invoke();
|
|
67
|
+
}
|
|
68
|
+
} as {
|
|
69
|
+
(...args: Parameters<F>): void;
|
|
70
|
+
cancel: () => void;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
debounced.cancel = cancel;
|
|
74
|
+
return debounced;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type DebounceOptions = {
|
|
78
|
+
leading?: boolean;
|
|
79
|
+
trailing?: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export function useDebounce<F extends (...args: unknown[]) => void>(
|
|
83
|
+
callback: F,
|
|
84
|
+
wait: number,
|
|
85
|
+
options: DebounceOptions = {},
|
|
86
|
+
) {
|
|
87
|
+
const preservedCallback = usePreservedCallback(callback) as F;
|
|
88
|
+
|
|
89
|
+
const { leading = false, trailing = true } = options;
|
|
90
|
+
|
|
91
|
+
const edges = useMemo(() => {
|
|
92
|
+
const _edges: Array<"leading" | "trailing"> = [];
|
|
93
|
+
if (leading) {
|
|
94
|
+
_edges.push("leading");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (trailing) {
|
|
98
|
+
_edges.push("trailing");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return _edges;
|
|
102
|
+
}, [leading, trailing]);
|
|
103
|
+
|
|
104
|
+
const debounced = useMemo(() => {
|
|
105
|
+
return debounce(preservedCallback, wait, { edges });
|
|
106
|
+
}, [preservedCallback, wait, edges]);
|
|
107
|
+
|
|
108
|
+
useEffect(
|
|
109
|
+
function cancelDebouncedOnUnmount() {
|
|
110
|
+
return () => {
|
|
111
|
+
debounced.cancel();
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
[debounced],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return debounced;
|
|
118
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
import { debounce } from "./useDebounce";
|
|
6
|
+
import { usePreservedCallback } from "./usePreservedCallback";
|
|
7
|
+
|
|
8
|
+
type DebounceOptions = {
|
|
9
|
+
leading?: boolean;
|
|
10
|
+
trailing?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function useDebouncedCallback({
|
|
14
|
+
onChange,
|
|
15
|
+
timeThreshold,
|
|
16
|
+
leading = false,
|
|
17
|
+
trailing = true,
|
|
18
|
+
}: DebounceOptions & {
|
|
19
|
+
onChange: (newValue: boolean) => void;
|
|
20
|
+
timeThreshold: number;
|
|
21
|
+
}) {
|
|
22
|
+
const handleChange = usePreservedCallback(onChange);
|
|
23
|
+
const ref = useRef({ value: false, clearPreviousDebounce: () => {} });
|
|
24
|
+
|
|
25
|
+
useEffect(function clearDebouncedOnUnmount() {
|
|
26
|
+
const current = ref.current;
|
|
27
|
+
return () => {
|
|
28
|
+
current.clearPreviousDebounce();
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const edges = useMemo(() => {
|
|
33
|
+
const _edges: Array<"leading" | "trailing"> = [];
|
|
34
|
+
if (leading) {
|
|
35
|
+
_edges.push("leading");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (trailing) {
|
|
39
|
+
_edges.push("trailing");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return _edges;
|
|
43
|
+
}, [leading, trailing]);
|
|
44
|
+
|
|
45
|
+
return useCallback(
|
|
46
|
+
(nextValue: boolean) => {
|
|
47
|
+
if (nextValue === ref.current.value) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const debounced = debounce(
|
|
52
|
+
() => {
|
|
53
|
+
handleChange(nextValue);
|
|
54
|
+
|
|
55
|
+
ref.current.value = nextValue;
|
|
56
|
+
},
|
|
57
|
+
timeThreshold,
|
|
58
|
+
{ edges },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
ref.current.clearPreviousDebounce();
|
|
62
|
+
|
|
63
|
+
debounced();
|
|
64
|
+
|
|
65
|
+
ref.current.clearPreviousDebounce = debounced.cancel;
|
|
66
|
+
},
|
|
67
|
+
[handleChange, timeThreshold, edges],
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type MouseEvent, useCallback, useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
type UseDoubleClickProps<E extends HTMLElement> = {
|
|
6
|
+
delay?: number;
|
|
7
|
+
click?: (event: MouseEvent<E>) => void;
|
|
8
|
+
doubleClick: (event: MouseEvent<E>) => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function useDoubleClick<E extends HTMLElement = HTMLElement>({
|
|
12
|
+
delay = 250,
|
|
13
|
+
click,
|
|
14
|
+
doubleClick,
|
|
15
|
+
}: UseDoubleClickProps<E>) {
|
|
16
|
+
const clickTimeout = useRef<number | null>(null);
|
|
17
|
+
|
|
18
|
+
const savedClick = useRef(click);
|
|
19
|
+
const savedDoubleClick = useRef(doubleClick);
|
|
20
|
+
savedClick.current = click;
|
|
21
|
+
savedDoubleClick.current = doubleClick;
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
return () => {
|
|
25
|
+
if (clickTimeout.current != null) {
|
|
26
|
+
window.clearTimeout(clickTimeout.current);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const handleEvent = useCallback(
|
|
32
|
+
(event: MouseEvent<E>) => {
|
|
33
|
+
if (clickTimeout.current != null) {
|
|
34
|
+
window.clearTimeout(clickTimeout.current);
|
|
35
|
+
clickTimeout.current = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (event.detail === 1 && savedClick.current) {
|
|
39
|
+
clickTimeout.current = window.setTimeout(() => {
|
|
40
|
+
clickTimeout.current = null;
|
|
41
|
+
savedClick.current?.(event);
|
|
42
|
+
}, delay);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (event.detail === 2) {
|
|
46
|
+
savedDoubleClick.current(event);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[delay],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return handleEvent;
|
|
53
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
type GeolocationData = {
|
|
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
|
+
const MOUNT_BEHAVIOR = { GET: "get", WATCH: "watch" } as const;
|
|
17
|
+
|
|
18
|
+
type MountBehavior = (typeof MOUNT_BEHAVIOR)[keyof typeof MOUNT_BEHAVIOR];
|
|
19
|
+
|
|
20
|
+
type UseGeolocationOptions = {
|
|
21
|
+
mountBehavior?: MountBehavior;
|
|
22
|
+
enableHighAccuracy?: boolean;
|
|
23
|
+
timeout?: number;
|
|
24
|
+
maximumAge?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function useGeolocation(options?: UseGeolocationOptions) {
|
|
28
|
+
const [state, setState] = useState<{
|
|
29
|
+
loading: boolean;
|
|
30
|
+
error: string | null;
|
|
31
|
+
data: GeolocationData | null;
|
|
32
|
+
}>({
|
|
33
|
+
loading: !!options?.mountBehavior,
|
|
34
|
+
error: null,
|
|
35
|
+
data: null,
|
|
36
|
+
});
|
|
37
|
+
const [isTracking, setIsTracking] = useState(false);
|
|
38
|
+
const watchIdRef = useRef<number | null>(null);
|
|
39
|
+
|
|
40
|
+
const isSupported = useCallback(() => {
|
|
41
|
+
if (typeof window === "undefined" || !navigator.geolocation) {
|
|
42
|
+
setState((prev) => ({
|
|
43
|
+
...prev,
|
|
44
|
+
loading: false,
|
|
45
|
+
error: "Geolocation is not supported by this environment.",
|
|
46
|
+
}));
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleSuccess = useCallback((position: GeolocationPosition) => {
|
|
53
|
+
const { coords } = position;
|
|
54
|
+
setState((prev) => ({
|
|
55
|
+
...prev,
|
|
56
|
+
loading: false,
|
|
57
|
+
error: null,
|
|
58
|
+
data: {
|
|
59
|
+
latitude: coords.latitude,
|
|
60
|
+
longitude: coords.longitude,
|
|
61
|
+
accuracy: coords.accuracy,
|
|
62
|
+
altitude: coords.altitude,
|
|
63
|
+
altitudeAccuracy: coords.altitudeAccuracy,
|
|
64
|
+
heading: coords.heading,
|
|
65
|
+
speed: coords.speed,
|
|
66
|
+
timestamp: position.timestamp,
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const handleError = useCallback((error: GeolocationPositionError) => {
|
|
72
|
+
setState((prev) => ({
|
|
73
|
+
...prev,
|
|
74
|
+
loading: false,
|
|
75
|
+
error: error.message,
|
|
76
|
+
}));
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const geoOptions = useCallback(
|
|
80
|
+
() => ({
|
|
81
|
+
enableHighAccuracy: options?.enableHighAccuracy ?? false,
|
|
82
|
+
maximumAge: options?.maximumAge ?? 0,
|
|
83
|
+
timeout: options?.timeout ?? Infinity,
|
|
84
|
+
}),
|
|
85
|
+
[options?.enableHighAccuracy, options?.maximumAge, options?.timeout],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const getCurrentPosition = useCallback(() => {
|
|
89
|
+
if (!isSupported()) return;
|
|
90
|
+
setState((prev) => ({ ...prev, loading: true }));
|
|
91
|
+
navigator.geolocation.getCurrentPosition(
|
|
92
|
+
handleSuccess,
|
|
93
|
+
handleError,
|
|
94
|
+
geoOptions(),
|
|
95
|
+
);
|
|
96
|
+
}, [handleSuccess, handleError, geoOptions, isSupported]);
|
|
97
|
+
|
|
98
|
+
const startTracking = useCallback(() => {
|
|
99
|
+
if (!isSupported()) return;
|
|
100
|
+
|
|
101
|
+
if (watchIdRef.current !== null) {
|
|
102
|
+
navigator.geolocation.clearWatch(watchIdRef.current);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setState((prev) => ({ ...prev, loading: true }));
|
|
106
|
+
|
|
107
|
+
watchIdRef.current = navigator.geolocation.watchPosition(
|
|
108
|
+
(position) => {
|
|
109
|
+
setIsTracking(true);
|
|
110
|
+
handleSuccess(position);
|
|
111
|
+
},
|
|
112
|
+
handleError,
|
|
113
|
+
geoOptions(),
|
|
114
|
+
);
|
|
115
|
+
}, [handleSuccess, handleError, geoOptions, isSupported]);
|
|
116
|
+
|
|
117
|
+
const stopTracking = useCallback(() => {
|
|
118
|
+
if (watchIdRef.current === null) return;
|
|
119
|
+
navigator.geolocation.clearWatch(watchIdRef.current);
|
|
120
|
+
watchIdRef.current = null;
|
|
121
|
+
setIsTracking(false);
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (options?.mountBehavior === MOUNT_BEHAVIOR.WATCH) {
|
|
126
|
+
startTracking();
|
|
127
|
+
} else if (options?.mountBehavior === MOUNT_BEHAVIOR.GET) {
|
|
128
|
+
getCurrentPosition();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
if (watchIdRef.current !== null) {
|
|
133
|
+
navigator.geolocation.clearWatch(watchIdRef.current);
|
|
134
|
+
watchIdRef.current = null;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}, [options?.mountBehavior, getCurrentPosition, startTracking]);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
...state,
|
|
141
|
+
getCurrentPosition,
|
|
142
|
+
startTracking,
|
|
143
|
+
stopTracking,
|
|
144
|
+
isTracking,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
|
|
5
|
+
import { usePreservedCallback } from "./usePreservedCallback";
|
|
6
|
+
import { useRefEffect } from "./useRefEffect";
|
|
7
|
+
|
|
8
|
+
export function useIntersectionObserver<Element extends HTMLElement>(
|
|
9
|
+
callback: (entry: IntersectionObserverEntry) => void,
|
|
10
|
+
options: IntersectionObserverInit,
|
|
11
|
+
): (element: Element | null) => void {
|
|
12
|
+
const preservedCallback = usePreservedCallback(callback);
|
|
13
|
+
|
|
14
|
+
const observer = useMemo(() => {
|
|
15
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return new IntersectionObserver(([entry]) => {
|
|
20
|
+
preservedCallback(entry);
|
|
21
|
+
}, options);
|
|
22
|
+
}, [preservedCallback, options]);
|
|
23
|
+
|
|
24
|
+
return useRefEffect<Element>(
|
|
25
|
+
(element) => {
|
|
26
|
+
observer?.observe(element);
|
|
27
|
+
|
|
28
|
+
return () => {
|
|
29
|
+
observer?.unobserve(element);
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
[preservedCallback, options],
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
|
|
5
|
+
import { usePreservedCallback } from "./usePreservedCallback";
|
|
6
|
+
|
|
7
|
+
type IntervalOptions =
|
|
8
|
+
| number
|
|
9
|
+
| {
|
|
10
|
+
delay: number;
|
|
11
|
+
immediate?: boolean;
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function useInterval(callback: () => void, options: IntervalOptions) {
|
|
16
|
+
const delay = typeof options === "number" ? options : options.delay;
|
|
17
|
+
const immediate = typeof options === "number" ? false : options.immediate;
|
|
18
|
+
const enabled = typeof options === "number" ? true : (options.enabled ?? true);
|
|
19
|
+
|
|
20
|
+
const preservedCallback = usePreservedCallback(callback);
|
|
21
|
+
const immediateCalledRef = useRef(false);
|
|
22
|
+
|
|
23
|
+
useEffect(
|
|
24
|
+
function runImmediateCallback() {
|
|
25
|
+
if (immediate !== true) {
|
|
26
|
+
immediateCalledRef.current = false;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!enabled) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (immediateCalledRef.current) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
immediateCalledRef.current = true;
|
|
39
|
+
preservedCallback();
|
|
40
|
+
},
|
|
41
|
+
[immediate, preservedCallback, enabled],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
useEffect(
|
|
45
|
+
function startInterval() {
|
|
46
|
+
if (!enabled) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const id = setInterval(preservedCallback, delay);
|
|
51
|
+
return () => clearInterval(id);
|
|
52
|
+
},
|
|
53
|
+
[delay, preservedCallback, enabled],
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { subscribeKeyboardHeight } from "../utils/subscribeKeyboardHeight";
|
|
5
|
+
|
|
6
|
+
export function useKeyboardHeight() {
|
|
7
|
+
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const { unsubscribe } = subscribeKeyboardHeight({
|
|
11
|
+
callback: setKeyboardHeight,
|
|
12
|
+
immediate: true,
|
|
13
|
+
});
|
|
14
|
+
return unsubscribe;
|
|
15
|
+
}, []);
|
|
16
|
+
|
|
17
|
+
return { keyboardHeight };
|
|
18
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { isEditableKeyboardTarget } from "../utils/keyboardTarget";
|
|
5
|
+
|
|
6
|
+
const FIRST_ITEM_KEYS = new Set(["ArrowDown", "ArrowRight", "Home"]);
|
|
7
|
+
const LAST_ITEM_KEYS = new Set(["ArrowUp", "ArrowLeft", "End"]);
|
|
8
|
+
|
|
9
|
+
type UseKeyboardListNavigationOptions = {
|
|
10
|
+
itemCount: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function useKeyboardListNavigation({
|
|
14
|
+
itemCount,
|
|
15
|
+
}: UseKeyboardListNavigationOptions) {
|
|
16
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
17
|
+
const itemRefs = useRef<(HTMLElement | null)[]>([]);
|
|
18
|
+
|
|
19
|
+
const setItemRef = useCallback(
|
|
20
|
+
(index: number, element: HTMLElement | null) => {
|
|
21
|
+
itemRefs.current[index] = element;
|
|
22
|
+
},
|
|
23
|
+
[],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const focusItem = useCallback((index: number) => {
|
|
27
|
+
const target = itemRefs.current[index];
|
|
28
|
+
if (!target) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setActiveIndex(index);
|
|
33
|
+
target.focus();
|
|
34
|
+
target.scrollIntoView({
|
|
35
|
+
block: "nearest",
|
|
36
|
+
inline: "nearest",
|
|
37
|
+
});
|
|
38
|
+
return true;
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const focusBoundaryItem = useCallback(
|
|
42
|
+
(key: string) => {
|
|
43
|
+
if (itemCount === 0) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (FIRST_ITEM_KEYS.has(key)) {
|
|
48
|
+
return focusItem(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (LAST_ITEM_KEYS.has(key)) {
|
|
52
|
+
return focusItem(itemCount - 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return false;
|
|
56
|
+
},
|
|
57
|
+
[focusItem, itemCount],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const moveActiveItem = useCallback(
|
|
61
|
+
(key: string) => {
|
|
62
|
+
if (itemCount === 0) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (key === "Home") {
|
|
67
|
+
return focusItem(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (key === "End") {
|
|
71
|
+
return focusItem(itemCount - 1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (key === "ArrowDown" || key === "ArrowRight") {
|
|
75
|
+
const nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % itemCount;
|
|
76
|
+
return focusItem(nextIndex);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (key === "ArrowUp" || key === "ArrowLeft") {
|
|
80
|
+
const nextIndex =
|
|
81
|
+
activeIndex < 0
|
|
82
|
+
? itemCount - 1
|
|
83
|
+
: (activeIndex - 1 + itemCount) % itemCount;
|
|
84
|
+
return focusItem(nextIndex);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return false;
|
|
88
|
+
},
|
|
89
|
+
[activeIndex, focusItem, itemCount],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const clickActiveItem = useCallback(() => {
|
|
93
|
+
if (activeIndex < 0) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
itemRefs.current[activeIndex]?.click();
|
|
98
|
+
return true;
|
|
99
|
+
}, [activeIndex]);
|
|
100
|
+
|
|
101
|
+
const handleListKeyDown = useCallback(
|
|
102
|
+
(
|
|
103
|
+
event: React.KeyboardEvent<HTMLElement>,
|
|
104
|
+
options?: { container?: HTMLElement | null },
|
|
105
|
+
) => {
|
|
106
|
+
if (itemCount === 0 || event.defaultPrevented) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const target = event.target as HTMLElement | null;
|
|
111
|
+
|
|
112
|
+
if (!target) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isEditableKeyboardTarget(target)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (options?.container && !options.container.contains(target)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (event.key === "Escape") {
|
|
125
|
+
setActiveIndex(-1);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
event.key === "Enter" ||
|
|
131
|
+
event.key === " " ||
|
|
132
|
+
event.key === "ArrowDown" ||
|
|
133
|
+
event.key === "ArrowUp" ||
|
|
134
|
+
event.key === "ArrowLeft" ||
|
|
135
|
+
event.key === "ArrowRight" ||
|
|
136
|
+
event.key === "Home" ||
|
|
137
|
+
event.key === "End"
|
|
138
|
+
) {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
143
|
+
clickActiveItem();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (moveActiveItem(event.key)) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (focusBoundaryItem(event.key)) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
[clickActiveItem, moveActiveItem, focusBoundaryItem, itemCount],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
itemRefs.current = itemRefs.current.slice(0, itemCount);
|
|
160
|
+
}, [itemCount]);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
activeIndex,
|
|
164
|
+
setActiveIndex,
|
|
165
|
+
setItemRef,
|
|
166
|
+
clickActiveItem,
|
|
167
|
+
focusBoundaryItem,
|
|
168
|
+
handleListKeyDown,
|
|
169
|
+
};
|
|
170
|
+
}
|