@boxcustodia/library 2.0.0-alpha.16 → 2.0.0-alpha.18
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/dist/index.cjs.js +1 -1
- package/dist/index.d.ts +40 -14
- package/dist/index.es.js +310 -282
- package/package.json +1 -1
- package/src/__doc__/V2.mdx +37 -1
- package/src/components/accordion/accordion.test.tsx +117 -0
- package/src/components/alert-dialog/alert-dialog.test.tsx +208 -22
- package/src/components/alert-dialog/alert-dialog.tsx +4 -4
- package/src/components/avatar/avatar.test.tsx +166 -29
- package/src/components/combobox/combobox.tsx +1 -1
- package/src/components/dialog/dialog.tsx +1 -1
- package/src/components/input/input.stories.tsx +2 -3
- package/src/components/input/input.test.tsx +109 -0
- package/src/components/label/label.tsx +1 -1
- package/src/components/number-input/number-input.test.tsx +155 -48
- package/src/components/toast/toast.tsx +1 -1
- package/src/hooks/index.ts +4 -3
- package/src/hooks/use-clipboard/index.ts +1 -0
- package/src/hooks/use-clipboard/use-clipboard.stories.tsx +168 -0
- package/src/hooks/use-clipboard/use-clipboard.test.tsx +83 -0
- package/src/hooks/use-clipboard/use-clipboard.tsx +64 -0
- package/src/hooks/use-document-title/index.ts +1 -0
- package/src/hooks/use-document-title/use-document-title.stories.tsx +72 -0
- package/src/hooks/use-document-title/use-document-title.test.tsx +75 -0
- package/src/hooks/use-document-title/use-document-title.tsx +32 -0
- package/src/hooks/use-hover/index.ts +1 -0
- package/src/hooks/use-hover/use-hover.stories.tsx +90 -0
- package/src/hooks/use-hover/use-hover.test.tsx +93 -0
- package/src/hooks/use-hover/use-hover.tsx +45 -0
- package/src/hooks/use-on-mount/index.ts +1 -0
- package/src/hooks/use-on-mount/use-on-mount.stories.tsx +85 -0
- package/src/hooks/use-on-mount/use-on-mount.test.tsx +44 -0
- package/src/hooks/use-on-mount/use-on-mount.tsx +13 -0
- package/src/utils/form.tsx +0 -2
- package/src/hooks/useClipboard/__doc__/useClipboard.stories.tsx +0 -43
- package/src/hooks/useClipboard/__test__/useClipboard.test.tsx +0 -19
- package/src/hooks/useClipboard/index.ts +0 -1
- package/src/hooks/useClipboard/useClipboard.tsx +0 -28
- package/src/hooks/useDocumentTitle/__doc__/useDocumentTitle.stories.tsx +0 -26
- package/src/hooks/useDocumentTitle/index.ts +0 -1
- package/src/hooks/useDocumentTitle/useDocumentTitle.tsx +0 -11
- package/src/hooks/useHover/__doc__/useHover.stories.tsx +0 -41
- package/src/hooks/useHover/__test__/useHover.test.tsx +0 -45
- package/src/hooks/useHover/index.ts +0 -1
- package/src/hooks/useHover/useHover.tsx +0 -40
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useClipboard } from "./use-clipboard";
|
|
4
|
+
|
|
5
|
+
describe("useClipboard", () => {
|
|
6
|
+
const writeText = vi.fn<(value: string) => Promise<void>>();
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
writeText.mockResolvedValue();
|
|
10
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
11
|
+
value: { writeText },
|
|
12
|
+
configurable: true,
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
writeText.mockReset();
|
|
18
|
+
vi.useRealTimers();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("copies a value and flips copied to true", async () => {
|
|
22
|
+
const { result } = renderHook(() => useClipboard());
|
|
23
|
+
|
|
24
|
+
act(() => result.current.copy("test"));
|
|
25
|
+
|
|
26
|
+
await waitFor(() => expect(result.current.copied).toBe(true));
|
|
27
|
+
expect(writeText).toHaveBeenCalledWith("test");
|
|
28
|
+
expect(result.current.error).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("auto-resets copied after the configured timeout", async () => {
|
|
32
|
+
vi.useFakeTimers();
|
|
33
|
+
const { result } = renderHook(() => useClipboard({ timeout: 500 }));
|
|
34
|
+
|
|
35
|
+
await act(async () => {
|
|
36
|
+
result.current.copy("test");
|
|
37
|
+
await Promise.resolve();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(result.current.copied).toBe(true);
|
|
41
|
+
|
|
42
|
+
act(() => vi.advanceTimersByTime(500));
|
|
43
|
+
|
|
44
|
+
expect(result.current.copied).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("exposes the error when writeText rejects", async () => {
|
|
48
|
+
const failure = new Error("denied");
|
|
49
|
+
writeText.mockRejectedValueOnce(failure);
|
|
50
|
+
const { result } = renderHook(() => useClipboard());
|
|
51
|
+
|
|
52
|
+
act(() => result.current.copy("test"));
|
|
53
|
+
|
|
54
|
+
await waitFor(() => expect(result.current.error).toBe(failure));
|
|
55
|
+
expect(result.current.copied).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("populates error when navigator.clipboard is unavailable", () => {
|
|
59
|
+
Object.defineProperty(navigator, "clipboard", {
|
|
60
|
+
value: undefined,
|
|
61
|
+
configurable: true,
|
|
62
|
+
});
|
|
63
|
+
const { result } = renderHook(() => useClipboard());
|
|
64
|
+
|
|
65
|
+
act(() => result.current.copy("test"));
|
|
66
|
+
|
|
67
|
+
expect(result.current.error).toBeInstanceOf(Error);
|
|
68
|
+
expect(result.current.error?.message).toMatch(/not supported/);
|
|
69
|
+
expect(result.current.copied).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("clears copied and error on reset()", async () => {
|
|
73
|
+
const { result } = renderHook(() => useClipboard());
|
|
74
|
+
|
|
75
|
+
act(() => result.current.copy("test"));
|
|
76
|
+
await waitFor(() => expect(result.current.copied).toBe(true));
|
|
77
|
+
|
|
78
|
+
act(() => result.current.reset());
|
|
79
|
+
|
|
80
|
+
expect(result.current.copied).toBe(false);
|
|
81
|
+
expect(result.current.error).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UseClipboardInput {
|
|
4
|
+
/** Time in ms after which the copied state will reset, `2000` by default */
|
|
5
|
+
timeout?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UseClipboardReturnValue {
|
|
9
|
+
/** Function to copy a string value to the clipboard */
|
|
10
|
+
copy: (value: string) => void;
|
|
11
|
+
|
|
12
|
+
/** Function to reset copied state and error */
|
|
13
|
+
reset: () => void;
|
|
14
|
+
|
|
15
|
+
/** Error if copying failed, `null` otherwise */
|
|
16
|
+
error: Error | null;
|
|
17
|
+
|
|
18
|
+
/** Boolean indicating if the value was copied successfully */
|
|
19
|
+
copied: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useClipboard(
|
|
23
|
+
options: UseClipboardInput = {},
|
|
24
|
+
): UseClipboardReturnValue {
|
|
25
|
+
const timeout = options.timeout ?? 2000;
|
|
26
|
+
const [error, setError] = useState<Error | null>(null);
|
|
27
|
+
const [copied, setCopied] = useState(false);
|
|
28
|
+
const timeoutRef = useRef<number | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(
|
|
31
|
+
() => () => {
|
|
32
|
+
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
|
33
|
+
},
|
|
34
|
+
[],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const handleCopyResult = (value: boolean) => {
|
|
38
|
+
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
|
39
|
+
timeoutRef.current = window.setTimeout(() => setCopied(false), timeout);
|
|
40
|
+
setCopied(value);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const copy = (value: string) => {
|
|
44
|
+
if (!navigator.clipboard?.writeText) {
|
|
45
|
+
setError(new Error("useClipboard: navigator.clipboard is not supported"));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
navigator.clipboard
|
|
49
|
+
.writeText(value)
|
|
50
|
+
.then(() => {
|
|
51
|
+
setError(null);
|
|
52
|
+
handleCopyResult(true);
|
|
53
|
+
})
|
|
54
|
+
.catch((err: Error) => setError(err));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const reset = () => {
|
|
58
|
+
setCopied(false);
|
|
59
|
+
setError(null);
|
|
60
|
+
if (timeoutRef.current) window.clearTimeout(timeoutRef.current);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return { copy, reset, error, copied };
|
|
64
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./use-document-title";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Input } from "../../components/input";
|
|
4
|
+
import { Stack } from "../../components/stack";
|
|
5
|
+
import { useDocumentTitle } from "./use-document-title";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sync `document.title` with a piece of React state — the classic
|
|
9
|
+
* "mirror the page name in the browser tab" pattern.
|
|
10
|
+
*
|
|
11
|
+
* ```tsx
|
|
12
|
+
* useDocumentTitle("Dashboard");
|
|
13
|
+
* useDocumentTitle(`Inbox (${unreadCount})`);
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Good to know:
|
|
17
|
+
* - **Empty and whitespace-only values are ignored.** The current title
|
|
18
|
+
* stays put. Avoids the foot-gun of a parent component passing a
|
|
19
|
+
* not-yet-loaded value and blanking the tab.
|
|
20
|
+
* - **Leading and trailing whitespace is trimmed** before being written.
|
|
21
|
+
* - **Opt-in cleanup:** pass `{ restoreOnUnmount: true }` to restore the
|
|
22
|
+
* title that existed at mount time when the component unmounts. Useful
|
|
23
|
+
* for modal/route components that only own the title while they are
|
|
24
|
+
* visible.
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* useDocumentTitle(`Editing: ${doc.name}`, { restoreOnUnmount: true });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Heads up: inside Storybook the change happens on the **iframe's**
|
|
31
|
+
* `document.title`, not the parent browser tab. Open the story in
|
|
32
|
+
* isolated canvas view to see the tab update, or just read the value
|
|
33
|
+
* rendered inline below.
|
|
34
|
+
*/
|
|
35
|
+
const meta: Meta<typeof useDocumentTitle> = {
|
|
36
|
+
title: "hooks/useDocumentTitle",
|
|
37
|
+
parameters: { layout: "centered" },
|
|
38
|
+
tags: ["beta"],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export default meta;
|
|
42
|
+
type Story = StoryObj<typeof useDocumentTitle>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Type into the input — the hook writes `document.title` in real time.
|
|
46
|
+
* The code block below mirrors what gets written, including the
|
|
47
|
+
* whitespace-trim behavior. Clear the input to see the "ignored" path:
|
|
48
|
+
* the title stays at its last non-empty value.
|
|
49
|
+
*/
|
|
50
|
+
export const Default: Story = {
|
|
51
|
+
render: () => {
|
|
52
|
+
const [title, setTitle] = useState("Dashboard");
|
|
53
|
+
useDocumentTitle(title);
|
|
54
|
+
|
|
55
|
+
const written = title.trim();
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Stack direction="vertical" gap={12} style={{ minWidth: 360 }}>
|
|
59
|
+
<Input
|
|
60
|
+
value={title}
|
|
61
|
+
onValueChange={setTitle}
|
|
62
|
+
placeholder="Type a title…"
|
|
63
|
+
/>
|
|
64
|
+
<code className="rounded-md bg-muted px-3 py-2 text-xs">
|
|
65
|
+
{written.length > 0
|
|
66
|
+
? `document.title = "${written}"`
|
|
67
|
+
: "(empty — current title preserved)"}
|
|
68
|
+
</code>
|
|
69
|
+
</Stack>
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { renderHook } from "@testing-library/react";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { useDocumentTitle } from "./use-document-title";
|
|
4
|
+
|
|
5
|
+
describe("useDocumentTitle", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
document.title = "Original";
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
document.title = "";
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("sets document.title to the provided value", () => {
|
|
15
|
+
renderHook(() => useDocumentTitle("Dashboard"));
|
|
16
|
+
expect(document.title).toBe("Dashboard");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("updates the title when the value changes", () => {
|
|
20
|
+
const { rerender } = renderHook(({ title }) => useDocumentTitle(title), {
|
|
21
|
+
initialProps: { title: "First" },
|
|
22
|
+
});
|
|
23
|
+
expect(document.title).toBe("First");
|
|
24
|
+
|
|
25
|
+
rerender({ title: "Second" });
|
|
26
|
+
expect(document.title).toBe("Second");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("ignores empty strings — the existing title stays put", () => {
|
|
30
|
+
renderHook(() => useDocumentTitle(""));
|
|
31
|
+
expect(document.title).toBe("Original");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("ignores whitespace-only strings", () => {
|
|
35
|
+
renderHook(() => useDocumentTitle(" \t\n "));
|
|
36
|
+
expect(document.title).toBe("Original");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("trims surrounding whitespace before writing", () => {
|
|
40
|
+
renderHook(() => useDocumentTitle(" Hello "));
|
|
41
|
+
expect(document.title).toBe("Hello");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not restore the previous title on unmount by default", () => {
|
|
45
|
+
const { unmount } = renderHook(() => useDocumentTitle("New"));
|
|
46
|
+
expect(document.title).toBe("New");
|
|
47
|
+
|
|
48
|
+
unmount();
|
|
49
|
+
expect(document.title).toBe("New");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("restores the previous title on unmount when restoreOnUnmount=true", () => {
|
|
53
|
+
const { unmount } = renderHook(() =>
|
|
54
|
+
useDocumentTitle("New", { restoreOnUnmount: true }),
|
|
55
|
+
);
|
|
56
|
+
expect(document.title).toBe("New");
|
|
57
|
+
|
|
58
|
+
unmount();
|
|
59
|
+
expect(document.title).toBe("Original");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("captures the original title at mount, not the latest one", () => {
|
|
63
|
+
document.title = "First";
|
|
64
|
+
const { unmount, rerender } = renderHook(
|
|
65
|
+
({ title }) => useDocumentTitle(title, { restoreOnUnmount: true }),
|
|
66
|
+
{ initialProps: { title: "Step 1" } },
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
rerender({ title: "Step 2" });
|
|
70
|
+
expect(document.title).toBe("Step 2");
|
|
71
|
+
|
|
72
|
+
unmount();
|
|
73
|
+
expect(document.title).toBe("First");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UseDocumentTitleInput {
|
|
4
|
+
/** Restore the previous `document.title` when the component unmounts. Defaults to `false`. */
|
|
5
|
+
restoreOnUnmount?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function useDocumentTitle(
|
|
9
|
+
title: string,
|
|
10
|
+
options: UseDocumentTitleInput = {},
|
|
11
|
+
): void {
|
|
12
|
+
const { restoreOnUnmount = false } = options;
|
|
13
|
+
const restoreRef = useRef(restoreOnUnmount);
|
|
14
|
+
restoreRef.current = restoreOnUnmount;
|
|
15
|
+
|
|
16
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: mount/unmount only
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const previous = document.title;
|
|
19
|
+
return () => {
|
|
20
|
+
if (restoreRef.current) {
|
|
21
|
+
document.title = previous;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (typeof title !== "string") return;
|
|
28
|
+
const trimmed = title.trim();
|
|
29
|
+
if (trimmed.length === 0) return;
|
|
30
|
+
document.title = trimmed;
|
|
31
|
+
}, [title]);
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./use-hover";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useHover } from "./use-hover";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Track whether the pointer is over a specific element.
|
|
6
|
+
*
|
|
7
|
+
* Returns a `ref` callback you attach to the target element and a
|
|
8
|
+
* `hovered` boolean that flips while the pointer is inside its bounds.
|
|
9
|
+
* Powered by `mouseenter` / `mouseleave` — children inside the target
|
|
10
|
+
* do **not** flicker the state on and off.
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* const { ref, hovered } = useHover<HTMLDivElement>();
|
|
14
|
+
*
|
|
15
|
+
* <div ref={ref} className={hovered ? "bg-accent" : ""}>
|
|
16
|
+
* Hover me
|
|
17
|
+
* </div>
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Optional callbacks for imperative side effects (analytics, prefetch,
|
|
21
|
+
* audio) — they receive the native `MouseEvent` and don't force a render:
|
|
22
|
+
*
|
|
23
|
+
* ```tsx
|
|
24
|
+
* useHover<HTMLAnchorElement>({
|
|
25
|
+
* onHoverStart: () => prefetch("/dashboard"),
|
|
26
|
+
* onHoverEnd: (event) => log("left", event.clientX, event.clientY),
|
|
27
|
+
* });
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Good to know:
|
|
31
|
+
* - `ref` is a callback ref (not a `RefObject`). Pass it straight to a
|
|
32
|
+
* DOM element — you can't read `.current` from outside the hook.
|
|
33
|
+
* - Listeners are attached when the ref binds and removed when it
|
|
34
|
+
* detaches (unmount, ref reassignment, conditional rendering). No
|
|
35
|
+
* memory leaks.
|
|
36
|
+
* - The callback identity is read fresh on every event, so passing
|
|
37
|
+
* inline arrow functions is safe — no re-attaching, no stale closure.
|
|
38
|
+
* - Generic param controls the element type:
|
|
39
|
+
* `useHover<HTMLButtonElement>()` types the ref for a `<button>`.
|
|
40
|
+
*/
|
|
41
|
+
const meta: Meta<typeof useHover> = {
|
|
42
|
+
title: "hooks/useHover",
|
|
43
|
+
parameters: { layout: "centered" },
|
|
44
|
+
tags: ["beta"],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export default meta;
|
|
48
|
+
type Story = StoryObj<typeof useHover>;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Move the pointer over the card. Background and label update in
|
|
52
|
+
* real time. Notice the child elements (the icon and the caption) do
|
|
53
|
+
* **not** flicker the state — `mouseenter` ignores child traversals.
|
|
54
|
+
*/
|
|
55
|
+
export const Default: Story = {
|
|
56
|
+
render: () => {
|
|
57
|
+
const { ref, hovered } = useHover<HTMLDivElement>();
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
ref={ref}
|
|
62
|
+
style={{
|
|
63
|
+
width: 280,
|
|
64
|
+
padding: 24,
|
|
65
|
+
borderRadius: 12,
|
|
66
|
+
cursor: "pointer",
|
|
67
|
+
transition: "background-color 150ms, transform 150ms",
|
|
68
|
+
transform: hovered ? "translateY(-2px)" : "translateY(0)",
|
|
69
|
+
}}
|
|
70
|
+
className={
|
|
71
|
+
hovered
|
|
72
|
+
? "border border-primary bg-primary/10 text-primary"
|
|
73
|
+
: "border border-border bg-card"
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
<div className="flex items-center gap-3">
|
|
77
|
+
<span style={{ fontSize: 28 }}>{hovered ? "👋" : "🙂"}</span>
|
|
78
|
+
<div className="flex flex-col">
|
|
79
|
+
<span className="text-sm font-medium">
|
|
80
|
+
{hovered ? "Hovering" : "Hover me"}
|
|
81
|
+
</span>
|
|
82
|
+
<span className="text-xs text-muted-foreground">
|
|
83
|
+
hovered = {String(hovered)}
|
|
84
|
+
</span>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useHover } from "./use-hover";
|
|
4
|
+
|
|
5
|
+
function HoverTarget({
|
|
6
|
+
onHoverStart,
|
|
7
|
+
onHoverEnd,
|
|
8
|
+
}: {
|
|
9
|
+
onHoverStart?: (event: MouseEvent) => void;
|
|
10
|
+
onHoverEnd?: (event: MouseEvent) => void;
|
|
11
|
+
}) {
|
|
12
|
+
const { ref, hovered } = useHover<HTMLDivElement>({
|
|
13
|
+
onHoverStart,
|
|
14
|
+
onHoverEnd,
|
|
15
|
+
});
|
|
16
|
+
return (
|
|
17
|
+
<div ref={ref} data-testid="target">
|
|
18
|
+
{hovered ? "hovered" : "idle"}
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("useHover", () => {
|
|
24
|
+
it("starts in the idle state", () => {
|
|
25
|
+
render(<HoverTarget />);
|
|
26
|
+
expect(screen.getByTestId("target")).toHaveTextContent("idle");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("flips hovered to true on mouseenter and back on mouseleave", () => {
|
|
30
|
+
render(<HoverTarget />);
|
|
31
|
+
const target = screen.getByTestId("target");
|
|
32
|
+
|
|
33
|
+
fireEvent.mouseEnter(target);
|
|
34
|
+
expect(target).toHaveTextContent("hovered");
|
|
35
|
+
|
|
36
|
+
fireEvent.mouseLeave(target);
|
|
37
|
+
expect(target).toHaveTextContent("idle");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("ignores mouseover/mouseout (which bubble from children)", () => {
|
|
41
|
+
render(<HoverTarget />);
|
|
42
|
+
const target = screen.getByTestId("target");
|
|
43
|
+
|
|
44
|
+
fireEvent.mouseOver(target);
|
|
45
|
+
expect(target).toHaveTextContent("idle");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("invokes onHoverStart with the MouseEvent on enter", () => {
|
|
49
|
+
const onHoverStart = vi.fn();
|
|
50
|
+
render(<HoverTarget onHoverStart={onHoverStart} />);
|
|
51
|
+
const target = screen.getByTestId("target");
|
|
52
|
+
|
|
53
|
+
fireEvent.mouseEnter(target);
|
|
54
|
+
|
|
55
|
+
expect(onHoverStart).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(onHoverStart.mock.calls[0][0]).toBeInstanceOf(MouseEvent);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("invokes onHoverEnd with the MouseEvent on leave", () => {
|
|
60
|
+
const onHoverEnd = vi.fn();
|
|
61
|
+
render(<HoverTarget onHoverEnd={onHoverEnd} />);
|
|
62
|
+
const target = screen.getByTestId("target");
|
|
63
|
+
|
|
64
|
+
fireEvent.mouseEnter(target);
|
|
65
|
+
fireEvent.mouseLeave(target);
|
|
66
|
+
|
|
67
|
+
expect(onHoverEnd).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(onHoverEnd.mock.calls[0][0]).toBeInstanceOf(MouseEvent);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("uses the latest callback identity without re-attaching listeners", () => {
|
|
72
|
+
const first = vi.fn();
|
|
73
|
+
const second = vi.fn();
|
|
74
|
+
const { rerender } = render(<HoverTarget onHoverStart={first} />);
|
|
75
|
+
const target = screen.getByTestId("target");
|
|
76
|
+
|
|
77
|
+
rerender(<HoverTarget onHoverStart={second} />);
|
|
78
|
+
fireEvent.mouseEnter(target);
|
|
79
|
+
|
|
80
|
+
expect(first).not.toHaveBeenCalled();
|
|
81
|
+
expect(second).toHaveBeenCalledTimes(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("removes listeners on unmount", () => {
|
|
85
|
+
const { unmount } = render(<HoverTarget />);
|
|
86
|
+
const target = screen.getByTestId("target");
|
|
87
|
+
|
|
88
|
+
fireEvent.mouseEnter(target);
|
|
89
|
+
expect(target).toHaveTextContent("hovered");
|
|
90
|
+
|
|
91
|
+
expect(() => unmount()).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type RefCallback, useCallback, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface UseHoverInput {
|
|
4
|
+
/** Fired when the pointer enters the target element. Receives the native MouseEvent. */
|
|
5
|
+
onHoverStart?: (event: MouseEvent) => void;
|
|
6
|
+
/** Fired when the pointer leaves the target element. Receives the native MouseEvent. */
|
|
7
|
+
onHoverEnd?: (event: MouseEvent) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseHoverReturnValue<T extends HTMLElement = HTMLElement> {
|
|
11
|
+
hovered: boolean;
|
|
12
|
+
ref: RefCallback<T>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useHover<T extends HTMLElement = HTMLElement>(
|
|
16
|
+
options: UseHoverInput = {},
|
|
17
|
+
): UseHoverReturnValue<T> {
|
|
18
|
+
const [hovered, setHovered] = useState(false);
|
|
19
|
+
const callbacksRef = useRef(options);
|
|
20
|
+
callbacksRef.current = options;
|
|
21
|
+
|
|
22
|
+
const ref: RefCallback<T> = useCallback((node) => {
|
|
23
|
+
if (!node) return;
|
|
24
|
+
|
|
25
|
+
const handleEnter = (event: MouseEvent) => {
|
|
26
|
+
setHovered(true);
|
|
27
|
+
callbacksRef.current.onHoverStart?.(event);
|
|
28
|
+
};
|
|
29
|
+
const handleLeave = (event: MouseEvent) => {
|
|
30
|
+
setHovered(false);
|
|
31
|
+
callbacksRef.current.onHoverEnd?.(event);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
node.addEventListener("mouseenter", handleEnter);
|
|
35
|
+
node.addEventListener("mouseleave", handleLeave);
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
node.removeEventListener("mouseenter", handleEnter);
|
|
39
|
+
node.removeEventListener("mouseleave", handleLeave);
|
|
40
|
+
setHovered(false);
|
|
41
|
+
};
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
return { hovered, ref };
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./use-on-mount";
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Button } from "../../components/button";
|
|
4
|
+
import { Stack } from "../../components/stack";
|
|
5
|
+
import { useOnMount } from "./use-on-mount";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run a callback exactly once, when the component mounts.
|
|
9
|
+
*
|
|
10
|
+
* It's basically `useEffect(fn, [])` with two improvements: the callback
|
|
11
|
+
* never re-runs even if React's StrictMode replays effects in dev, and
|
|
12
|
+
* the intent ("on mount") is right there in the name — no `// biome-ignore`
|
|
13
|
+
* for an empty deps array.
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* useOnMount(() => {
|
|
17
|
+
* trackPageView("/dashboard");
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Good to know:
|
|
22
|
+
* - Returning a function from the callback registers a cleanup that runs
|
|
23
|
+
* on unmount — same contract as `useEffect`.
|
|
24
|
+
* - The callback identity is captured on first run. Passing a new function
|
|
25
|
+
* on later renders does **not** re-fire the hook, so you don't need to
|
|
26
|
+
* memoize it.
|
|
27
|
+
* - For "do something on every update except the first one" use a different
|
|
28
|
+
* pattern (a ref-guarded `useEffect` with deps) — this hook is mount-only.
|
|
29
|
+
*/
|
|
30
|
+
const meta: Meta<typeof useOnMount> = {
|
|
31
|
+
title: "hooks/useOnMount",
|
|
32
|
+
parameters: { layout: "centered" },
|
|
33
|
+
tags: ["new"],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export default meta;
|
|
37
|
+
type Story = StoryObj<typeof useOnMount>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The hook stamps a timestamp on the first render and never touches it
|
|
41
|
+
* again. Click "Force re-render" as many times as you want — the
|
|
42
|
+
* "Mounted at" value stays frozen while the render counter ticks up.
|
|
43
|
+
* That's the proof that `useOnMount` fires exactly once.
|
|
44
|
+
*/
|
|
45
|
+
export const Default: Story = {
|
|
46
|
+
render: () => {
|
|
47
|
+
const [mountedAt, setMountedAt] = useState("");
|
|
48
|
+
const [renderCount, setRenderCount] = useState(0);
|
|
49
|
+
|
|
50
|
+
useOnMount(() => {
|
|
51
|
+
setMountedAt(
|
|
52
|
+
new Date().toLocaleTimeString(undefined, {
|
|
53
|
+
hour12: false,
|
|
54
|
+
hour: "2-digit",
|
|
55
|
+
minute: "2-digit",
|
|
56
|
+
second: "2-digit",
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Stack
|
|
63
|
+
direction="vertical"
|
|
64
|
+
gap={16}
|
|
65
|
+
style={{ minWidth: 360, padding: 20, borderRadius: 12 }}
|
|
66
|
+
className="border border-border bg-card"
|
|
67
|
+
>
|
|
68
|
+
<Stack direction="vertical" gap={4}>
|
|
69
|
+
<span className="text-sm">
|
|
70
|
+
<strong>Mounted at:</strong>{" "}
|
|
71
|
+
<code className="text-success">{mountedAt || "..."}</code>
|
|
72
|
+
</span>
|
|
73
|
+
<span className="text-xs text-muted-foreground">
|
|
74
|
+
Re-rendered {renderCount} time{renderCount === 1 ? "" : "s"} —
|
|
75
|
+
timestamp stays the same.
|
|
76
|
+
</span>
|
|
77
|
+
</Stack>
|
|
78
|
+
|
|
79
|
+
<Button onClick={() => setRenderCount((n) => n + 1)}>
|
|
80
|
+
Force re-render
|
|
81
|
+
</Button>
|
|
82
|
+
</Stack>
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
};
|