@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,54 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { formatClientRelative } from "../datetime/dateTime.client";
5
+ import type { DateInput, DatePreset } from "../datetime/dateTime.shared";
6
+ import { toDate } from "../datetime/dateTime.shared";
7
+
8
+ type UseRelativeDateTimeOptions = {
9
+ locale?: string;
10
+ maxRelativeDays?: number;
11
+ fallbackDatePreset?: DatePreset;
12
+ updateIntervalMs?: number;
13
+ };
14
+
15
+ export function useRelativeDateTime(
16
+ value: DateInput,
17
+ options?: UseRelativeDateTimeOptions,
18
+ ) {
19
+ const [ready, setReady] = useState(false);
20
+ const [text, setText] = useState("");
21
+ const [isRelative, setIsRelative] = useState(false);
22
+
23
+ useEffect(() => {
24
+ const update = () => {
25
+ const next = formatClientRelative(value, {
26
+ locale: options?.locale,
27
+ maxRelativeDays: options?.maxRelativeDays,
28
+ fallbackDatePreset: options?.fallbackDatePreset,
29
+ now: Date.now(),
30
+ });
31
+ setText(next.text);
32
+ setIsRelative(next.isRelative);
33
+ setReady(true);
34
+ };
35
+
36
+ update();
37
+
38
+ const timer = window.setInterval(update, options?.updateIntervalMs ?? 1000);
39
+ return () => window.clearInterval(timer);
40
+ }, [
41
+ options?.fallbackDatePreset,
42
+ options?.locale,
43
+ options?.maxRelativeDays,
44
+ options?.updateIntervalMs,
45
+ value,
46
+ ]);
47
+
48
+ return {
49
+ ready,
50
+ text,
51
+ isRelative,
52
+ date: toDate(value),
53
+ };
54
+ }
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef } from "react";
4
+
5
+ import { usePreservedCallback } from "./usePreservedCallback";
6
+
7
+ type UseTimeoutOptions = {
8
+ enabled?: boolean;
9
+ };
10
+
11
+ export type UseTimeoutControls = {
12
+ start: (overrideDelay?: number) => void;
13
+ reset: (overrideDelay?: number) => void;
14
+ clear: () => void;
15
+ isPending: () => boolean;
16
+ };
17
+
18
+ export function useTimeout(
19
+ callback: () => void,
20
+ delay = 0,
21
+ options?: UseTimeoutOptions,
22
+ ): UseTimeoutControls {
23
+ const { enabled = true } = options ?? {};
24
+ const preservedCallback = usePreservedCallback(callback);
25
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
26
+
27
+ const clear = useCallback(() => {
28
+ if (timeoutRef.current !== null) {
29
+ clearTimeout(timeoutRef.current);
30
+ timeoutRef.current = null;
31
+ }
32
+ }, []);
33
+
34
+ const start = useCallback(
35
+ (overrideDelay?: number) => {
36
+ clear();
37
+ timeoutRef.current = setTimeout(() => {
38
+ timeoutRef.current = null;
39
+ preservedCallback();
40
+ }, overrideDelay ?? delay);
41
+ },
42
+ [clear, delay, preservedCallback],
43
+ );
44
+
45
+ const isPending = useCallback(() => timeoutRef.current !== null, []);
46
+
47
+ useEffect(() => {
48
+ if (!enabled) return;
49
+ start();
50
+ return clear;
51
+ }, [enabled, start, clear]);
52
+
53
+ useEffect(() => clear, [clear]);
54
+
55
+ return { start, reset: start, clear, isPending };
56
+ }
@@ -0,0 +1,13 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+
5
+ export function useToggleState(initialValue = false) {
6
+ const [value, setValue] = useState(initialValue);
7
+
8
+ const setTrue = useCallback(() => setValue(true), []);
9
+ const setFalse = useCallback(() => setValue(false), []);
10
+ const toggle = useCallback(() => setValue((v) => !v), []);
11
+
12
+ return { value, setValue, setTrue, setFalse, toggle };
13
+ }
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function useViewportHeight() {
6
+ const [height, setHeight] = useState(0);
7
+
8
+ useEffect(() => {
9
+ const update = () =>
10
+ setHeight(window.visualViewport?.height ?? window.innerHeight);
11
+
12
+ update();
13
+ window.visualViewport?.addEventListener("resize", update);
14
+ window.addEventListener("resize", update);
15
+
16
+ return () => {
17
+ window.visualViewport?.removeEventListener("resize", update);
18
+ window.removeEventListener("resize", update);
19
+ };
20
+ }, []);
21
+
22
+ return { height };
23
+ }
@@ -0,0 +1,27 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ export function useViewportMatch(mediaQuery: string): boolean {
6
+ const [isMatched, setIsMatched] = useState(false);
7
+
8
+ useEffect(() => {
9
+ if (typeof window === "undefined") {
10
+ return;
11
+ }
12
+
13
+ const mediaQueryList = window.matchMedia(mediaQuery);
14
+ const syncMatchState = () => {
15
+ setIsMatched(mediaQueryList.matches);
16
+ };
17
+
18
+ syncMatchState();
19
+ mediaQueryList.addEventListener("change", syncMatchState);
20
+
21
+ return () => {
22
+ mediaQueryList.removeEventListener("change", syncMatchState);
23
+ };
24
+ }, [mediaQuery]);
25
+
26
+ return isMatched;
27
+ }
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ type StorageKind = "localStorage" | "sessionStorage";
4
+
5
+ function getStorage(kind: StorageKind): Storage | null {
6
+ if (typeof window === "undefined") return null;
7
+ return window[kind];
8
+ }
9
+
10
+ function saveStorage(kind: StorageKind, key: string, value: unknown) {
11
+ const storage = getStorage(kind);
12
+ if (!storage) return;
13
+ try {
14
+ storage.setItem(key, JSON.stringify(value));
15
+ } catch {}
16
+ }
17
+
18
+ function loadStorage(kind: StorageKind, key: string): unknown {
19
+ const storage = getStorage(kind);
20
+ if (!storage) return null;
21
+ try {
22
+ const raw = storage.getItem(key);
23
+ return raw === null ? null : (JSON.parse(raw) as unknown);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ function removeStorage(kind: StorageKind, key: string) {
30
+ const storage = getStorage(kind);
31
+ if (!storage) return;
32
+ try {
33
+ storage.removeItem(key);
34
+ } catch {}
35
+ }
36
+
37
+ export function getLocalStorage(key: string): unknown {
38
+ return loadStorage("localStorage", key);
39
+ }
40
+
41
+ export function updateLocalStorage(key: string, value: unknown) {
42
+ saveStorage("localStorage", key, value);
43
+ }
44
+
45
+ export function removeLocalStorage(key: string) {
46
+ removeStorage("localStorage", key);
47
+ }
48
+
49
+ export function getSessionStorage(key: string): unknown {
50
+ return loadStorage("sessionStorage", key);
51
+ }
52
+
53
+ export function updateSessionStorage(key: string, value: unknown) {
54
+ saveStorage("sessionStorage", key, value);
55
+ }
56
+
57
+ export function removeSessionStorage(key: string) {
58
+ removeStorage("sessionStorage", key);
59
+ }
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import { createContext, type ReactNode, useContext, useMemo } from "react";
4
+
5
+ export function buildContext<T>(defaultValue: T) {
6
+ const Ctx = createContext<T>(defaultValue);
7
+
8
+ function Provider({ value, children }: { value: T; children: ReactNode }) {
9
+ const stable = useMemo(() => value, [value]);
10
+ return <Ctx.Provider value={stable}>{children}</Ctx.Provider>;
11
+ }
12
+
13
+ function useValue() {
14
+ const ctx = useContext(Ctx);
15
+ return ctx;
16
+ }
17
+
18
+ return [Provider, useValue] as const;
19
+ }
@@ -0,0 +1,74 @@
1
+ export type DeviceInfo = {
2
+ isMobile: boolean;
3
+ isIOS: boolean;
4
+ isAndroid: boolean;
5
+ isSafari: boolean;
6
+ isIOSSafari: boolean;
7
+ isMacSafari: boolean;
8
+ isSamsungBrowser: boolean;
9
+ isTouchDevice: boolean;
10
+ browser: string;
11
+ };
12
+
13
+ export function getDeviceInfo(): DeviceInfo {
14
+ let isMobile = false;
15
+ let isIOS = false;
16
+ let isAndroid = false;
17
+ let isSafari = false;
18
+ let isIOSSafari = false;
19
+ let isMacSafari = false;
20
+ let isSamsungBrowser = false;
21
+ let isTouchDevice = false;
22
+ let browser = "unknown";
23
+
24
+ if (typeof window === "undefined") {
25
+ return {
26
+ isMobile,
27
+ isIOS,
28
+ isAndroid,
29
+ isSafari,
30
+ isIOSSafari,
31
+ isMacSafari,
32
+ isSamsungBrowser,
33
+ isTouchDevice,
34
+ browser,
35
+ };
36
+ }
37
+
38
+ const ua = navigator.userAgent;
39
+
40
+ isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(ua);
41
+ isIOS = /iPhone|iPad|iPod/i.test(ua);
42
+ isAndroid = /Android/i.test(ua);
43
+ isSafari = /Safari/i.test(ua) && !/Chrome/i.test(ua);
44
+ isIOSSafari = isIOS && isSafari;
45
+ isMacSafari = /Macintosh/i.test(ua) && isSafari && !isIOS;
46
+ isSamsungBrowser = /SamsungBrowser/i.test(ua);
47
+ isTouchDevice = "ontouchstart" in window;
48
+
49
+ if (isSamsungBrowser) {
50
+ browser = "samsung";
51
+ } else if (isSafari) {
52
+ browser = "safari";
53
+ } else if (/Chrome/i.test(ua)) {
54
+ browser = "chrome";
55
+ } else if (/Firefox/i.test(ua)) {
56
+ browser = "firefox";
57
+ } else if (/Edge/i.test(ua)) {
58
+ browser = "edge";
59
+ } else {
60
+ browser = "unknown";
61
+ }
62
+
63
+ return {
64
+ isMobile,
65
+ isIOS,
66
+ isAndroid,
67
+ isSafari,
68
+ isIOSSafari,
69
+ isMacSafari,
70
+ isSamsungBrowser,
71
+ isTouchDevice,
72
+ browser,
73
+ };
74
+ }
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import type {
4
+ NavigatorClipboardProps,
5
+ NavigatorClipboardResult,
6
+ NavigatorShareProps,
7
+ NavigatorShareResult,
8
+ } from "./clipboardShare.types";
9
+
10
+ export function NavigatorClipboard({
11
+ text,
12
+ }: NavigatorClipboardProps): Promise<NavigatorClipboardResult> {
13
+ return navigator.clipboard.writeText(text).then(
14
+ () => ({ success: true }),
15
+ () => ({ success: false }),
16
+ );
17
+ }
18
+
19
+ export function NavigatorShare({
20
+ title,
21
+ text,
22
+ link,
23
+ }: NavigatorShareProps): Promise<NavigatorShareResult> {
24
+ if (navigator.share) {
25
+ return navigator.share({ title, text, url: link }).then(
26
+ () => ({ success: true, method: "share" }),
27
+ (error) => {
28
+ if (error instanceof DOMException && error.name === "AbortError") {
29
+ return { success: false, method: "share" };
30
+ }
31
+ return clipboardFallback(link);
32
+ },
33
+ );
34
+ }
35
+
36
+ return clipboardFallback(link);
37
+ }
38
+
39
+ function clipboardFallback(link: string): Promise<NavigatorShareResult> {
40
+ if (navigator.clipboard) {
41
+ return navigator.clipboard.writeText(link).then(
42
+ () => ({ success: true, method: "clipboard" }),
43
+ () => ({ success: false, method: "unsupported" }),
44
+ );
45
+ }
46
+ return Promise.resolve({ success: false, method: "unsupported" });
47
+ }
@@ -1,18 +1,18 @@
1
1
  export type NavigatorClipboardProps = {
2
- text: string;
2
+ text: string;
3
3
  };
4
4
 
5
5
  export type NavigatorShareProps = {
6
- title: string;
7
- text: string;
8
- link: string;
6
+ title: string;
7
+ text: string;
8
+ link: string;
9
9
  };
10
10
 
11
11
  export type NavigatorClipboardResult = {
12
- success: boolean;
12
+ success: boolean;
13
13
  };
14
14
 
15
15
  export type NavigatorShareResult = {
16
- success: boolean;
17
- method: "share" | "clipboard" | "unsupported";
16
+ success: boolean;
17
+ method: "share" | "clipboard" | "unsupported";
18
18
  };
@@ -0,0 +1,99 @@
1
+ export type FloatingMotionMode =
2
+ | "anchored"
3
+ | "center-selected"
4
+ | "modal-center"
5
+ | "mobile-sheet";
6
+
7
+ export type FloatingPlacement =
8
+ | "top-left"
9
+ | "top-center"
10
+ | "top-right"
11
+ | "middle-left"
12
+ | "middle-center"
13
+ | "middle-right"
14
+ | "bottom-left"
15
+ | "bottom-center"
16
+ | "bottom-right";
17
+
18
+ export interface FloatingMotionPreset {
19
+ enterMs: number;
20
+ exitMs: number;
21
+ ease: string;
22
+ }
23
+
24
+ export const getFloatingMotionPreset = (
25
+ mode: FloatingMotionMode,
26
+ ): FloatingMotionPreset => {
27
+ if (mode === "mobile-sheet") {
28
+ return {
29
+ enterMs: 360,
30
+ exitMs: 280,
31
+ ease: "cubic-bezier(0.2, 0.8, 0.2, 1)",
32
+ };
33
+ }
34
+
35
+ if (mode === "modal-center") {
36
+ return {
37
+ enterMs: 340,
38
+ exitMs: 260,
39
+ ease: "cubic-bezier(0.22, 1, 0.36, 1)",
40
+ };
41
+ }
42
+
43
+ if (mode === "center-selected") {
44
+ return {
45
+ enterMs: 190,
46
+ exitMs: 140,
47
+ ease: "cubic-bezier(0.2, 0, 0, 1)",
48
+ };
49
+ }
50
+
51
+ return {
52
+ enterMs: 230,
53
+ exitMs: 170,
54
+ ease: "cubic-bezier(0.16, 1, 0.3, 1)",
55
+ };
56
+ };
57
+
58
+ export const getFloatingTransformOrigin = (
59
+ placement?: FloatingPlacement,
60
+ ): string => {
61
+ if (!placement) return "top center";
62
+
63
+ const [row, col] = placement.split("-") as [
64
+ "top" | "middle" | "bottom",
65
+ "left" | "center" | "right",
66
+ ];
67
+ const y = row === "top" ? "bottom" : row === "bottom" ? "top" : "center";
68
+ const x = col === "right" ? "right" : col === "left" ? "left" : "center";
69
+
70
+ return `${y} ${x}`;
71
+ };
72
+
73
+ export const getFloatingHiddenTransform = ({
74
+ mode,
75
+ placement,
76
+ }: {
77
+ mode: FloatingMotionMode;
78
+ placement?: FloatingPlacement;
79
+ }): string => {
80
+ if (mode === "mobile-sheet") return "translateY(1.8rem) scale(1)";
81
+ if (mode === "modal-center") return "translateY(.8rem) scale(.94)";
82
+ if (mode === "center-selected") return "translateY(.45rem) scale(.99)";
83
+ if (!placement) return "translateY(.4rem) scale(.975)";
84
+
85
+ const [row, col] = placement.split("-") as [
86
+ "top" | "middle" | "bottom",
87
+ "left" | "center" | "right",
88
+ ];
89
+
90
+ if (row === "top") return "translateY(.4rem) scale(.975)";
91
+ if (row === "bottom") return "translateY(-.4rem) scale(.975)";
92
+
93
+ if (row === "middle") {
94
+ if (col === "left") return "translateX(.4rem) scale(.975)";
95
+ if (col === "right") return "translateX(-.4rem) scale(.975)";
96
+ }
97
+
98
+ return "translateY(.4rem) scale(.975)";
99
+ };
@@ -0,0 +1,13 @@
1
+ "use client";
2
+
3
+ export function isEditableKeyboardTarget(target: HTMLElement) {
4
+ return (
5
+ target.isContentEditable ||
6
+ target.tagName === "TEXTAREA" ||
7
+ target.tagName === "SELECT" ||
8
+ (target.tagName === "INPUT" &&
9
+ (target as HTMLInputElement).type !== "button" &&
10
+ (target as HTMLInputElement).type !== "checkbox" &&
11
+ (target as HTMLInputElement).type !== "radio")
12
+ );
13
+ }
@@ -0,0 +1,15 @@
1
+ import type { Ref, RefCallback } from "react";
2
+
3
+ type ReactRef<T> = Ref<T> | undefined | null;
4
+
5
+ export function mergeRefs<T>(...refs: ReactRef<T>[]): RefCallback<T> {
6
+ return (value: T | null) => {
7
+ for (const ref of refs) {
8
+ if (typeof ref === "function") {
9
+ ref(value);
10
+ } else if (ref && "current" in ref) {
11
+ (ref as { current: T | null }).current = value;
12
+ }
13
+ }
14
+ };
15
+ }
@@ -0,0 +1,29 @@
1
+ export const SEEN_STORAGE_KEY = "seen";
2
+
3
+ type SeenMap = Partial<Record<string, boolean>>;
4
+
5
+ export function parseSeen(raw: string | null): SeenMap {
6
+ if (!raw) return {};
7
+
8
+ try {
9
+ const parsed = JSON.parse(raw) as unknown;
10
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
11
+ return {};
12
+ }
13
+
14
+ return parsed as SeenMap;
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ export function hasSeenKey(raw: string | null, key: string) {
21
+ const map = parseSeen(raw);
22
+ return map[key] === true;
23
+ }
24
+
25
+ export function buildSeenValue(raw: string | null, key: string): string {
26
+ const map = parseSeen(raw);
27
+ map[key] = true;
28
+ return JSON.stringify(map);
29
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ type SubscribeKeyboardHeightOptions = {
4
+ callback: (height: number) => void;
5
+ immediate?: boolean;
6
+ throttleMs?: number;
7
+ };
8
+
9
+ export function subscribeKeyboardHeight({
10
+ callback,
11
+ immediate = false,
12
+ throttleMs = 16,
13
+ }: SubscribeKeyboardHeightOptions) {
14
+ let ticking = false;
15
+ let lastHeight = 0;
16
+
17
+ const notify = (height: number) => {
18
+ if (ticking) return;
19
+ ticking = true;
20
+
21
+ const run = () => {
22
+ if (height !== lastHeight) {
23
+ lastHeight = height;
24
+ callback(height);
25
+ }
26
+ ticking = false;
27
+ };
28
+
29
+ if (throttleMs > 0) {
30
+ setTimeout(run, throttleMs);
31
+ } else {
32
+ run();
33
+ }
34
+ };
35
+
36
+ const handleResize = () => {
37
+ const vv = window.visualViewport;
38
+ if (!vv) return;
39
+ const diff = window.innerHeight - vv.height;
40
+ notify(diff > 0 ? diff : 0);
41
+ };
42
+
43
+ if (immediate) handleResize();
44
+
45
+ window.visualViewport?.addEventListener("resize", handleResize);
46
+ window.visualViewport?.addEventListener("scroll", handleResize);
47
+
48
+ return {
49
+ unsubscribe() {
50
+ window.visualViewport?.removeEventListener("resize", handleResize);
51
+ window.visualViewport?.removeEventListener("scroll", handleResize);
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,15 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { ScrolltoTop } from "@/packages/components/ScrolltoTop";
4
+
5
+ describe("ScrolltoTop", () => {
6
+ test("calls window.scrollTo(0, 0) on mount", () => {
7
+ const originalScrollTo = window.scrollTo;
8
+ window.scrollTo = vi.fn();
9
+
10
+ render(<ScrolltoTop />);
11
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
12
+
13
+ window.scrollTo = originalScrollTo;
14
+ });
15
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { render } from "@testing-library/react";
3
+ import { SwitchCase } from "@/packages/components/SwitchCase";
4
+ import React from "react";
5
+
6
+ describe("SwitchCase", () => {
7
+ const cases = {
8
+ 1: <div>Case 1</div>,
9
+ 2: <div>Case 2</div>,
10
+ three: <div>Case Three</div>,
11
+ };
12
+
13
+ test("renders correctly matching case", () => {
14
+ const { container } = render(<SwitchCase value={1} cases={cases} />);
15
+ expect(container.textContent).toBe("Case 1");
16
+ });
17
+
18
+ test("renders another case", () => {
19
+ const { container } = render(<SwitchCase value="three" cases={cases} />);
20
+ expect(container.textContent).toBe("Case Three");
21
+ });
22
+
23
+ test("renders otherwise when case does not exist", () => {
24
+ const { container } = render(
25
+ <SwitchCase value={99} cases={cases} otherwise={<div>Fallback</div>} />,
26
+ );
27
+ expect(container.textContent).toBe("Fallback");
28
+ });
29
+
30
+ test("renders null when case does not exist and otherwise is not provided", () => {
31
+ const { container } = render(<SwitchCase value={99} cases={cases} />);
32
+ expect(container.innerHTML).toBe("");
33
+ });
34
+ });