@musecat/functionkit 1.0.0 → 1.0.2

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 (157) hide show
  1. package/.agents/references/components/ScrolltoTop.md +36 -0
  2. package/.agents/references/components/SwitchCase.md +47 -0
  3. package/.agents/references/components/ViewportPortal.md +34 -0
  4. package/.agents/references/cookie/cookie.md +47 -0
  5. package/.agents/references/datetime/dateTime.client.md +42 -0
  6. package/.agents/references/datetime/dateTime.server.md +42 -0
  7. package/.agents/references/datetime/dateTime.shared.md +86 -0
  8. package/.agents/references/hooks/useAvoidKeyboard.md +29 -0
  9. package/.agents/references/hooks/useCheckInvisible.md +36 -0
  10. package/.agents/references/hooks/useCheckScroll.md +36 -0
  11. package/.agents/references/hooks/useClientDateTime.md +39 -0
  12. package/.agents/references/hooks/useDebounce.md +46 -0
  13. package/.agents/references/hooks/useDebouncedCallback.md +34 -0
  14. package/.agents/references/hooks/useDoubleClick.md +40 -0
  15. package/.agents/references/hooks/useGeolocation.md +37 -0
  16. package/.agents/references/hooks/useHasMounted.md +27 -0
  17. package/.agents/references/hooks/useIntersectionObserver.md +37 -0
  18. package/.agents/references/hooks/useInterval.md +40 -0
  19. package/.agents/references/hooks/useKeyboardHeight.md +33 -0
  20. package/.agents/references/hooks/useKeyboardListNavigation.md +58 -0
  21. package/.agents/references/hooks/useLongPress.md +48 -0
  22. package/.agents/references/hooks/usePreservedCallback.md +33 -0
  23. package/.agents/references/hooks/usePreservedReference.md +34 -0
  24. package/.agents/references/hooks/useRefEffect.md +35 -0
  25. package/.agents/references/hooks/useRelativeDateTime.md +35 -0
  26. package/.agents/references/hooks/useTimeout.md +42 -0
  27. package/.agents/references/hooks/useToggleState.md +39 -0
  28. package/.agents/references/hooks/useViewportHeight.md +26 -0
  29. package/.agents/references/hooks/useViewportMatch.md +32 -0
  30. package/.agents/references/utils/browserStorage.md +41 -0
  31. package/.agents/references/utils/buildContext.md +47 -0
  32. package/.agents/references/utils/clipboardShare.md +59 -0
  33. package/.agents/references/utils/floatingMotion.md +67 -0
  34. package/.agents/references/utils/getDeviceInfo.md +43 -0
  35. package/.agents/references/utils/isEditableKeyboardTarget.md +35 -0
  36. package/.agents/references/utils/mergeRefs.md +29 -0
  37. package/.agents/references/utils/seen.md +37 -0
  38. package/.agents/references/utils/subscribeKeyboardHeight.md +35 -0
  39. package/AGENTS.md +103 -396
  40. package/LICENSE +9 -0
  41. package/README.md +38 -0
  42. package/index.ts +97 -102
  43. package/package.json +27 -13
  44. package/{components → packages/components}/ScrolltoTop.tsx +3 -3
  45. package/{components → packages/components}/SwitchCase.tsx +7 -7
  46. package/packages/components/ViewportPortal.tsx +40 -0
  47. package/packages/cookie/cookie.shared.ts +138 -0
  48. package/packages/datetime/dateTime.client.ts +108 -0
  49. package/packages/datetime/dateTime.server.ts +108 -0
  50. package/packages/datetime/dateTime.shared.ts +358 -0
  51. package/packages/hooks/useAvoidKeyboard.ts +28 -0
  52. package/packages/hooks/useCheckInvisible.ts +25 -0
  53. package/packages/hooks/useCheckScroll.ts +19 -0
  54. package/packages/hooks/useClientDateTime.ts +49 -0
  55. package/packages/hooks/useDebounce.ts +118 -0
  56. package/packages/hooks/useDebouncedCallback.ts +69 -0
  57. package/packages/hooks/useDoubleClick.ts +53 -0
  58. package/packages/hooks/useGeolocation.ts +146 -0
  59. package/packages/hooks/useHasMounted.ts +13 -0
  60. package/packages/hooks/useIntersectionObserver.ts +34 -0
  61. package/packages/hooks/useInterval.ts +56 -0
  62. package/packages/hooks/useKeyboardHeight.ts +18 -0
  63. package/packages/hooks/useKeyboardListNavigation.ts +170 -0
  64. package/packages/hooks/useLongPress.ts +120 -0
  65. package/packages/hooks/usePreservedCallback.ts +21 -0
  66. package/packages/hooks/usePreservedReference.ts +21 -0
  67. package/packages/hooks/useRefEffect.ts +35 -0
  68. package/packages/hooks/useRelativeDateTime.ts +54 -0
  69. package/packages/hooks/useTimeout.ts +56 -0
  70. package/packages/hooks/useToggleState.ts +13 -0
  71. package/packages/hooks/useViewportHeight.ts +23 -0
  72. package/packages/hooks/useViewportMatch.ts +27 -0
  73. package/packages/utils/browserStorage.ts +59 -0
  74. package/packages/utils/buildContext.tsx +19 -0
  75. package/packages/utils/checkDevice.ts +74 -0
  76. package/packages/utils/clipboardShare.tsx +47 -0
  77. package/{utils → packages/utils}/clipboardShare.types.ts +7 -7
  78. package/packages/utils/floatingMotion.ts +99 -0
  79. package/packages/utils/keyboardTarget.ts +13 -0
  80. package/packages/utils/mergeRefs.ts +15 -0
  81. package/packages/utils/seen.ts +29 -0
  82. package/packages/utils/subscribeKeyboardHeight.ts +54 -0
  83. package/tests/components/ScrolltoTop.test.tsx +15 -0
  84. package/tests/components/SwitchCase.test.tsx +34 -0
  85. package/tests/components/ViewportPortal.test.tsx +42 -0
  86. package/tests/cookie/cookie.test.ts +156 -0
  87. package/tests/datetime/datetime.test.ts +250 -0
  88. package/tests/hooks/useAvoidKeyboard.test.ts +12 -0
  89. package/tests/hooks/useCheckInvisible.test.ts +28 -0
  90. package/tests/hooks/useCheckScroll.test.ts +23 -0
  91. package/tests/hooks/useClientDateTime.test.ts +14 -0
  92. package/tests/hooks/useDebounce.test.ts +71 -0
  93. package/tests/hooks/useDebouncedCallback.test.ts +34 -0
  94. package/tests/hooks/useDoubleClick.test.ts +31 -0
  95. package/tests/hooks/useGeolocation.test.ts +50 -0
  96. package/tests/hooks/useHasMounted.test.ts +10 -0
  97. package/tests/hooks/useIntersectionObserver.test.ts +13 -0
  98. package/tests/hooks/useInterval.test.ts +31 -0
  99. package/tests/hooks/useKeyboardHeight.test.ts +10 -0
  100. package/tests/hooks/useKeyboardListNavigation.test.ts +82 -0
  101. package/tests/hooks/useLongPress.test.ts +52 -0
  102. package/tests/hooks/usePreservedCallback.test.ts +35 -0
  103. package/tests/hooks/usePreservedReference.test.ts +35 -0
  104. package/tests/hooks/useRefEffect.test.ts +30 -0
  105. package/tests/hooks/useRelativeDateTime.test.ts +19 -0
  106. package/tests/hooks/useTimeout.test.ts +34 -0
  107. package/tests/hooks/useToggleState.test.ts +35 -0
  108. package/tests/hooks/useViewportHeight.test.ts +10 -0
  109. package/tests/hooks/useViewportMatch.test.ts +35 -0
  110. package/tests/setup.ts +9 -0
  111. package/tests/utils/browserStorage.test.ts +54 -0
  112. package/tests/utils/buildContext.test.tsx +27 -0
  113. package/tests/utils/checkDevice.test.ts +84 -0
  114. package/tests/utils/clipboardShare.test.ts +78 -0
  115. package/tests/utils/floatingMotion.test.ts +84 -0
  116. package/tests/utils/keyboardTarget.test.ts +44 -0
  117. package/tests/utils/mergeRefs.test.ts +18 -0
  118. package/tests/utils/seen.test.ts +31 -0
  119. package/tests/utils/subscribeKeyboardHeight.test.ts +50 -0
  120. package/tsconfig.json +16 -16
  121. package/vitest.config.ts +16 -0
  122. package/components/ViewportPortal.tsx +0 -40
  123. package/cookie/cookie.shared.ts +0 -142
  124. package/datetime/dateTime.client.ts +0 -108
  125. package/datetime/dateTime.server.ts +0 -108
  126. package/datetime/dateTime.shared.ts +0 -358
  127. package/hooks/useAvoidKeyboard.ts +0 -28
  128. package/hooks/useCheckInvisible.ts +0 -25
  129. package/hooks/useCheckScroll.ts +0 -19
  130. package/hooks/useClientDateTime.ts +0 -49
  131. package/hooks/useDebounce.ts +0 -118
  132. package/hooks/useDebouncedCallback.ts +0 -69
  133. package/hooks/useDoubleClick.ts +0 -53
  134. package/hooks/useGeolocation.ts +0 -146
  135. package/hooks/useHasMounted.ts +0 -13
  136. package/hooks/useIntersectionObserver.ts +0 -34
  137. package/hooks/useInterval.ts +0 -55
  138. package/hooks/useKeyboardHeight.ts +0 -18
  139. package/hooks/useKeyboardListNavigation.ts +0 -170
  140. package/hooks/useLongPress.ts +0 -120
  141. package/hooks/usePreservedCallback.ts +0 -21
  142. package/hooks/usePreservedReference.ts +0 -21
  143. package/hooks/useRefEffect.ts +0 -35
  144. package/hooks/useRelativeDateTime.ts +0 -54
  145. package/hooks/useTimeout.ts +0 -56
  146. package/hooks/useToggleState.ts +0 -13
  147. package/hooks/useViewportHeight.ts +0 -23
  148. package/hooks/useViewportMatch.ts +0 -33
  149. package/utils/browserStorage.ts +0 -59
  150. package/utils/buildContext.tsx +0 -19
  151. package/utils/checkDevice.ts +0 -74
  152. package/utils/clipboardShare.tsx +0 -47
  153. package/utils/floatingMotion.ts +0 -99
  154. package/utils/keyboardTarget.ts +0 -13
  155. package/utils/mergeRefs.ts +0 -15
  156. package/utils/seen.ts +0 -29
  157. package/utils/subscribeKeyboardHeight.ts +0 -54
@@ -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,13 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function useHasMounted() {
6
+ const [hasMounted, setHasMounted] = useState(false);
7
+
8
+ useEffect(() => {
9
+ setHasMounted(true);
10
+ }, []);
11
+
12
+ return hasMounted;
13
+ }
@@ -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,56 @@
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 =
19
+ typeof options === "number" ? true : (options.enabled ?? true);
20
+
21
+ const preservedCallback = usePreservedCallback(callback);
22
+ const immediateCalledRef = useRef(false);
23
+
24
+ useEffect(
25
+ function runImmediateCallback() {
26
+ if (immediate !== true) {
27
+ immediateCalledRef.current = false;
28
+ return;
29
+ }
30
+
31
+ if (!enabled) {
32
+ return;
33
+ }
34
+
35
+ if (immediateCalledRef.current) {
36
+ return;
37
+ }
38
+
39
+ immediateCalledRef.current = true;
40
+ preservedCallback();
41
+ },
42
+ [immediate, preservedCallback, enabled],
43
+ );
44
+
45
+ useEffect(
46
+ function startInterval() {
47
+ if (!enabled) {
48
+ return;
49
+ }
50
+
51
+ const id = setInterval(preservedCallback, delay);
52
+ return () => clearInterval(id);
53
+ },
54
+ [delay, preservedCallback, enabled],
55
+ );
56
+ }
@@ -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
+ }
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ import { type MouseEvent, type TouchEvent, useCallback, useRef } from "react";
4
+
5
+ export type UseLongPressOptions<E extends HTMLElement> = {
6
+ delay?: number;
7
+ moveThreshold?: {
8
+ x?: number;
9
+ y?: number;
10
+ };
11
+ onClick?: (event: MouseEvent<E> | TouchEvent<E>) => void;
12
+ onLongPressEnd?: (event: MouseEvent<E> | TouchEvent<E>) => void;
13
+ };
14
+
15
+ export function useLongPress<E extends HTMLElement = HTMLElement>(
16
+ onLongPress: (event: MouseEvent<E> | TouchEvent<E>) => void,
17
+ {
18
+ delay = 500,
19
+ moveThreshold,
20
+ onClick,
21
+ onLongPressEnd,
22
+ }: UseLongPressOptions<E> = {},
23
+ ) {
24
+ const timeoutRef = useRef<number | null>(null);
25
+ const isLongPressActiveRef = useRef(false);
26
+ const initialPositionRef = useRef({ x: 0, y: 0 });
27
+
28
+ const savedOnLongPress = useRef(onLongPress);
29
+ const savedOnClick = useRef(onClick);
30
+ const savedOnLongPressEnd = useRef(onLongPressEnd);
31
+ savedOnLongPress.current = onLongPress;
32
+ savedOnClick.current = onClick;
33
+ savedOnLongPressEnd.current = onLongPressEnd;
34
+
35
+ const hasThreshold =
36
+ moveThreshold?.x !== undefined || moveThreshold?.y !== undefined;
37
+
38
+ const getClientPosition = useCallback(
39
+ (event: MouseEvent<E> | TouchEvent<E>) => {
40
+ if ("touches" in event.nativeEvent) {
41
+ const touch = event.nativeEvent.touches[0];
42
+ return { x: touch.clientX, y: touch.clientY };
43
+ }
44
+ return {
45
+ x: event.nativeEvent.clientX,
46
+ y: event.nativeEvent.clientY,
47
+ };
48
+ },
49
+ [],
50
+ );
51
+
52
+ const isMovedBeyondThreshold = useCallback(
53
+ (event: MouseEvent<E> | TouchEvent<E>) => {
54
+ const { x, y } = getClientPosition(event);
55
+ const deltaX = Math.abs(x - initialPositionRef.current.x);
56
+ const deltaY = Math.abs(y - initialPositionRef.current.y);
57
+
58
+ return (
59
+ (moveThreshold?.x !== undefined && deltaX > moveThreshold.x) ||
60
+ (moveThreshold?.y !== undefined && deltaY > moveThreshold.y)
61
+ );
62
+ },
63
+ [getClientPosition, moveThreshold],
64
+ );
65
+
66
+ const cancelLongPress = useCallback(() => {
67
+ if (timeoutRef.current !== null) {
68
+ window.clearTimeout(timeoutRef.current);
69
+ timeoutRef.current = null;
70
+ }
71
+ }, []);
72
+
73
+ const handlePressStart = useCallback(
74
+ (event: MouseEvent<E> | TouchEvent<E>) => {
75
+ cancelLongPress();
76
+ initialPositionRef.current = getClientPosition(event);
77
+ isLongPressActiveRef.current = false;
78
+
79
+ timeoutRef.current = window.setTimeout(() => {
80
+ isLongPressActiveRef.current = true;
81
+ savedOnLongPress.current(event);
82
+ }, delay);
83
+ },
84
+ [cancelLongPress, delay, getClientPosition],
85
+ );
86
+
87
+ const handlePressEnd = useCallback(
88
+ (event: MouseEvent<E> | TouchEvent<E>) => {
89
+ if (isLongPressActiveRef.current) {
90
+ savedOnLongPressEnd.current?.(event);
91
+ } else if (timeoutRef.current !== null) {
92
+ savedOnClick.current?.(event);
93
+ }
94
+
95
+ cancelLongPress();
96
+ isLongPressActiveRef.current = false;
97
+ },
98
+ [cancelLongPress],
99
+ );
100
+
101
+ const handlePressMove = useCallback(
102
+ (event: MouseEvent<E> | TouchEvent<E>) => {
103
+ if (timeoutRef.current !== null && isMovedBeyondThreshold(event)) {
104
+ cancelLongPress();
105
+ }
106
+ },
107
+ [cancelLongPress, isMovedBeyondThreshold],
108
+ );
109
+
110
+ return {
111
+ onMouseDown: handlePressStart,
112
+ onMouseUp: handlePressEnd,
113
+ onMouseLeave: cancelLongPress,
114
+ onTouchStart: handlePressStart,
115
+ onTouchEnd: handlePressEnd,
116
+ ...(hasThreshold
117
+ ? { onTouchMove: handlePressMove, onMouseMove: handlePressMove }
118
+ : {}),
119
+ };
120
+ }
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+
5
+ export function usePreservedCallback<
6
+ Arguments extends unknown[] = unknown[],
7
+ ReturnValue = unknown,
8
+ >(callback: (...args: Arguments) => ReturnValue) {
9
+ const callbackRef = useRef(callback);
10
+
11
+ useEffect(
12
+ function syncCallbackRef() {
13
+ callbackRef.current = callback;
14
+ },
15
+ [callback],
16
+ );
17
+
18
+ return useCallback((...args: Arguments) => {
19
+ return callbackRef.current(...args);
20
+ }, []);
21
+ }
@@ -0,0 +1,21 @@
1
+ "use client";
2
+
3
+ import { useMemo, useRef } from "react";
4
+
5
+ function areDeeplyEqual<T>(a: T, b: T): boolean {
6
+ return JSON.stringify(a) === JSON.stringify(b);
7
+ }
8
+
9
+ export function usePreservedReference<T>(
10
+ value: T,
11
+ areValuesEqual: (a: T, b: T) => boolean = areDeeplyEqual,
12
+ ): T {
13
+ const ref = useRef(value);
14
+
15
+ return useMemo(() => {
16
+ if (!areValuesEqual(ref.current, value)) {
17
+ ref.current = value;
18
+ }
19
+ return ref.current;
20
+ }, [areValuesEqual, value]);
21
+ }
@@ -0,0 +1,35 @@
1
+ "use client";
2
+
3
+ import { type DependencyList, useCallback, useRef } from "react";
4
+
5
+ import { usePreservedCallback } from "./usePreservedCallback";
6
+
7
+ export type CleanupCallback = () => void;
8
+
9
+ export function useRefEffect<RefElement extends HTMLElement = HTMLElement>(
10
+ callback: (element: RefElement) => CleanupCallback | undefined,
11
+ deps: DependencyList,
12
+ ): (element: RefElement | null) => void {
13
+ const preservedCallback = usePreservedCallback(callback);
14
+ const cleanupCallbackRef = useRef<CleanupCallback>(() => {});
15
+
16
+ const effect = useCallback(
17
+ (element: RefElement | null) => {
18
+ cleanupCallbackRef.current();
19
+ cleanupCallbackRef.current = () => {};
20
+
21
+ if (element == null) {
22
+ return;
23
+ }
24
+
25
+ const cleanup = preservedCallback(element);
26
+
27
+ if (typeof cleanup === "function") {
28
+ cleanupCallbackRef.current = cleanup;
29
+ }
30
+ },
31
+ [preservedCallback, ...deps],
32
+ );
33
+
34
+ return effect;
35
+ }