@boxcustodia/library 2.0.0-alpha.19 → 2.0.0-alpha.20
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/components/button/button.cjs.js +1 -1
- package/dist/components/button/button.es.js +19 -18
- package/dist/components/button/components/base-button.cjs.js +1 -1
- package/dist/components/button/components/base-button.es.js +20 -20
- package/dist/components/calendar/calendar.cjs.js +1 -1
- package/dist/components/calendar/calendar.es.js +1 -0
- package/dist/components/date-picker/date-input.cjs.js +1 -1
- package/dist/components/date-picker/date-input.es.js +92 -75
- package/dist/components/date-picker/date-picker.cjs.js +1 -1
- package/dist/components/date-picker/date-picker.es.js +104 -95
- package/dist/components/date-picker/date-picker.utils.cjs.js +1 -1
- package/dist/components/date-picker/date-picker.utils.es.js +51 -43
- package/dist/components/date-picker/use-hidden-field-value.cjs.js +1 -0
- package/dist/components/date-picker/use-hidden-field-value.es.js +11 -0
- package/dist/components/menu/menu.es.js +1 -9
- package/dist/components/otp/otp.cjs.js +2 -0
- package/dist/components/otp/otp.es.js +93 -0
- package/dist/components/password/password.cjs.js +1 -1
- package/dist/components/password/password.es.js +2 -2
- package/dist/components/select/select.cjs.js +1 -1
- package/dist/components/select/select.es.js +68 -60
- package/dist/hooks/internal/is-apple-device.cjs.js +1 -0
- package/dist/hooks/internal/is-apple-device.es.js +9 -0
- package/dist/hooks/internal/use-latest-ref.cjs.js +1 -0
- package/dist/hooks/internal/use-latest-ref.es.js +11 -0
- package/dist/hooks/use-array/use-array.cjs.js +1 -1
- package/dist/hooks/use-array/use-array.es.js +54 -42
- package/dist/hooks/use-async/use-async.cjs.js +1 -1
- package/dist/hooks/use-async/use-async.es.js +53 -20
- package/dist/hooks/use-boolean/use-boolean.cjs.js +1 -0
- package/dist/hooks/use-boolean/use-boolean.es.js +25 -0
- package/dist/hooks/use-click-outside/use-click-outside.cjs.js +1 -1
- package/dist/hooks/use-click-outside/use-click-outside.es.js +26 -12
- package/dist/hooks/use-debounce-callback/use-debounced-callback.cjs.js +1 -1
- package/dist/hooks/use-debounce-callback/use-debounced-callback.es.js +27 -10
- package/dist/hooks/use-debounce-value/use-debounced-value.cjs.js +1 -1
- package/dist/hooks/use-debounce-value/use-debounced-value.es.js +7 -9
- package/dist/hooks/use-disclosure/use-disclosure.cjs.js +1 -1
- package/dist/hooks/use-disclosure/use-disclosure.es.js +21 -11
- package/dist/hooks/use-document-title/use-document-title.cjs.js +1 -1
- package/dist/hooks/use-document-title/use-document-title.es.js +14 -12
- package/dist/hooks/use-event-listener/use-event-listener.cjs.js +1 -1
- package/dist/hooks/use-event-listener/use-event-listener.es.js +17 -9
- package/dist/hooks/use-hotkey/use-hotkey.cjs.js +1 -1
- package/dist/hooks/use-hotkey/use-hotkey.es.js +30 -14
- package/dist/hooks/use-hotkey/utils/is-input-field.cjs.js +1 -1
- package/dist/hooks/use-hotkey/utils/is-input-field.es.js +4 -2
- package/dist/hooks/use-hotkey/utils/match-and-run.cjs.js +1 -0
- package/dist/hooks/use-hotkey/utils/match-and-run.es.js +12 -0
- package/dist/hooks/use-hotkey/utils/match-key-modifiers.cjs.js +1 -1
- package/dist/hooks/use-hotkey/utils/match-key-modifiers.es.js +13 -12
- package/dist/hooks/use-hover/use-hover.cjs.js +1 -1
- package/dist/hooks/use-hover/use-hover.es.js +32 -17
- package/dist/hooks/use-is-visible/use-is-visible.cjs.js +1 -1
- package/dist/hooks/use-is-visible/use-is-visible.es.js +31 -27
- package/dist/hooks/use-local-storage/use-local-storage.cjs.js +1 -1
- package/dist/hooks/use-local-storage/use-local-storage.es.js +52 -20
- package/dist/hooks/use-media-query/use-media-query.cjs.js +1 -1
- package/dist/hooks/use-media-query/use-media-query.es.js +21 -11
- package/dist/hooks/use-mutation/use-mutation.cjs.js +1 -1
- package/dist/hooks/use-mutation/use-mutation.es.js +36 -22
- package/dist/hooks/use-object/use-object.cjs.js +1 -1
- package/dist/hooks/use-object/use-object.es.js +26 -22
- package/dist/hooks/use-prevent-page-close/use-prevent-page-close.cjs.js +1 -0
- package/dist/hooks/use-prevent-page-close/use-prevent-page-close.es.js +14 -0
- package/dist/hooks/use-step/use-step.cjs.js +1 -1
- package/dist/hooks/use-step/use-step.es.js +25 -24
- package/dist/index.cjs.js +1 -1
- package/dist/index.es.js +308 -300
- package/dist/src/components/date-picker/date-picker.utils.d.ts +17 -0
- package/dist/src/components/date-picker/use-hidden-field-value.d.ts +12 -0
- package/dist/src/components/index.d.ts +1 -0
- package/dist/src/hooks/index.d.ts +2 -2
- package/dist/src/hooks/internal/index.d.ts +2 -0
- package/dist/src/hooks/internal/is-apple-device.d.ts +12 -0
- package/dist/src/hooks/internal/use-latest-ref.d.ts +12 -0
- package/dist/src/hooks/use-array/use-array.d.ts +24 -11
- package/dist/src/hooks/use-async/use-async.d.ts +16 -13
- package/dist/src/hooks/use-boolean/index.d.ts +1 -0
- package/dist/src/hooks/use-boolean/use-boolean.d.ts +15 -0
- package/dist/src/hooks/use-boolean/use-boolean.test.d.ts +1 -0
- package/dist/src/hooks/use-click-outside/use-click-outside.d.ts +23 -1
- package/dist/src/hooks/use-debounce-callback/use-debounced-callback.d.ts +19 -1
- package/dist/src/hooks/use-debounce-value/use-debounced-value.d.ts +10 -1
- package/dist/src/hooks/use-disclosure/use-disclosure.d.ts +17 -8
- package/dist/src/hooks/use-document-title/use-document-title.d.ts +11 -0
- package/dist/src/hooks/use-event-listener/use-event-listener.d.ts +18 -1
- package/dist/src/hooks/use-hotkey/index.d.ts +2 -1
- package/dist/src/hooks/use-hotkey/use-hotkey.d.ts +62 -5
- package/dist/src/hooks/use-hotkey/utils/index.d.ts +4 -3
- package/dist/src/hooks/use-hotkey/utils/is-input-field.d.ts +12 -2
- package/dist/src/hooks/use-hotkey/utils/is-input-field.test.d.ts +1 -0
- package/dist/src/hooks/use-hotkey/utils/match-and-run.d.ts +36 -0
- package/dist/src/hooks/use-hotkey/utils/match-and-run.test.d.ts +1 -0
- package/dist/src/hooks/use-hotkey/utils/match-key-modifiers.d.ts +20 -6
- package/dist/src/hooks/use-hotkey/utils/match-key-modifiers.test.d.ts +1 -0
- package/dist/src/hooks/use-hover/use-hover.d.ts +8 -4
- package/dist/src/hooks/use-is-visible/use-is-visible.d.ts +28 -4
- package/dist/src/hooks/use-local-storage/use-local-storage.d.ts +13 -2
- package/dist/src/hooks/use-media-query/use-media-query.d.ts +10 -1
- package/dist/src/hooks/use-media-query/use-media-query.test.d.ts +1 -0
- package/dist/src/hooks/use-mutation/use-mutation.d.ts +18 -11
- package/dist/src/hooks/use-object/use-object.d.ts +15 -6
- package/dist/src/hooks/use-prevent-page-close/index.d.ts +1 -0
- package/dist/src/hooks/use-prevent-page-close/use-prevent-page-close.d.ts +10 -0
- package/dist/src/hooks/use-prevent-page-close/use-prevent-page-close.test.d.ts +1 -0
- package/dist/src/hooks/use-step/use-step.d.ts +18 -11
- package/dist/src/utils/form.d.ts +10 -0
- package/package.json +1 -1
- package/src/components/alert-dialog/alert-dialog.test.tsx +13 -9
- package/src/components/auto-complete/auto-complete.test.tsx +4 -14
- package/src/components/avatar/avatar.test.tsx +7 -12
- package/src/components/button/button.test.tsx +10 -15
- package/src/components/button/button.tsx +14 -9
- package/src/components/button/components/base-button.tsx +2 -4
- package/src/components/calendar/calendar.test.tsx +12 -19
- package/src/components/calendar/calendar.tsx +4 -0
- package/src/components/card/card.test.tsx +4 -6
- package/src/components/checkbox/checkbox.test.tsx +12 -8
- package/src/components/checkbox-group/checkbox-group.test.tsx +7 -8
- package/src/components/combobox/combobox.test.tsx +24 -21
- package/src/components/date-picker/date-input-form.test.tsx +77 -0
- package/src/components/date-picker/date-input.stories.tsx +30 -18
- package/src/components/date-picker/date-input.tsx +77 -44
- package/src/components/date-picker/date-picker.stories.tsx +31 -1
- package/src/components/date-picker/date-picker.test.tsx +3 -13
- package/src/components/date-picker/date-picker.tsx +35 -16
- package/src/components/date-picker/date-picker.utils.test.ts +32 -14
- package/src/components/date-picker/date-picker.utils.ts +33 -0
- package/src/components/date-picker/use-date-input-popover.test.ts +3 -1
- package/src/components/date-picker/use-hidden-field-value.ts +23 -0
- package/src/components/dialog/dialog.test.tsx +10 -8
- package/src/components/dropzone/dropzone.test.tsx +11 -13
- package/src/components/empty/empty.test.tsx +4 -3
- package/src/components/field/field.test.tsx +12 -13
- package/src/components/form/form.stories.tsx +16 -1
- package/src/components/index.ts +1 -0
- package/src/components/label/label.test.tsx +3 -3
- package/src/components/menu/menu.tsx +1 -5
- package/src/components/number-input/number-input.test.tsx +6 -2
- package/src/components/password/password.test.tsx +20 -6
- package/src/components/password/password.tsx +2 -2
- package/src/components/popover/popover.test.tsx +4 -4
- package/src/components/progress/progress.test.tsx +7 -8
- package/src/components/radio-group/radio-group.test.tsx +17 -11
- package/src/components/select/select.test.tsx +10 -10
- package/src/components/select/select.tsx +9 -1
- package/src/components/stepper/stepper.stories.tsx +11 -15
- package/src/components/stepper/stepper.test.tsx +6 -4
- package/src/components/switch/switch.test.tsx +3 -3
- package/src/components/table/table.test.tsx +9 -3
- package/src/components/tabs/tabs.test.tsx +6 -2
- package/src/components/tag/tag.test.tsx +1 -3
- package/src/components/textarea/textarea.test.tsx +4 -1
- package/src/components/timeline/timeline.test.tsx +10 -5
- package/src/components/toast/toast.test.tsx +11 -14
- package/src/components/tooltip/tooltip.test.tsx +1 -5
- package/src/components/tree/tree.test.tsx +3 -1
- package/src/hooks/index.ts +2 -2
- package/src/hooks/internal/index.ts +2 -0
- package/src/hooks/internal/is-apple-device.test.ts +41 -0
- package/src/hooks/internal/is-apple-device.ts +33 -0
- package/src/hooks/internal/use-isomorphic-layout-effect.ts +3 -1
- package/src/hooks/internal/use-latest-ref.ts +21 -0
- package/src/hooks/use-array/use-array.stories.tsx +435 -64
- package/src/hooks/use-array/use-array.test.tsx +398 -15
- package/src/hooks/use-array/use-array.ts +105 -66
- package/src/hooks/use-async/use-async.stories.tsx +255 -131
- package/src/hooks/use-async/use-async.test.ts +397 -0
- package/src/hooks/use-async/use-async.ts +117 -39
- package/src/hooks/use-boolean/index.ts +1 -0
- package/src/hooks/use-boolean/use-boolean.stories.tsx +377 -0
- package/src/hooks/use-boolean/use-boolean.test.tsx +177 -0
- package/src/hooks/use-boolean/use-boolean.ts +50 -0
- package/src/hooks/use-click-outside/use-click-outside.stories.tsx +188 -18
- package/src/hooks/use-click-outside/use-click-outside.test.tsx +89 -10
- package/src/hooks/use-click-outside/use-click-outside.ts +62 -16
- package/src/hooks/use-debounce-callback/use-debounced-callback.stories.tsx +141 -41
- package/src/hooks/use-debounce-callback/use-debounced-callback.test.ts +217 -9
- package/src/hooks/use-debounce-callback/use-debounced-callback.ts +71 -11
- package/src/hooks/use-debounce-value/use-debounced-value.stories.tsx +247 -47
- package/src/hooks/use-debounce-value/use-debounced-value.test.ts +105 -10
- package/src/hooks/use-debounce-value/use-debounced-value.ts +19 -10
- package/src/hooks/use-disclosure/use-disclosure.stories.tsx +305 -14
- package/src/hooks/use-disclosure/use-disclosure.test.ts +198 -50
- package/src/hooks/use-disclosure/use-disclosure.ts +49 -29
- package/src/hooks/use-document-title/use-document-title.stories.tsx +54 -0
- package/src/hooks/use-document-title/use-document-title.test.tsx +26 -0
- package/src/hooks/use-document-title/{use-document-title.tsx → use-document-title.ts} +17 -3
- package/src/hooks/use-event-listener/use-event-listener.stories.tsx +105 -9
- package/src/hooks/use-event-listener/use-event-listener.test.tsx +77 -10
- package/src/hooks/use-event-listener/use-event-listener.ts +71 -11
- package/src/hooks/use-focus-trap/use-focus-trap.test.ts +31 -6
- package/src/hooks/use-focus-trap/use-focus-trap.ts +3 -2
- package/src/hooks/use-hotkey/index.ts +9 -1
- package/src/hooks/use-hotkey/use-hotkey.stories.tsx +279 -74
- package/src/hooks/use-hotkey/use-hotkey.test.tsx +286 -34
- package/src/hooks/use-hotkey/use-hotkey.ts +141 -17
- package/src/hooks/use-hotkey/utils/index.ts +8 -3
- package/src/hooks/use-hotkey/utils/is-input-field.test.ts +78 -0
- package/src/hooks/use-hotkey/utils/is-input-field.ts +31 -10
- package/src/hooks/use-hotkey/utils/match-and-run.test.ts +203 -0
- package/src/hooks/use-hotkey/utils/match-and-run.ts +62 -0
- package/src/hooks/use-hotkey/utils/match-key-modifiers.test.ts +65 -0
- package/src/hooks/use-hotkey/utils/match-key-modifiers.ts +39 -12
- package/src/hooks/use-hover/use-hover.stories.tsx +258 -80
- package/src/hooks/use-hover/use-hover.test.tsx +266 -26
- package/src/hooks/use-hover/use-hover.tsx +93 -28
- package/src/hooks/use-is-visible/use-is-visible.stories.tsx +193 -46
- package/src/hooks/use-is-visible/use-is-visible.test.tsx +235 -7
- package/src/hooks/use-is-visible/use-is-visible.ts +114 -0
- package/src/hooks/use-local-storage/use-local-storage.stories.tsx +129 -29
- package/src/hooks/use-local-storage/use-local-storage.test.ts +106 -41
- package/src/hooks/use-local-storage/use-local-storage.ts +100 -31
- package/src/hooks/use-media-query/use-media-query.stories.tsx +86 -26
- package/src/hooks/use-media-query/use-media-query.test.ts +132 -0
- package/src/hooks/use-media-query/use-media-query.ts +39 -14
- package/src/hooks/use-memoized-fn/use-memoized-fn.ts +0 -1
- package/src/hooks/use-mutation/use-mutation.stories.tsx +260 -94
- package/src/hooks/use-mutation/use-mutation.test.ts +359 -0
- package/src/hooks/use-mutation/use-mutation.ts +97 -0
- package/src/hooks/use-object/use-object.stories.tsx +310 -79
- package/src/hooks/use-object/use-object.test.tsx +235 -56
- package/src/hooks/use-object/use-object.ts +59 -0
- package/src/hooks/use-pagination/use-pagination.tsx +0 -1
- package/src/hooks/use-prevent-page-close/index.ts +1 -0
- package/src/hooks/use-prevent-page-close/use-prevent-page-close.stories.tsx +39 -0
- package/src/hooks/use-prevent-page-close/use-prevent-page-close.test.ts +89 -0
- package/src/hooks/use-prevent-page-close/use-prevent-page-close.ts +27 -0
- package/src/hooks/use-range-pagination/use-range-pagination.test.tsx +1 -1
- package/src/hooks/use-range-pagination/use-range-pagination.tsx +1 -1
- package/src/hooks/use-selection/use-selection.ts +0 -1
- package/src/hooks/use-step/use-step.stories.tsx +178 -65
- package/src/hooks/use-step/use-step.test.ts +178 -53
- package/src/hooks/use-step/use-step.ts +57 -49
- package/src/utils/form.test.tsx +13 -8
- package/src/utils/form.tsx +10 -0
- package/src/utils/functions/getFormData.test.ts +1 -1
- package/dist/hooks/use-hotkey/utils/create-hotkey-listener.cjs.js +0 -1
- package/dist/hooks/use-hotkey/utils/create-hotkey-listener.es.js +0 -10
- package/dist/hooks/use-prevent-close-window/use-prevent-close-window.cjs.js +0 -1
- package/dist/hooks/use-prevent-close-window/use-prevent-close-window.es.js +0 -15
- package/dist/hooks/use-toggle/use-toggle.cjs.js +0 -1
- package/dist/hooks/use-toggle/use-toggle.es.js +0 -10
- package/dist/src/hooks/use-hotkey/utils/create-hotkey-listener.d.ts +0 -1
- package/dist/src/hooks/use-prevent-close-window/index.d.ts +0 -1
- package/dist/src/hooks/use-prevent-close-window/use-prevent-close-window.d.ts +0 -13
- package/dist/src/hooks/use-toggle/index.d.ts +0 -1
- package/dist/src/hooks/use-toggle/use-toggle.d.ts +0 -3
- package/src/hooks/use-async/use-async.test.tsx +0 -68
- package/src/hooks/use-hotkey/utils/create-hotkey-listener.ts +0 -25
- package/src/hooks/use-is-visible/use-is-visible.tsx +0 -49
- package/src/hooks/use-mutation/use-mutation.test.tsx +0 -83
- package/src/hooks/use-mutation/use-mutation.tsx +0 -59
- package/src/hooks/use-object/use-object.tsx +0 -46
- package/src/hooks/use-prevent-close-window/index.ts +0 -1
- package/src/hooks/use-prevent-close-window/use-prevent-close-window.stories.tsx +0 -32
- package/src/hooks/use-prevent-close-window/use-prevent-close-window.test.ts +0 -79
- package/src/hooks/use-prevent-close-window/use-prevent-close-window.ts +0 -33
- package/src/hooks/use-toggle/index.ts +0 -1
- package/src/hooks/use-toggle/use-toggle.stories.tsx +0 -25
- package/src/hooks/use-toggle/use-toggle.test.tsx +0 -64
- package/src/hooks/use-toggle/use-toggle.ts +0 -14
- /package/dist/src/{hooks/use-prevent-close-window/use-prevent-close-window.test.d.ts → components/date-picker/date-input-form.test.d.ts} +0 -0
- /package/dist/src/hooks/{use-toggle/use-toggle.test.d.ts → internal/is-apple-device.test.d.ts} +0 -0
|
@@ -1,60 +1,207 @@
|
|
|
1
|
-
import { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import {
|
|
3
|
-
import { ReactNode } from "react";
|
|
4
|
-
import { createToastManager, ToastProvider } from "../../components/toast";
|
|
5
|
-
import { useIsVisible } from "../use-is-visible";
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useIsVisible } from "./use-is-visible";
|
|
6
3
|
|
|
7
|
-
|
|
4
|
+
// ─── Demo component ────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
type IsVisibleDemoProps = {
|
|
7
|
+
onVisible?: () => void;
|
|
8
|
+
triggerOnce?: boolean;
|
|
9
|
+
freezeOnceVisible?: boolean;
|
|
10
|
+
threshold?: number;
|
|
11
|
+
rootMargin?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const IsVisibleDemo = ({
|
|
15
|
+
onVisible,
|
|
16
|
+
triggerOnce = false,
|
|
17
|
+
freezeOnceVisible = false,
|
|
18
|
+
threshold = 0,
|
|
19
|
+
rootMargin = "0px",
|
|
20
|
+
}: IsVisibleDemoProps) => {
|
|
21
|
+
const { ref, isVisible, entry } = useIsVisible<HTMLDivElement>({
|
|
22
|
+
onVisible,
|
|
23
|
+
triggerOnce,
|
|
24
|
+
freezeOnceVisible,
|
|
25
|
+
threshold,
|
|
26
|
+
rootMargin,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex flex-col gap-3 max-w-xs">
|
|
31
|
+
<pre className="rounded-md bg-slate-950 p-3 text-xs text-white leading-relaxed">
|
|
32
|
+
{JSON.stringify(
|
|
33
|
+
{
|
|
34
|
+
isVisible,
|
|
35
|
+
"entry.isIntersecting": entry?.isIntersecting ?? null,
|
|
36
|
+
"entry.intersectionRatio": entry
|
|
37
|
+
? +entry.intersectionRatio.toFixed(2)
|
|
38
|
+
: null,
|
|
39
|
+
},
|
|
40
|
+
null,
|
|
41
|
+
2,
|
|
42
|
+
)}
|
|
43
|
+
</pre>
|
|
44
|
+
|
|
45
|
+
<div className="h-48 overflow-y-auto border rounded-md">
|
|
46
|
+
<div className="flex flex-col" style={{ height: 480 }}>
|
|
47
|
+
<div className="flex-1 flex items-start pt-4 justify-center text-xs text-muted-foreground">
|
|
48
|
+
↓ scroll down
|
|
49
|
+
</div>
|
|
50
|
+
<div
|
|
51
|
+
ref={ref}
|
|
52
|
+
className={`mx-2 mb-2 p-4 rounded text-center text-sm font-medium text-white transition-colors duration-500 ${
|
|
53
|
+
isVisible ? "bg-primary" : "bg-muted-foreground/60"
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
{isVisible ? "Visible" : "Not visible"}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ─── Meta ─────────────────────────────────────────────────────────────────────
|
|
8
65
|
|
|
9
66
|
/**
|
|
10
|
-
*
|
|
67
|
+
* `useIsVisible` observes a DOM element with `IntersectionObserver` and reports
|
|
68
|
+
* whether it is intersecting the viewport (or a custom root).
|
|
69
|
+
*
|
|
70
|
+
* **API summary**
|
|
11
71
|
*
|
|
12
|
-
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* const { ref, isVisible, entry } = useIsVisible<T>({
|
|
74
|
+
* onVisible?, // () => void — stable, safe to pass inline
|
|
75
|
+
* triggerOnce?, // boolean — disconnect after first intersection (default false)
|
|
76
|
+
* freezeOnceVisible?, // boolean — never reset isVisible to false (default false)
|
|
77
|
+
* // IntersectionObserverInit options:
|
|
78
|
+
* threshold?, // number | number[] (default 0)
|
|
79
|
+
* rootMargin?, // string (default "0px")
|
|
80
|
+
* root?, // Element | Document | null (default null = viewport)
|
|
81
|
+
* });
|
|
82
|
+
* ```
|
|
83
|
+
*
|
|
84
|
+
* **Return**
|
|
85
|
+
*
|
|
86
|
+
* | Field | Type | Description |
|
|
87
|
+
* |-------|------|-------------|
|
|
88
|
+
* | `ref` | `RefObject<T \| null>` | Attach to the element you want to observe |
|
|
89
|
+
* | `isVisible` | `boolean` | `true` when the element intersects the root |
|
|
90
|
+
* | `entry` | `IntersectionObserverEntry \| null` | Latest raw entry, `null` until first event |
|
|
91
|
+
*
|
|
92
|
+
* **Key behaviors**
|
|
93
|
+
* - `onVisible` is stabilized via `useLatestRef` — inline callbacks never re-attach the observer.
|
|
94
|
+
* - `triggerOnce: true` — observer disconnects after the first positive intersection; `isVisible` latches permanently.
|
|
95
|
+
* - `freezeOnceVisible: true` — `isVisible` never resets; observer stays connected so `entry` keeps updating.
|
|
96
|
+
* - SSR-safe: the effect short-circuits when `IntersectionObserver` is unavailable.
|
|
13
97
|
*/
|
|
14
|
-
const meta: Meta = {
|
|
98
|
+
const meta: Meta<typeof IsVisibleDemo> = {
|
|
15
99
|
title: "hooks/useIsVisible",
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
100
|
+
component: IsVisibleDemo,
|
|
101
|
+
argTypes: {
|
|
102
|
+
onVisible: {
|
|
103
|
+
action: "onVisible",
|
|
104
|
+
description:
|
|
105
|
+
"Called once when the observed element first becomes visible. Stable — safe to pass an inline function without causing observer re-attachment.",
|
|
106
|
+
table: {
|
|
107
|
+
category: "Hook options",
|
|
108
|
+
type: { summary: "() => void" },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
triggerOnce: {
|
|
112
|
+
control: "boolean",
|
|
113
|
+
description:
|
|
114
|
+
"When true, the observer disconnects after the first positive intersection. `isVisible` latches to `true` permanently and never flips back.",
|
|
115
|
+
table: {
|
|
116
|
+
category: "Hook options",
|
|
117
|
+
type: { summary: "boolean" },
|
|
118
|
+
defaultValue: { summary: "false" },
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
freezeOnceVisible: {
|
|
122
|
+
control: "boolean",
|
|
123
|
+
description:
|
|
124
|
+
"When true, `isVisible` never resets to `false` once set. Unlike `triggerOnce`, the observer stays connected and `entry` continues updating on subsequent intersections.",
|
|
125
|
+
table: {
|
|
126
|
+
category: "Hook options",
|
|
127
|
+
type: { summary: "boolean" },
|
|
128
|
+
defaultValue: { summary: "false" },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
threshold: {
|
|
132
|
+
control: { type: "range", min: 0, max: 1, step: 0.05 },
|
|
133
|
+
description:
|
|
134
|
+
"Fraction of the element that must be visible before an intersection fires. `0` = any pixel, `1` = fully visible.",
|
|
135
|
+
table: {
|
|
136
|
+
category: "Hook options",
|
|
137
|
+
type: { summary: "number | number[]" },
|
|
138
|
+
defaultValue: { summary: "0" },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
rootMargin: {
|
|
142
|
+
control: "text",
|
|
143
|
+
description:
|
|
144
|
+
'Margin around the root (viewport by default). Expands or shrinks the detection area. CSS shorthand syntax, e.g. `"50px 0px"`.',
|
|
145
|
+
table: {
|
|
146
|
+
category: "Hook options",
|
|
147
|
+
type: { summary: "string" },
|
|
148
|
+
defaultValue: { summary: '"0px"' },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
args: {
|
|
153
|
+
triggerOnce: false,
|
|
154
|
+
freezeOnceVisible: false,
|
|
155
|
+
threshold: 0,
|
|
156
|
+
rootMargin: "0px",
|
|
157
|
+
},
|
|
158
|
+
render: (args) => <IsVisibleDemo {...args} />,
|
|
23
159
|
};
|
|
24
160
|
|
|
25
161
|
export default meta;
|
|
162
|
+
type Story = StoryObj<typeof IsVisibleDemo>;
|
|
26
163
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
164
|
+
// ─── Stories ──────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Scroll down inside the box to reveal the observed element. `isVisible`
|
|
168
|
+
* toggles as the element enters and leaves the viewport. `entry` updates on
|
|
169
|
+
* every intersection event. `onVisible` fires each time the element becomes
|
|
170
|
+
* visible (visible in the Actions tab).
|
|
171
|
+
*/
|
|
172
|
+
export const Default: Story = {};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* With `triggerOnce: true`, the observer disconnects after the first positive
|
|
176
|
+
* intersection. `isVisible` latches permanently to `true` — scrolling back up
|
|
177
|
+
* does not reset it and `onVisible` never fires a second time.
|
|
178
|
+
*/
|
|
179
|
+
export const TriggerOnce: Story = {
|
|
180
|
+
args: {
|
|
181
|
+
triggerOnce: true,
|
|
182
|
+
},
|
|
31
183
|
};
|
|
32
|
-
export const Default: StoryObj = {
|
|
33
|
-
render: () => {
|
|
34
|
-
const { ref, isVisible } = useIsVisible<HTMLDivElement>({
|
|
35
|
-
onVisible: () =>
|
|
36
|
-
toastManager.add({
|
|
37
|
-
variant: "success",
|
|
38
|
-
description: "El elemento es visible",
|
|
39
|
-
}),
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<>
|
|
44
|
-
<div className="flex items-center gap-x-2">
|
|
45
|
-
<span>Scroll</span>
|
|
46
|
-
<ArrowDown className="animate-bounce" />
|
|
47
|
-
</div>
|
|
48
184
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
185
|
+
/**
|
|
186
|
+
* With `freezeOnceVisible: true`, `isVisible` never returns to `false` once
|
|
187
|
+
* set. The observer stays connected — scroll up and down after the first
|
|
188
|
+
* intersection to see `entry.intersectionRatio` still updating while
|
|
189
|
+
* `isVisible` remains `true`.
|
|
190
|
+
*/
|
|
191
|
+
export const FreezeOnceVisible: Story = {
|
|
192
|
+
args: {
|
|
193
|
+
freezeOnceVisible: true,
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* `threshold: 0.5` requires 50% of the element to be visible before
|
|
199
|
+
* `isVisible` becomes `true`. Adjust the **threshold** slider in controls to
|
|
200
|
+
* shift the intersection boundary. Watch `entry.intersectionRatio` update in
|
|
201
|
+
* real time as you scroll.
|
|
202
|
+
*/
|
|
203
|
+
export const WithThreshold: Story = {
|
|
204
|
+
args: {
|
|
205
|
+
threshold: 0.5,
|
|
59
206
|
},
|
|
60
207
|
};
|
|
@@ -7,14 +7,19 @@ type ObserverCallback = IntersectionObserverCallback;
|
|
|
7
7
|
|
|
8
8
|
let observerCallback: ObserverCallback | null = null;
|
|
9
9
|
let observedEl: Element | null = null;
|
|
10
|
+
let observerInit: IntersectionObserverInit | undefined = undefined;
|
|
11
|
+
let constructorCallCount = 0;
|
|
10
12
|
|
|
11
13
|
class IntersectionObserverMock implements IntersectionObserver {
|
|
12
14
|
readonly root = null;
|
|
13
15
|
readonly rootMargin = "0px";
|
|
14
16
|
readonly thresholds = [0];
|
|
15
17
|
|
|
16
|
-
constructor(cb: ObserverCallback) {
|
|
18
|
+
constructor(cb: ObserverCallback, init?: IntersectionObserverInit) {
|
|
17
19
|
observerCallback = cb;
|
|
20
|
+
observerInit = init;
|
|
21
|
+
constructorCallCount++;
|
|
22
|
+
observedEl = null;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
observe(el: Element) {
|
|
@@ -37,10 +42,30 @@ function makeEntry(isIntersecting: boolean): IntersectionObserverEntry {
|
|
|
37
42
|
|
|
38
43
|
function Fixture({
|
|
39
44
|
onVisible,
|
|
45
|
+
triggerOnce,
|
|
46
|
+
freezeOnceVisible,
|
|
47
|
+
rootMargin,
|
|
48
|
+
threshold,
|
|
49
|
+
onRender,
|
|
40
50
|
}: {
|
|
41
51
|
onVisible?: () => void;
|
|
52
|
+
triggerOnce?: boolean;
|
|
53
|
+
freezeOnceVisible?: boolean;
|
|
54
|
+
rootMargin?: string;
|
|
55
|
+
threshold?: number;
|
|
56
|
+
onRender?: (result: {
|
|
57
|
+
isVisible: boolean;
|
|
58
|
+
entry: IntersectionObserverEntry | null;
|
|
59
|
+
}) => void;
|
|
42
60
|
}) {
|
|
43
|
-
const { ref, isVisible } = useIsVisible<HTMLDivElement>({
|
|
61
|
+
const { ref, isVisible, entry } = useIsVisible<HTMLDivElement>({
|
|
62
|
+
onVisible,
|
|
63
|
+
triggerOnce,
|
|
64
|
+
freezeOnceVisible,
|
|
65
|
+
rootMargin,
|
|
66
|
+
threshold,
|
|
67
|
+
});
|
|
68
|
+
onRender?.({ isVisible, entry });
|
|
44
69
|
return (
|
|
45
70
|
<div ref={ref} data-testid="target">
|
|
46
71
|
{isVisible ? "visible" : "hidden"}
|
|
@@ -49,6 +74,13 @@ function Fixture({
|
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
describe("useIsVisible", () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
constructorCallCount = 0;
|
|
79
|
+
observerInit = undefined;
|
|
80
|
+
observerCallback = null;
|
|
81
|
+
observedEl = null;
|
|
82
|
+
});
|
|
83
|
+
|
|
52
84
|
it("starts as hidden", () => {
|
|
53
85
|
render(<Fixture />);
|
|
54
86
|
expect(screen.getByTestId("target")).toHaveTextContent("hidden");
|
|
@@ -109,10 +141,11 @@ describe("useIsVisible", () => {
|
|
|
109
141
|
expect(observedEl).not.toBeNull();
|
|
110
142
|
});
|
|
111
143
|
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
// T-02: updated to assert disconnect instead of unobserve
|
|
145
|
+
it("calls disconnect when observer options change (cleanup while mounted)", () => {
|
|
146
|
+
const disconnectSpy = vi.spyOn(
|
|
114
147
|
IntersectionObserverMock.prototype,
|
|
115
|
-
"
|
|
148
|
+
"disconnect",
|
|
116
149
|
);
|
|
117
150
|
|
|
118
151
|
function Resettable({ margin }: { margin: string }) {
|
|
@@ -129,7 +162,202 @@ describe("useIsVisible", () => {
|
|
|
129
162
|
const { rerender } = render(<Resettable margin="0px" />);
|
|
130
163
|
rerender(<Resettable margin="10px" />);
|
|
131
164
|
|
|
132
|
-
expect(
|
|
133
|
-
|
|
165
|
+
expect(disconnectSpy).toHaveBeenCalled();
|
|
166
|
+
disconnectSpy.mockRestore();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// T-03: entry exposed and matches received entry
|
|
170
|
+
describe("entry", () => {
|
|
171
|
+
it("starts as null", () => {
|
|
172
|
+
let capturedEntry: IntersectionObserverEntry | null | undefined =
|
|
173
|
+
undefined;
|
|
174
|
+
render(
|
|
175
|
+
<Fixture
|
|
176
|
+
onRender={({ entry }) => {
|
|
177
|
+
capturedEntry = entry;
|
|
178
|
+
}}
|
|
179
|
+
/>,
|
|
180
|
+
);
|
|
181
|
+
expect(capturedEntry).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("reflects the received IntersectionObserverEntry after intersection fires", () => {
|
|
185
|
+
let capturedEntry: IntersectionObserverEntry | null | undefined =
|
|
186
|
+
undefined;
|
|
187
|
+
render(
|
|
188
|
+
<Fixture
|
|
189
|
+
onRender={({ entry }) => {
|
|
190
|
+
capturedEntry = entry;
|
|
191
|
+
}}
|
|
192
|
+
/>,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const entry = makeEntry(true);
|
|
196
|
+
act(() => {
|
|
197
|
+
observerCallback?.([entry], {} as IntersectionObserver);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(capturedEntry).toBe(entry);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("updates entry on negative intersection", () => {
|
|
204
|
+
let capturedEntry: IntersectionObserverEntry | null | undefined =
|
|
205
|
+
undefined;
|
|
206
|
+
render(
|
|
207
|
+
<Fixture
|
|
208
|
+
onRender={({ entry }) => {
|
|
209
|
+
capturedEntry = entry;
|
|
210
|
+
}}
|
|
211
|
+
/>,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
act(() => {
|
|
215
|
+
observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const negEntry = makeEntry(false);
|
|
219
|
+
act(() => {
|
|
220
|
+
observerCallback?.([negEntry], {} as IntersectionObserver);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(capturedEntry).toBe(negEntry);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// T-04: triggerOnce behavior
|
|
228
|
+
describe("triggerOnce", () => {
|
|
229
|
+
it("isVisible stays true after element leaves viewport", () => {
|
|
230
|
+
render(<Fixture triggerOnce={true} />);
|
|
231
|
+
|
|
232
|
+
act(() => {
|
|
233
|
+
observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
|
|
234
|
+
});
|
|
235
|
+
expect(screen.getByTestId("target")).toHaveTextContent("visible");
|
|
236
|
+
|
|
237
|
+
act(() => {
|
|
238
|
+
observerCallback?.([makeEntry(false)], {} as IntersectionObserver);
|
|
239
|
+
});
|
|
240
|
+
expect(screen.getByTestId("target")).toHaveTextContent("visible");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("observer is disconnected after first positive intersection", () => {
|
|
244
|
+
const disconnectSpy = vi.spyOn(
|
|
245
|
+
IntersectionObserverMock.prototype,
|
|
246
|
+
"disconnect",
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
render(<Fixture triggerOnce={true} />);
|
|
250
|
+
|
|
251
|
+
act(() => {
|
|
252
|
+
observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(disconnectSpy).toHaveBeenCalledTimes(1);
|
|
256
|
+
disconnectSpy.mockRestore();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// T-05: freezeOnceVisible behavior
|
|
261
|
+
describe("freezeOnceVisible", () => {
|
|
262
|
+
it("isVisible stays true after element leaves viewport", () => {
|
|
263
|
+
render(<Fixture freezeOnceVisible={true} />);
|
|
264
|
+
|
|
265
|
+
act(() => {
|
|
266
|
+
observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
|
|
267
|
+
});
|
|
268
|
+
expect(screen.getByTestId("target")).toHaveTextContent("visible");
|
|
269
|
+
|
|
270
|
+
act(() => {
|
|
271
|
+
observerCallback?.([makeEntry(false)], {} as IntersectionObserver);
|
|
272
|
+
});
|
|
273
|
+
expect(screen.getByTestId("target")).toHaveTextContent("visible");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// T-06: stable onVisible — no re-attach on re-render
|
|
278
|
+
it("does not re-attach observer when onVisible changes reference", () => {
|
|
279
|
+
constructorCallCount = 0;
|
|
280
|
+
|
|
281
|
+
function StableFixture({ onVisible }: { onVisible: () => void }) {
|
|
282
|
+
const { ref } = useIsVisible<HTMLDivElement>({ onVisible });
|
|
283
|
+
return <div ref={ref} data-testid="stable-target" />;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const { rerender } = render(<StableFixture onVisible={() => {}} />);
|
|
287
|
+
rerender(<StableFixture onVisible={() => {}} />);
|
|
288
|
+
rerender(<StableFixture onVisible={() => {}} />);
|
|
289
|
+
|
|
290
|
+
expect(constructorCallCount).toBe(1);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// T-07: onVisible not called on negative intersection (stable-ref test)
|
|
294
|
+
it("does not call a newly-referenced onVisible on negative intersection", () => {
|
|
295
|
+
const firstMock = vi.fn();
|
|
296
|
+
|
|
297
|
+
function DynamicFixture({ onVisible }: { onVisible: () => void }) {
|
|
298
|
+
const { ref } = useIsVisible<HTMLDivElement>({ onVisible });
|
|
299
|
+
return <div ref={ref} data-testid="dynamic-target" />;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const { rerender } = render(<DynamicFixture onVisible={firstMock} />);
|
|
303
|
+
|
|
304
|
+
act(() => {
|
|
305
|
+
observerCallback?.([makeEntry(true)], {} as IntersectionObserver);
|
|
306
|
+
});
|
|
307
|
+
firstMock.mockClear();
|
|
308
|
+
|
|
309
|
+
const secondMock = vi.fn();
|
|
310
|
+
rerender(<DynamicFixture onVisible={secondMock} />);
|
|
311
|
+
|
|
312
|
+
act(() => {
|
|
313
|
+
observerCallback?.([makeEntry(false)], {} as IntersectionObserver);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
expect(secondMock).not.toHaveBeenCalled();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// T-08: SSR safety
|
|
320
|
+
describe("SSR safety", () => {
|
|
321
|
+
afterEach(() => {
|
|
322
|
+
vi.stubGlobal("IntersectionObserver", IntersectionObserverMock);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("does not throw when IntersectionObserver is undefined (SSR)", () => {
|
|
326
|
+
vi.stubGlobal("IntersectionObserver", undefined);
|
|
327
|
+
|
|
328
|
+
function SSRFixture() {
|
|
329
|
+
const { ref, isVisible } = useIsVisible<HTMLDivElement>();
|
|
330
|
+
return (
|
|
331
|
+
<div ref={ref} data-testid="ssr-target">
|
|
332
|
+
{isVisible ? "visible" : "hidden"}
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
expect(() => render(<SSRFixture />)).not.toThrow();
|
|
338
|
+
expect(screen.getByTestId("ssr-target")).toHaveTextContent("hidden");
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// T-09: IntersectionObserver options forwarded to constructor
|
|
343
|
+
it("forwards root, rootMargin, and threshold to IntersectionObserver constructor", () => {
|
|
344
|
+
render(<Fixture rootMargin="50px" threshold={0.5} />);
|
|
345
|
+
|
|
346
|
+
expect(observerInit?.rootMargin).toBe("50px");
|
|
347
|
+
expect(observerInit?.threshold).toBe(0.5);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// T-10: cleanup on unmount is distinct from options-change cleanup
|
|
351
|
+
it("calls disconnect on unmount (no prior options change)", () => {
|
|
352
|
+
const disconnectSpy = vi.spyOn(
|
|
353
|
+
IntersectionObserverMock.prototype,
|
|
354
|
+
"disconnect",
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const { unmount } = render(<Fixture />);
|
|
358
|
+
unmount();
|
|
359
|
+
|
|
360
|
+
expect(disconnectSpy).toHaveBeenCalledTimes(1);
|
|
361
|
+
disconnectSpy.mockRestore();
|
|
134
362
|
});
|
|
135
363
|
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { isBrowser } from "../internal/is-browser";
|
|
4
|
+
import { useLatestRef } from "../internal/use-latest-ref";
|
|
5
|
+
|
|
6
|
+
export interface UseIsVisibleOptions extends IntersectionObserverInit {
|
|
7
|
+
/**
|
|
8
|
+
* Called once when the element first becomes visible.
|
|
9
|
+
* Stable — safe to pass an inline arrow function without causing observer re-attachment.
|
|
10
|
+
*/
|
|
11
|
+
onVisible?: () => void;
|
|
12
|
+
/**
|
|
13
|
+
* When true, the IntersectionObserver disconnects after the first positive intersection.
|
|
14
|
+
* `isVisible` latches to `true` and never flips back to `false`.
|
|
15
|
+
* The observer lifecycle is torn down early; no further intersection callbacks fire.
|
|
16
|
+
*/
|
|
17
|
+
triggerOnce?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* When true, once `isVisible` becomes `true` it is never reset to `false`.
|
|
20
|
+
* The observer stays connected (unlike `triggerOnce`), so `entry` may continue updating.
|
|
21
|
+
*/
|
|
22
|
+
freezeOnceVisible?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseIsVisibleReturn<T extends HTMLElement> {
|
|
26
|
+
/** Attach to the DOM element you want to observe. */
|
|
27
|
+
ref: RefObject<T | null>;
|
|
28
|
+
/**
|
|
29
|
+
* True when the observed element is intersecting the viewport (or root).
|
|
30
|
+
* Stays true permanently when `triggerOnce` or `freezeOnceVisible` has triggered.
|
|
31
|
+
*/
|
|
32
|
+
isVisible: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* The latest IntersectionObserverEntry received.
|
|
35
|
+
* `null` until the first intersection event fires.
|
|
36
|
+
*/
|
|
37
|
+
entry: IntersectionObserverEntry | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useIsVisible<T extends HTMLElement>(
|
|
41
|
+
options: UseIsVisibleOptions = {},
|
|
42
|
+
): UseIsVisibleReturn<T> {
|
|
43
|
+
const {
|
|
44
|
+
onVisible,
|
|
45
|
+
triggerOnce = false,
|
|
46
|
+
freezeOnceVisible = false,
|
|
47
|
+
root = null,
|
|
48
|
+
rootMargin = "0px",
|
|
49
|
+
threshold = 0,
|
|
50
|
+
} = options;
|
|
51
|
+
|
|
52
|
+
const ref = useRef<T | null>(null);
|
|
53
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
54
|
+
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
|
|
55
|
+
|
|
56
|
+
// AD-2: stable callback ref — inline onVisible never re-attaches the observer
|
|
57
|
+
const onVisibleRef = useLatestRef(onVisible);
|
|
58
|
+
|
|
59
|
+
// AD-3: guards triggerOnce against double-disconnect / late callbacks
|
|
60
|
+
const hasTriggeredRef = useRef(false);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
// AD-4: SSR guard at the top of the effect body
|
|
64
|
+
// Also guard against environments where IntersectionObserver may be undefined
|
|
65
|
+
// even when window is available (e.g. jsdom tests that stub it to undefined).
|
|
66
|
+
if (!isBrowser || typeof IntersectionObserver === "undefined") return;
|
|
67
|
+
|
|
68
|
+
const el = ref.current;
|
|
69
|
+
if (el == null) return;
|
|
70
|
+
|
|
71
|
+
// fresh observer per effect run → reset the trigger latch
|
|
72
|
+
hasTriggeredRef.current = false;
|
|
73
|
+
|
|
74
|
+
const observer = new IntersectionObserver(
|
|
75
|
+
([nextEntry]) => {
|
|
76
|
+
// AD-3: ignore any callback that lands after triggerOnce teardown
|
|
77
|
+
if (hasTriggeredRef.current) return;
|
|
78
|
+
if (!nextEntry) return;
|
|
79
|
+
|
|
80
|
+
const intersecting = nextEntry.isIntersecting;
|
|
81
|
+
|
|
82
|
+
// entry always tracks the latest observed entry
|
|
83
|
+
setEntry(nextEntry);
|
|
84
|
+
|
|
85
|
+
// AD-3 freezeOnceVisible: once visible, never write false again
|
|
86
|
+
setIsVisible((prev) =>
|
|
87
|
+
freezeOnceVisible && prev ? true : intersecting,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (intersecting) {
|
|
91
|
+
// AD-1/AD-2: fire the stable callback synchronously
|
|
92
|
+
onVisibleRef.current?.();
|
|
93
|
+
|
|
94
|
+
// AD-3 triggerOnce: tear down early, latch the guard
|
|
95
|
+
if (triggerOnce) {
|
|
96
|
+
hasTriggeredRef.current = true;
|
|
97
|
+
observer.disconnect();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{ root, rootMargin, threshold },
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
observer.observe(el);
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
// AD-3: skip redundant teardown if triggerOnce already disconnected
|
|
108
|
+
if (hasTriggeredRef.current) return;
|
|
109
|
+
observer.disconnect();
|
|
110
|
+
};
|
|
111
|
+
}, [root, rootMargin, threshold, triggerOnce, freezeOnceVisible]);
|
|
112
|
+
|
|
113
|
+
return { ref, isVisible, entry };
|
|
114
|
+
}
|