@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.
- package/lib/DismissibleLayer-12s-Cz1jvGI2.js +23 -0
- package/lib/DismissibleLayer-12s-ZXi0vrlt.cjs +23 -0
- package/lib/index.cjs +13 -0
- package/lib/index.d.ts +112 -0
- package/lib/index.js +3 -0
- package/lib/layer-stack-12s-Ctg3ipjW.cjs +125 -0
- package/lib/layer-stack-12s-DjRVp1_u.js +112 -0
- package/lib/use-escape-keydown-12s-Bl_her-_.js +31 -0
- package/lib/use-escape-keydown-12s-CNZqs1dI.cjs +31 -0
- package/lib/use-focus-outside-12s-Ch-SoiiA.cjs +48 -0
- package/lib/use-focus-outside-12s-LpFt7UjO.js +48 -0
- package/lib/use-pointer-down-outside-12s-BLctGw0m.js +202 -0
- package/lib/use-pointer-down-outside-12s-nlxC-DrD.cjs +202 -0
- package/lib/useDismissibleLayer-12s-C34wvjXv.js +146 -0
- package/lib/useDismissibleLayer-12s-D4qWttkW.cjs +146 -0
- package/package.json +45 -0
- package/src/DismissibleLayer.tsx +25 -0
- package/src/index.ts +17 -0
- package/src/layer-stack.test.ts +291 -0
- package/src/layer-stack.ts +169 -0
- package/src/use-escape-keydown.ts +31 -0
- package/src/use-focus-outside.ts +62 -0
- package/src/use-pointer-down-outside.ts +272 -0
- package/src/useDismissibleLayer.test.tsx +732 -0
- package/src/useDismissibleLayer.ts +234 -0
|
@@ -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
|
+
}
|