@seed-design/react-dismissible-layer 0.0.0-alpha-20260414104312

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.
@@ -0,0 +1,234 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
4
+ import {
5
+ addLayer,
6
+ removeLayer,
7
+ isTopMost,
8
+ getPointerEventsEnabled,
9
+ useLayerStackContext,
10
+ useDismissibleParentNode,
11
+ LAYER_UPDATE_EVENT,
12
+ type CascadeDismissDetail,
13
+ } from "./layer-stack";
14
+ import { useEscapeKeydown } from "./use-escape-keydown";
15
+ import { usePointerDownOutside } from "./use-pointer-down-outside";
16
+ import { useFocusOutside } from "./use-focus-outside";
17
+
18
+ export interface UseDismissibleLayerOptions {
19
+ /**
20
+ * Whether the dismissible layer is active. When false, the layer is not
21
+ * registered in the stack and no event listeners are attached.
22
+ */
23
+ enabled: boolean;
24
+
25
+ /**
26
+ * When true, disables pointer events on elements outside this layer.
27
+ * Used for modal overlays (Dialog, BottomSheet).
28
+ */
29
+ blockPointerEvents?: boolean;
30
+
31
+ /**
32
+ * Called when escape key is pressed while this layer is topmost.
33
+ * Call `event.preventDefault()` to prevent dismiss.
34
+ */
35
+ onEscapeKeyDown: (event: KeyboardEvent) => void;
36
+
37
+ /**
38
+ * Called when a press occurs outside the layer.
39
+ * Call `event.preventDefault()` to signal that the event is handled.
40
+ */
41
+ onPressOutside: (event: PointerEvent | TouchEvent) => void;
42
+
43
+ /**
44
+ * Called when focus moves outside the layer.
45
+ * Call `event.preventDefault()` to signal that the event is handled.
46
+ */
47
+ onFocusOutside: (event: FocusEvent) => void;
48
+
49
+ /**
50
+ * Called when a parent layer is removed and this layer should close as a consequence.
51
+ * Unlike escape/outside/focus callbacks, this is not preventable.
52
+ */
53
+ onCascadeDismiss: (detail: CascadeDismissDetail) => void;
54
+
55
+ /**
56
+ * Custom function to determine if a target should be treated as "inside".
57
+ * Useful for trigger elements that are outside the layer DOM but should
58
+ * not trigger dismiss (e.g., Menu trigger).
59
+ */
60
+ exclude?: (target: HTMLElement) => boolean;
61
+
62
+ /**
63
+ * Determines when an outside press triggers dismiss.
64
+ *
65
+ * - `"confirm"` (default): Both mouse and touch defer to click.
66
+ * - `"eager"`: Mouse on pointerdown, touch defers to click.
67
+ * - `"drag"`: Mouse on pointerdown, touch on drag (>10px immediate, >5px on touchend).
68
+ */
69
+ pressBehavior?: "eager" | "confirm" | "drag";
70
+ }
71
+
72
+ const NOOP = () => {};
73
+
74
+ // Module-level storage for the original body pointer-events value.
75
+ // Follows Radix's pattern: save once when the first blocking layer mounts,
76
+ // restore when the last blocking layer unmounts. This decouples save/restore
77
+ // from React's bottom-up cleanup order.
78
+ let originalBodyPointerEvents: string;
79
+
80
+ export function useDismissibleLayer(options: UseDismissibleLayerOptions) {
81
+ const {
82
+ enabled,
83
+ blockPointerEvents = false,
84
+ onEscapeKeyDown,
85
+ onPressOutside,
86
+ onFocusOutside,
87
+ onCascadeDismiss,
88
+ exclude,
89
+ pressBehavior,
90
+ } = options;
91
+
92
+ const ctx = useLayerStackContext();
93
+ const parentNode = useDismissibleParentNode();
94
+ const [node, setNode] = useState<HTMLElement | null>(null);
95
+ const [, forceRender] = useState({});
96
+
97
+ // Callback ref — fires when element mounts/unmounts.
98
+ // Drives `node` state which triggers registration and event listener setup.
99
+ const dismissibleRef = useCallback((el: HTMLElement | null) => {
100
+ setNode(el);
101
+ }, []);
102
+
103
+ // Stable callback refs
104
+ const onCascadeDismissRef = useRef(onCascadeDismiss);
105
+ onCascadeDismissRef.current = onCascadeDismiss;
106
+
107
+ const onEscapeKeyDownRef = useRef(onEscapeKeyDown);
108
+ onEscapeKeyDownRef.current = onEscapeKeyDown;
109
+
110
+ // -- Layer registration --
111
+ useEffect(() => {
112
+ if (!node || !enabled) return;
113
+
114
+ const layer = {
115
+ node,
116
+ dismiss: (detail: CascadeDismissDetail) => onCascadeDismissRef.current?.(detail),
117
+ blockPointerEvents,
118
+ parentNode,
119
+ };
120
+
121
+ addLayer(ctx, layer);
122
+
123
+ return () => {
124
+ removeLayer(ctx, node);
125
+ };
126
+ }, [node, enabled, blockPointerEvents, ctx, parentNode]);
127
+
128
+ // -- Pointer event blocking --
129
+ // Follows Radix's pattern: save original value once when the first blocking
130
+ // layer mounts, restore when the last unmounts. The module-level variable
131
+ // decouples save/restore from React's bottom-up cleanup order.
132
+ useEffect(() => {
133
+ if (!node || !enabled || !blockPointerEvents) return;
134
+
135
+ const ownerDocument = node.ownerDocument ?? document;
136
+ const blockingLayers = ctx.layers.filter((l) => l.blockPointerEvents);
137
+
138
+ if (blockingLayers.length === 1) {
139
+ // First blocking layer — save the original value and block
140
+ originalBodyPointerEvents = ownerDocument.body.style.pointerEvents;
141
+ ownerDocument.body.style.pointerEvents = "none";
142
+ }
143
+
144
+ return () => {
145
+ // Check count BEFORE this layer is removed from ctx.layers.
146
+ // At cleanup time, this layer is still in the array.
147
+ const remainingBlocking = ctx.layers.filter((l) => l.blockPointerEvents && l.node !== node);
148
+ if (remainingBlocking.length === 0) {
149
+ ownerDocument.body.style.pointerEvents = originalBodyPointerEvents;
150
+ }
151
+ };
152
+ }, [node, enabled, blockPointerEvents, ctx]);
153
+
154
+ // -- Subscribe to layer changes for style updates --
155
+ useEffect(() => {
156
+ if (!enabled) return;
157
+ const handler = () => forceRender({});
158
+ document.addEventListener(LAYER_UPDATE_EVENT, handler);
159
+ return () => document.removeEventListener(LAYER_UPDATE_EVENT, handler);
160
+ }, [enabled]);
161
+
162
+ // -- Escape keydown --
163
+ const handleEscapeKeyDown = useCallback((event: KeyboardEvent) => {
164
+ onEscapeKeyDownRef.current?.(event);
165
+ }, []);
166
+
167
+ useEscapeKeydown(enabled ? node : null, ctx, handleEscapeKeyDown);
168
+
169
+ // -- Press outside --
170
+ const handlePressOutside = useCallback(
171
+ (event: PointerEvent | TouchEvent) => {
172
+ onPressOutside(event);
173
+ },
174
+ [onPressOutside],
175
+ );
176
+
177
+ const pressOutsideProps = usePointerDownOutside(enabled ? node : null, ctx, {
178
+ enabled,
179
+ exclude,
180
+ onPressOutside: handlePressOutside,
181
+ pressBehavior,
182
+ });
183
+
184
+ // -- Focus outside --
185
+ const handleFocusOutside = useCallback(
186
+ (event: FocusEvent) => {
187
+ onFocusOutside(event);
188
+ },
189
+ [onFocusOutside],
190
+ );
191
+
192
+ const focusOutsideProps = useFocusOutside(enabled ? node : null, ctx, {
193
+ enabled,
194
+ exclude,
195
+ onFocusOutside: handleFocusOutside,
196
+ });
197
+
198
+ // -- Compute pointer-events style --
199
+ const hasBlocking = ctx.layers.some((l) => l.blockPointerEvents);
200
+ const pointerEventsEnabled = node ? getPointerEventsEnabled(ctx, node) : true;
201
+
202
+ const style: CSSProperties = hasBlocking
203
+ ? { pointerEvents: pointerEventsEnabled ? "auto" : "none" }
204
+ : {};
205
+
206
+ const topLayer = node ? isTopMost(ctx, node) : true;
207
+
208
+ if (!enabled) {
209
+ return {
210
+ dismissibleRef,
211
+ dismissibleProps: {
212
+ onPointerDownCapture: NOOP,
213
+ onFocusCapture: NOOP,
214
+ onBlurCapture: NOOP,
215
+ },
216
+ isTopLayer: true,
217
+ /** The current layer node, for providing DismissibleParentContext to children. */
218
+ layerNode: node,
219
+ };
220
+ }
221
+
222
+ return {
223
+ dismissibleRef,
224
+ dismissibleProps: {
225
+ onPointerDownCapture: pressOutsideProps.onPointerDownCapture,
226
+ onFocusCapture: focusOutsideProps.onFocusCapture,
227
+ onBlurCapture: focusOutsideProps.onBlurCapture,
228
+ style,
229
+ },
230
+ isTopLayer: topLayer,
231
+ /** The current layer node, for providing DismissibleParentContext to children. */
232
+ layerNode: node,
233
+ };
234
+ }