@musecat/functionkit 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.agents/references/hooks/useIntersectionObserver.md +4 -0
  2. package/.agents/references/hooks/useInterval.md +4 -0
  3. package/.agents/references/hooks/usePreservedCallback.md +4 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +31 -0
  5. package/.github/ISSUE_TEMPLATE/feature_request.md +22 -0
  6. package/.github/PULL_REQUEST_TEMPLATE.md +22 -0
  7. package/.github/workflows/ci.yml +20 -0
  8. package/.local/state/gh/device-id +1 -0
  9. package/.prettierrc +8 -0
  10. package/AGENTS.md +14 -0
  11. package/CODE_OF_CONDUCT.md +55 -0
  12. package/CONTRIBUTING.md +39 -0
  13. package/biome.json +25 -0
  14. package/index.ts +1 -1
  15. package/package.json +21 -2
  16. package/packages/components/ScrolltoTop.tsx +1 -1
  17. package/packages/components/ViewportPortal.tsx +1 -2
  18. package/packages/cookie/cookie.shared.ts +7 -33
  19. package/packages/cookie/cookieNames.shared.ts +9 -0
  20. package/packages/datetime/dateTime.client.ts +1 -3
  21. package/packages/datetime/dateTime.server.ts +1 -3
  22. package/packages/datetime/dateTime.shared.ts +9 -47
  23. package/packages/hooks/useCheckInvisible.ts +1 -1
  24. package/packages/hooks/useClientDateTime.ts +3 -16
  25. package/packages/hooks/useDebounce.ts +2 -7
  26. package/packages/hooks/useGeolocation.ts +1 -5
  27. package/packages/hooks/useInterval.ts +1 -2
  28. package/packages/hooks/useKeyboardListNavigation.ts +10 -38
  29. package/packages/hooks/useLongPress.ts +13 -24
  30. package/packages/hooks/useRelativeDateTime.ts +1 -4
  31. package/packages/hooks/useViewportHeight.ts +1 -2
  32. package/packages/hooks/useViewportMatch.ts +0 -4
  33. package/packages/utils/buildContext.tsx +2 -3
  34. package/packages/utils/floatingMotion.ts +14 -20
  35. package/packages/utils/subscribeKeyboardHeight.ts +3 -0
  36. package/tests/components/ScrolltoTop.test.tsx +1 -1
  37. package/tests/components/SwitchCase.test.tsx +1 -2
  38. package/tests/components/ViewportPortal.test.tsx +10 -6
  39. package/tests/cookie/cookie.test.ts +47 -11
  40. package/tests/datetime/datetime.test.ts +257 -46
  41. package/tests/hooks/useAvoidKeyboard.test.ts +32 -1
  42. package/tests/hooks/useCheckInvisible.test.ts +36 -5
  43. package/tests/hooks/useCheckScroll.test.ts +2 -2
  44. package/tests/hooks/useClientDateTime.test.ts +2 -4
  45. package/tests/hooks/useDebounce.test.ts +30 -5
  46. package/tests/hooks/useDebouncedCallback.test.ts +41 -12
  47. package/tests/hooks/useDoubleClick.test.ts +58 -7
  48. package/tests/hooks/useGeolocation.test.ts +155 -4
  49. package/tests/hooks/useHasMounted.test.ts +1 -1
  50. package/tests/hooks/useIntersectionObserver.test.ts +70 -5
  51. package/tests/hooks/useInterval.test.ts +28 -3
  52. package/tests/hooks/useKeyboardHeight.test.ts +1 -1
  53. package/tests/hooks/useKeyboardListNavigation.test.ts +340 -18
  54. package/tests/hooks/useLongPress.test.ts +177 -15
  55. package/tests/hooks/usePreservedCallback.test.ts +7 -9
  56. package/tests/hooks/usePreservedReference.test.ts +19 -12
  57. package/tests/hooks/useRefEffect.test.ts +43 -6
  58. package/tests/hooks/useRelativeDateTime.test.ts +8 -6
  59. package/tests/hooks/useTimeout.test.ts +43 -5
  60. package/tests/hooks/useToggleState.test.ts +14 -6
  61. package/tests/hooks/useViewportHeight.test.ts +24 -2
  62. package/tests/hooks/useViewportMatch.test.ts +1 -1
  63. package/tests/utils/browserStorage.test.ts +68 -8
  64. package/tests/utils/buildContext.test.tsx +1 -2
  65. package/tests/utils/checkDevice.test.ts +62 -6
  66. package/tests/utils/clipboardShare.test.ts +79 -5
  67. package/tests/utils/floatingMotion.test.ts +60 -9
  68. package/tests/utils/keyboardTarget.test.ts +42 -1
  69. package/tests/utils/mergeRefs.test.ts +33 -1
  70. package/tests/utils/seen.test.ts +16 -7
  71. package/tests/utils/subscribeKeyboardHeight.test.ts +124 -2
  72. package/vitest.config.ts +7 -1
@@ -1,8 +1,8 @@
1
- import { describe, test, expect } from "vitest";
1
+ import { describe, expect, test } from "vitest";
2
2
  import {
3
+ getFloatingHiddenTransform,
3
4
  getFloatingMotionPreset,
4
5
  getFloatingTransformOrigin,
5
- getFloatingHiddenTransform,
6
6
  } from "@/packages/utils/floatingMotion";
7
7
 
8
8
  describe("getFloatingMotionPreset", () => {
@@ -47,19 +47,49 @@ describe("getFloatingTransformOrigin", () => {
47
47
  test("returns correct origin for middle-center", () => {
48
48
  expect(getFloatingTransformOrigin("middle-center")).toBe("center center");
49
49
  });
50
+
51
+ test("returns correct origin for top-center", () => {
52
+ expect(getFloatingTransformOrigin("top-center")).toBe("bottom center");
53
+ });
54
+
55
+ test("returns correct origin for top-right", () => {
56
+ expect(getFloatingTransformOrigin("top-right")).toBe("bottom right");
57
+ });
58
+
59
+ test("returns correct origin for middle-left", () => {
60
+ expect(getFloatingTransformOrigin("middle-left")).toBe("center left");
61
+ });
62
+
63
+ test("returns correct origin for middle-right", () => {
64
+ expect(getFloatingTransformOrigin("middle-right")).toBe("center right");
65
+ });
66
+
67
+ test("returns correct origin for bottom-left", () => {
68
+ expect(getFloatingTransformOrigin("bottom-left")).toBe("top left");
69
+ });
70
+
71
+ test("returns correct origin for bottom-center", () => {
72
+ expect(getFloatingTransformOrigin("bottom-center")).toBe("top center");
73
+ });
50
74
  });
51
75
 
52
76
  describe("getFloatingHiddenTransform", () => {
53
77
  test("returns mobile-sheet transform", () => {
54
- expect(getFloatingHiddenTransform({ mode: "mobile-sheet" })).toBe("translateY(1.8rem) scale(1)");
78
+ expect(getFloatingHiddenTransform({ mode: "mobile-sheet" })).toBe(
79
+ "translateY(1.8rem) scale(1)",
80
+ );
55
81
  });
56
82
 
57
83
  test("returns modal-center transform", () => {
58
- expect(getFloatingHiddenTransform({ mode: "modal-center" })).toBe("translateY(.8rem) scale(.94)");
84
+ expect(getFloatingHiddenTransform({ mode: "modal-center" })).toBe(
85
+ "translateY(.8rem) scale(.94)",
86
+ );
59
87
  });
60
88
 
61
89
  test("returns center-selected transform", () => {
62
- expect(getFloatingHiddenTransform({ mode: "center-selected" })).toBe("translateY(.45rem) scale(.99)");
90
+ expect(getFloatingHiddenTransform({ mode: "center-selected" })).toBe(
91
+ "translateY(.45rem) scale(.99)",
92
+ );
63
93
  });
64
94
 
65
95
  test("anchored with no placement", () => {
@@ -67,18 +97,39 @@ describe("getFloatingHiddenTransform", () => {
67
97
  });
68
98
 
69
99
  test("anchored with top placement", () => {
70
- expect(getFloatingHiddenTransform({ mode: "anchored", placement: "top-left" })).toBe("translateY(.4rem) scale(.975)");
100
+ expect(getFloatingHiddenTransform({ mode: "anchored", placement: "top-left" })).toBe(
101
+ "translateY(.4rem) scale(.975)",
102
+ );
71
103
  });
72
104
 
73
105
  test("anchored with bottom placement", () => {
74
- expect(getFloatingHiddenTransform({ mode: "anchored", placement: "bottom-center" })).toBe("translateY(-.4rem) scale(.975)");
106
+ expect(getFloatingHiddenTransform({ mode: "anchored", placement: "bottom-center" })).toBe(
107
+ "translateY(-.4rem) scale(.975)",
108
+ );
75
109
  });
76
110
 
77
111
  test("anchored with middle-left placement", () => {
78
- expect(getFloatingHiddenTransform({ mode: "anchored", placement: "middle-left" })).toBe("translateX(.4rem) scale(.975)");
112
+ expect(getFloatingHiddenTransform({ mode: "anchored", placement: "middle-left" })).toBe(
113
+ "translateX(.4rem) scale(.975)",
114
+ );
79
115
  });
80
116
 
81
117
  test("anchored with middle-right placement", () => {
82
- expect(getFloatingHiddenTransform({ mode: "anchored", placement: "middle-right" })).toBe("translateX(-.4rem) scale(.975)");
118
+ expect(getFloatingHiddenTransform({ mode: "anchored", placement: "middle-right" })).toBe(
119
+ "translateX(-.4rem) scale(.975)",
120
+ );
121
+ });
122
+
123
+ test("anchored with middle-center placement falls through to default", () => {
124
+ expect(getFloatingHiddenTransform({ mode: "anchored", placement: "middle-center" })).toBe(
125
+ "translateY(.4rem) scale(.975)",
126
+ );
127
+ });
128
+
129
+ test("anchored with middle placement and undefined col falls through", () => {
130
+ // @ts-expect-error - testing unexpected placement fallthrough
131
+ expect(getFloatingHiddenTransform({ mode: "anchored", placement: "middle" })).toBe(
132
+ "translateY(.4rem) scale(.975)",
133
+ );
83
134
  });
84
135
  });
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "vitest";
1
+ import { describe, expect, test } from "vitest";
2
2
  import { isEditableKeyboardTarget } from "@/packages/utils/keyboardTarget";
3
3
 
4
4
  describe("isEditableKeyboardTarget", () => {
@@ -41,4 +41,45 @@ describe("isEditableKeyboardTarget", () => {
41
41
  const el = document.createElement("div");
42
42
  expect(isEditableKeyboardTarget(el)).toBe(false);
43
43
  });
44
+
45
+ test("returns true for SELECT element", () => {
46
+ const el = document.createElement("select");
47
+ expect(isEditableKeyboardTarget(el)).toBe(true);
48
+ });
49
+
50
+ test("returns true for email INPUT", () => {
51
+ const el = document.createElement("input");
52
+ el.type = "email";
53
+ expect(isEditableKeyboardTarget(el)).toBe(true);
54
+ });
55
+
56
+ test("returns true for url INPUT", () => {
57
+ const el = document.createElement("input");
58
+ el.type = "url";
59
+ expect(isEditableKeyboardTarget(el)).toBe(true);
60
+ });
61
+
62
+ test("returns true for number INPUT", () => {
63
+ const el = document.createElement("input");
64
+ el.type = "number";
65
+ expect(isEditableKeyboardTarget(el)).toBe(true);
66
+ });
67
+
68
+ test("returns true for password INPUT", () => {
69
+ const el = document.createElement("input");
70
+ el.type = "password";
71
+ expect(isEditableKeyboardTarget(el)).toBe(true);
72
+ });
73
+
74
+ test("returns true for search INPUT", () => {
75
+ const el = document.createElement("input");
76
+ el.type = "search";
77
+ expect(isEditableKeyboardTarget(el)).toBe(true);
78
+ });
79
+
80
+ test("returns true for tel INPUT", () => {
81
+ const el = document.createElement("input");
82
+ el.type = "tel";
83
+ expect(isEditableKeyboardTarget(el)).toBe(true);
84
+ });
44
85
  });
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, vi } from "vitest";
1
+ import { describe, expect, test, vi } from "vitest";
2
2
  import { mergeRefs } from "@/packages/utils/mergeRefs";
3
3
 
4
4
  describe("mergeRefs", () => {
@@ -15,4 +15,36 @@ describe("mergeRefs", () => {
15
15
  expect(ref1).toHaveBeenCalledWith(element);
16
16
  expect(ref2.current).toBe(element);
17
17
  });
18
+
19
+ test("handles all null refs", () => {
20
+ const merged = mergeRefs(null, undefined, null);
21
+ const element = document.createElement("div");
22
+ expect(() => merged(element as any)).not.toThrow();
23
+ });
24
+
25
+ test("handles empty refs array", () => {
26
+ const merged = mergeRefs();
27
+ const element = document.createElement("div");
28
+ expect(() => merged(element as any)).not.toThrow();
29
+ });
30
+
31
+ test("handles all callback refs", () => {
32
+ const ref1 = vi.fn();
33
+ const ref2 = vi.fn();
34
+ const merged = mergeRefs(ref1, ref2);
35
+ const element = document.createElement("div");
36
+ merged(element as any);
37
+ expect(ref1).toHaveBeenCalledWith(element);
38
+ expect(ref2).toHaveBeenCalledWith(element);
39
+ });
40
+
41
+ test("handles all object refs", () => {
42
+ const ref1 = { current: null };
43
+ const ref2 = { current: null };
44
+ const merged = mergeRefs(ref1, ref2);
45
+ const element = document.createElement("div");
46
+ merged(element as any);
47
+ expect(ref1.current).toBe(element);
48
+ expect(ref2.current).toBe(element);
49
+ });
18
50
  });
@@ -1,10 +1,5 @@
1
- import { describe, test, expect } from "vitest";
2
- import {
3
- parseSeen,
4
- hasSeenKey,
5
- buildSeenValue,
6
- SEEN_STORAGE_KEY,
7
- } from "@/packages/utils/seen";
1
+ import { describe, expect, test } from "vitest";
2
+ import { buildSeenValue, hasSeenKey, parseSeen, SEEN_STORAGE_KEY } from "@/packages/utils/seen";
8
3
 
9
4
  describe("seen", () => {
10
5
  test("SEEN_STORAGE_KEY", () => {
@@ -28,4 +23,18 @@ describe("seen", () => {
28
23
  const result = buildSeenValue('{"a":true}', "b");
29
24
  expect(JSON.parse(result)).toEqual({ a: true, b: true });
30
25
  });
26
+
27
+ test("parseSeen empty string returns {}", () => {
28
+ expect(parseSeen("")).toEqual({});
29
+ });
30
+
31
+ test("buildSeenValue with null raw creates fresh object", () => {
32
+ const result = buildSeenValue(null, "key");
33
+ expect(JSON.parse(result)).toEqual({ key: true });
34
+ });
35
+
36
+ test("buildSeenValue overrides existing key to true", () => {
37
+ const result = buildSeenValue('{"key":false}', "key");
38
+ expect(JSON.parse(result)).toEqual({ key: true });
39
+ });
31
40
  });
@@ -1,4 +1,4 @@
1
- import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
2
  import { subscribeKeyboardHeight } from "@/packages/utils/subscribeKeyboardHeight";
3
3
 
4
4
  describe("subscribeKeyboardHeight", () => {
@@ -24,7 +24,9 @@ describe("subscribeKeyboardHeight", () => {
24
24
  });
25
25
  });
26
26
 
27
- afterEach(() => { vi.useRealTimers(); });
27
+ afterEach(() => {
28
+ vi.useRealTimers();
29
+ });
28
30
 
29
31
  test("subscribes to resize events", () => {
30
32
  const callback = vi.fn();
@@ -41,10 +43,130 @@ describe("subscribeKeyboardHeight", () => {
41
43
  expect(callback).toHaveBeenCalled();
42
44
  });
43
45
 
46
+ test("runs synchronously when throttleMs is 0", () => {
47
+ const callback = vi.fn();
48
+ subscribeKeyboardHeight({ callback, throttleMs: 0, immediate: true });
49
+ expect(callback).toHaveBeenCalled();
50
+ });
51
+
52
+ test("skips resize when visualViewport is null", () => {
53
+ const origVv = window.visualViewport;
54
+ Object.defineProperty(window, "visualViewport", {
55
+ value: undefined,
56
+ writable: true,
57
+ configurable: true,
58
+ });
59
+ const callback = vi.fn();
60
+ subscribeKeyboardHeight({ callback, immediate: true });
61
+ expect(callback).not.toHaveBeenCalled();
62
+ Object.defineProperty(window, "visualViewport", {
63
+ value: origVv,
64
+ writable: true,
65
+ configurable: true,
66
+ });
67
+ });
68
+
69
+ test("does not call callback when height unchanged", () => {
70
+ const callback = vi.fn();
71
+ subscribeKeyboardHeight({ callback, throttleMs: 0, immediate: true });
72
+ expect(callback).toHaveBeenCalledTimes(1);
73
+
74
+ // Trigger resize with the same keyboard height
75
+ const handler = visualViewport.addEventListener.mock.calls.find(
76
+ ([event]: [string]) => event === "resize",
77
+ )?.[1] as () => void;
78
+ if (handler) handler();
79
+
80
+ // Same diff (innerHeight 800 - visualViewport.height 600 = 200), so notify(200) again
81
+ // But lastHeight is already 200, so height !== lastHeight is false
82
+ expect(callback).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ test("handles no keyboard scenario (diff <= 0)", () => {
86
+ const origInnerHeight = window.innerHeight;
87
+ // First, call with keyboard raised (diff=200) so lastHeight becomes 200
88
+ const callback = vi.fn();
89
+ subscribeKeyboardHeight({ callback, throttleMs: 0, immediate: true });
90
+ expect(callback).toHaveBeenCalledWith(200);
91
+ // Now change innerHeight to match visualViewport.height (no keyboard)
92
+ Object.defineProperty(window, "innerHeight", {
93
+ value: 600,
94
+ writable: true,
95
+ configurable: true,
96
+ });
97
+ // Trigger resize handler
98
+ const resizeHandler = visualViewport.addEventListener.mock.calls.find(
99
+ ([event]: [string]) => event === "resize",
100
+ )?.[1] as () => void;
101
+ resizeHandler();
102
+ // diff = 600 - 600 = 0 → notify(0) → 0 !== 200 → callback(0)
103
+ expect(callback).toHaveBeenCalledWith(0);
104
+ Object.defineProperty(window, "innerHeight", {
105
+ value: origInnerHeight,
106
+ writable: true,
107
+ configurable: true,
108
+ });
109
+ });
110
+
111
+ test("throttles rapid resize events", () => {
112
+ vi.useFakeTimers();
113
+ const callback = vi.fn();
114
+ subscribeKeyboardHeight({ callback, throttleMs: 50 });
115
+
116
+ // Simulate two rapid resize events
117
+ visualViewport.height = 700;
118
+ visualViewport.addEventListener.mock.calls.forEach(([event, handler]: [string, () => void]) => {
119
+ if (event === "resize") handler();
120
+ });
121
+
122
+ // Change height again and fire again
123
+ visualViewport.height = 650;
124
+ visualViewport.addEventListener.mock.calls.forEach(([event, handler]: [string, () => void]) => {
125
+ if (event === "resize") handler();
126
+ });
127
+
128
+ // Only the last height should be reported after throttle
129
+ vi.advanceTimersByTime(50);
130
+ expect(callback).toHaveBeenCalledTimes(1);
131
+ });
132
+
44
133
  test("returns unsubscribe function", () => {
45
134
  const callback = vi.fn();
46
135
  const { unsubscribe } = subscribeKeyboardHeight({ callback });
47
136
  unsubscribe();
48
137
  expect(visualViewport.removeEventListener).toHaveBeenCalled();
49
138
  });
139
+
140
+ test("does not call callback after unsubscribe with throttle > 0", () => {
141
+ vi.useFakeTimers();
142
+ const callback = vi.fn();
143
+ const { unsubscribe } = subscribeKeyboardHeight({ callback, throttleMs: 50 });
144
+
145
+ // Trigger resize, which schedules a setTimeout
146
+ visualViewport.height = 400;
147
+ visualViewport.dispatchEvent?.(new Event("resize"));
148
+ if (visualViewport.addEventListener.mock.calls.length > 0) {
149
+ const handler = visualViewport.addEventListener.mock.calls[0][1];
150
+ handler?.();
151
+ }
152
+
153
+ unsubscribe();
154
+ vi.advanceTimersByTime(100);
155
+ expect(callback).not.toHaveBeenCalled();
156
+ });
157
+
158
+ test("does not call callback after unsubscribe with throttle === 0", () => {
159
+ vi.useFakeTimers();
160
+ const callback = vi.fn();
161
+ const { unsubscribe } = subscribeKeyboardHeight({ callback, throttleMs: 0 });
162
+
163
+ unsubscribe();
164
+
165
+ visualViewport.height = 400;
166
+ if (visualViewport.addEventListener.mock.calls.length > 0) {
167
+ const handler = visualViewport.addEventListener.mock.calls[0][1];
168
+ handler?.();
169
+ }
170
+ expect(callback).not.toHaveBeenCalled();
171
+ });
50
172
  });
package/vitest.config.ts CHANGED
@@ -1,5 +1,5 @@
1
+ import path from "node:path";
1
2
  import { defineConfig } from "vitest/config";
2
- import path from "path";
3
3
 
4
4
  export default defineConfig({
5
5
  test: {
@@ -7,6 +7,12 @@ export default defineConfig({
7
7
  setupFiles: ["./tests/setup.ts"],
8
8
  include: ["tests/**/*.test.{ts,tsx}"],
9
9
  globals: true,
10
+ coverage: {
11
+ provider: "v8",
12
+ include: ["packages/**/*.{ts,tsx}"],
13
+ exclude: ["packages/**/*.types.ts"],
14
+ reporter: ["text", "lcov"],
15
+ },
10
16
  },
11
17
  resolve: {
12
18
  alias: {