@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,146 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
var react = require('react');
|
|
3
|
+
var layerStack12s = require('./layer-stack-12s-Ctg3ipjW.cjs');
|
|
4
|
+
var useEscapeKeydown12s = require('./use-escape-keydown-12s-CNZqs1dI.cjs');
|
|
5
|
+
var usePointerDownOutside12s = require('./use-pointer-down-outside-12s-nlxC-DrD.cjs');
|
|
6
|
+
var useFocusOutside12s = require('./use-focus-outside-12s-Ch-SoiiA.cjs');
|
|
7
|
+
|
|
8
|
+
const NOOP = ()=>{};
|
|
9
|
+
// Module-level storage for the original body pointer-events value.
|
|
10
|
+
// Follows Radix's pattern: save once when the first blocking layer mounts,
|
|
11
|
+
// restore when the last blocking layer unmounts. This decouples save/restore
|
|
12
|
+
// from React's bottom-up cleanup order.
|
|
13
|
+
let originalBodyPointerEvents;
|
|
14
|
+
function useDismissibleLayer(options) {
|
|
15
|
+
const { enabled, blockPointerEvents = false, onEscapeKeyDown, onPressOutside, onFocusOutside, onCascadeDismiss, exclude, pressBehavior } = options;
|
|
16
|
+
const ctx = layerStack12s.useLayerStackContext();
|
|
17
|
+
const parentNode = layerStack12s.useDismissibleParentNode();
|
|
18
|
+
const [node, setNode] = react.useState(null);
|
|
19
|
+
const [, forceRender] = react.useState({});
|
|
20
|
+
// Callback ref — fires when element mounts/unmounts.
|
|
21
|
+
// Drives `node` state which triggers registration and event listener setup.
|
|
22
|
+
const dismissibleRef = react.useCallback((el)=>{
|
|
23
|
+
setNode(el);
|
|
24
|
+
}, []);
|
|
25
|
+
// Stable callback refs
|
|
26
|
+
const onCascadeDismissRef = react.useRef(onCascadeDismiss);
|
|
27
|
+
onCascadeDismissRef.current = onCascadeDismiss;
|
|
28
|
+
const onEscapeKeyDownRef = react.useRef(onEscapeKeyDown);
|
|
29
|
+
onEscapeKeyDownRef.current = onEscapeKeyDown;
|
|
30
|
+
// -- Layer registration --
|
|
31
|
+
react.useEffect(()=>{
|
|
32
|
+
if (!node || !enabled) return;
|
|
33
|
+
const layer = {
|
|
34
|
+
node,
|
|
35
|
+
dismiss: (detail)=>onCascadeDismissRef.current?.(detail),
|
|
36
|
+
blockPointerEvents,
|
|
37
|
+
parentNode
|
|
38
|
+
};
|
|
39
|
+
layerStack12s.addLayer(ctx, layer);
|
|
40
|
+
return ()=>{
|
|
41
|
+
layerStack12s.removeLayer(ctx, node);
|
|
42
|
+
};
|
|
43
|
+
}, [
|
|
44
|
+
node,
|
|
45
|
+
enabled,
|
|
46
|
+
blockPointerEvents,
|
|
47
|
+
ctx,
|
|
48
|
+
parentNode
|
|
49
|
+
]);
|
|
50
|
+
// -- Pointer event blocking --
|
|
51
|
+
// Follows Radix's pattern: save original value once when the first blocking
|
|
52
|
+
// layer mounts, restore when the last unmounts. The module-level variable
|
|
53
|
+
// decouples save/restore from React's bottom-up cleanup order.
|
|
54
|
+
react.useEffect(()=>{
|
|
55
|
+
if (!node || !enabled || !blockPointerEvents) return;
|
|
56
|
+
const ownerDocument = node.ownerDocument ?? document;
|
|
57
|
+
const blockingLayers = ctx.layers.filter((l)=>l.blockPointerEvents);
|
|
58
|
+
if (blockingLayers.length === 1) {
|
|
59
|
+
// First blocking layer — save the original value and block
|
|
60
|
+
originalBodyPointerEvents = ownerDocument.body.style.pointerEvents;
|
|
61
|
+
ownerDocument.body.style.pointerEvents = "none";
|
|
62
|
+
}
|
|
63
|
+
return ()=>{
|
|
64
|
+
// Check count BEFORE this layer is removed from ctx.layers.
|
|
65
|
+
// At cleanup time, this layer is still in the array.
|
|
66
|
+
const remainingBlocking = ctx.layers.filter((l)=>l.blockPointerEvents && l.node !== node);
|
|
67
|
+
if (remainingBlocking.length === 0) {
|
|
68
|
+
ownerDocument.body.style.pointerEvents = originalBodyPointerEvents;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}, [
|
|
72
|
+
node,
|
|
73
|
+
enabled,
|
|
74
|
+
blockPointerEvents,
|
|
75
|
+
ctx
|
|
76
|
+
]);
|
|
77
|
+
// -- Subscribe to layer changes for style updates --
|
|
78
|
+
react.useEffect(()=>{
|
|
79
|
+
if (!enabled) return;
|
|
80
|
+
const handler = ()=>forceRender({});
|
|
81
|
+
document.addEventListener(layerStack12s.LAYER_UPDATE_EVENT, handler);
|
|
82
|
+
return ()=>document.removeEventListener(layerStack12s.LAYER_UPDATE_EVENT, handler);
|
|
83
|
+
}, [
|
|
84
|
+
enabled
|
|
85
|
+
]);
|
|
86
|
+
// -- Escape keydown --
|
|
87
|
+
const handleEscapeKeyDown = react.useCallback((event)=>{
|
|
88
|
+
onEscapeKeyDownRef.current?.(event);
|
|
89
|
+
}, []);
|
|
90
|
+
useEscapeKeydown12s.useEscapeKeydown(enabled ? node : null, ctx, handleEscapeKeyDown);
|
|
91
|
+
// -- Press outside --
|
|
92
|
+
const handlePressOutside = react.useCallback((event)=>{
|
|
93
|
+
onPressOutside(event);
|
|
94
|
+
}, [
|
|
95
|
+
onPressOutside
|
|
96
|
+
]);
|
|
97
|
+
const pressOutsideProps = usePointerDownOutside12s.usePointerDownOutside(enabled ? node : null, ctx, {
|
|
98
|
+
enabled,
|
|
99
|
+
exclude,
|
|
100
|
+
onPressOutside: handlePressOutside,
|
|
101
|
+
pressBehavior
|
|
102
|
+
});
|
|
103
|
+
// -- Focus outside --
|
|
104
|
+
const handleFocusOutside = react.useCallback((event)=>{
|
|
105
|
+
onFocusOutside(event);
|
|
106
|
+
}, [
|
|
107
|
+
onFocusOutside
|
|
108
|
+
]);
|
|
109
|
+
const focusOutsideProps = useFocusOutside12s.useFocusOutside(enabled ? node : null, ctx, {
|
|
110
|
+
enabled,
|
|
111
|
+
exclude,
|
|
112
|
+
onFocusOutside: handleFocusOutside
|
|
113
|
+
});
|
|
114
|
+
// -- Compute pointer-events style --
|
|
115
|
+
const hasBlocking = ctx.layers.some((l)=>l.blockPointerEvents);
|
|
116
|
+
const pointerEventsEnabled = node ? layerStack12s.getPointerEventsEnabled(ctx, node) : true;
|
|
117
|
+
const style = hasBlocking ? {
|
|
118
|
+
pointerEvents: pointerEventsEnabled ? "auto" : "none"
|
|
119
|
+
} : {};
|
|
120
|
+
const topLayer = node ? layerStack12s.isTopMost(ctx, node) : true;
|
|
121
|
+
if (!enabled) {
|
|
122
|
+
return {
|
|
123
|
+
dismissibleRef,
|
|
124
|
+
dismissibleProps: {
|
|
125
|
+
onPointerDownCapture: NOOP,
|
|
126
|
+
onFocusCapture: NOOP,
|
|
127
|
+
onBlurCapture: NOOP
|
|
128
|
+
},
|
|
129
|
+
isTopLayer: true,
|
|
130
|
+
/** The current layer node, for providing DismissibleParentContext to children. */ layerNode: node
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
dismissibleRef,
|
|
135
|
+
dismissibleProps: {
|
|
136
|
+
onPointerDownCapture: pressOutsideProps.onPointerDownCapture,
|
|
137
|
+
onFocusCapture: focusOutsideProps.onFocusCapture,
|
|
138
|
+
onBlurCapture: focusOutsideProps.onBlurCapture,
|
|
139
|
+
style
|
|
140
|
+
},
|
|
141
|
+
isTopLayer: topLayer,
|
|
142
|
+
/** The current layer node, for providing DismissibleParentContext to children. */ layerNode: node
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
exports.useDismissibleLayer = useDismissibleLayer;
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seed-design/react-dismissible-layer",
|
|
3
|
+
"version": "0.0.0-alpha-20260414104312",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/daangn/seed-design.git",
|
|
7
|
+
"directory": "packages/react-headless/dismissible-layer"
|
|
8
|
+
},
|
|
9
|
+
"main": "./lib/index.cjs",
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/react": "^19.1.6",
|
|
12
|
+
"react": "^19.1.0",
|
|
13
|
+
"react-dom": "^19.1.0"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"react": ">=18.0.0",
|
|
17
|
+
"react-dom": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./lib/index.d.ts",
|
|
22
|
+
"import": "./lib/index.js",
|
|
23
|
+
"require": "./lib/index.cjs"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib",
|
|
29
|
+
"src"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"clean": "rm -rf lib",
|
|
36
|
+
"build": "bunchee",
|
|
37
|
+
"lint:publish": "bun publint"
|
|
38
|
+
},
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"type": "module",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@radix-ui/react-compose-refs": "^1.1.2",
|
|
43
|
+
"@radix-ui/react-slot": "^1.2.4"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { composeRefs } from "@radix-ui/react-compose-refs";
|
|
4
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
5
|
+
import { forwardRef } from "react";
|
|
6
|
+
import { useDismissibleLayer, type UseDismissibleLayerOptions } from "./useDismissibleLayer";
|
|
7
|
+
import { DismissibleParentContext } from "./layer-stack";
|
|
8
|
+
|
|
9
|
+
export interface DismissibleLayerProps extends UseDismissibleLayerOptions {
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DismissibleLayer = forwardRef<HTMLElement, DismissibleLayerProps>((props, ref) => {
|
|
14
|
+
const { children, ...options } = props;
|
|
15
|
+
const { dismissibleRef, dismissibleProps, layerNode } = useDismissibleLayer(options);
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<DismissibleParentContext.Provider value={layerNode}>
|
|
19
|
+
<Slot ref={composeRefs(ref, dismissibleRef)} {...dismissibleProps}>
|
|
20
|
+
{children}
|
|
21
|
+
</Slot>
|
|
22
|
+
</DismissibleParentContext.Provider>
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
DismissibleLayer.displayName = "DismissibleLayer";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
useDismissibleLayer,
|
|
3
|
+
type UseDismissibleLayerOptions,
|
|
4
|
+
} from "./useDismissibleLayer";
|
|
5
|
+
|
|
6
|
+
export { DismissibleLayer, type DismissibleLayerProps } from "./DismissibleLayer";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
LayerStackContext,
|
|
10
|
+
useLayerStackContext,
|
|
11
|
+
isTopMost,
|
|
12
|
+
addBranch,
|
|
13
|
+
removeBranch,
|
|
14
|
+
type Layer,
|
|
15
|
+
type CascadeDismissDetail,
|
|
16
|
+
type LayerStackContextValue,
|
|
17
|
+
} from "./layer-stack";
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { describe, expect, it, mock, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
addLayer,
|
|
4
|
+
removeLayer,
|
|
5
|
+
addBranch,
|
|
6
|
+
removeBranch,
|
|
7
|
+
isTopMost,
|
|
8
|
+
isInNestedLayer,
|
|
9
|
+
isInBranch,
|
|
10
|
+
isBelowPointerBlockingLayer,
|
|
11
|
+
getPointerEventsEnabled,
|
|
12
|
+
type LayerStackContextValue,
|
|
13
|
+
type Layer,
|
|
14
|
+
} from "./layer-stack";
|
|
15
|
+
|
|
16
|
+
function createCtx(): LayerStackContextValue {
|
|
17
|
+
return { layers: [], branches: [], recentlyRemoved: new Set() };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createNode(id = "node"): HTMLElement {
|
|
21
|
+
const el = document.createElement("div");
|
|
22
|
+
el.setAttribute("data-testid", id);
|
|
23
|
+
document.body.appendChild(el);
|
|
24
|
+
return el;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createLayer(node: HTMLElement, overrides: Partial<Omit<Layer, "node">> = {}): Layer {
|
|
28
|
+
return {
|
|
29
|
+
node,
|
|
30
|
+
dismiss: mock(() => {}),
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("layer-stack", () => {
|
|
36
|
+
let ctx: LayerStackContextValue;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
ctx = createCtx();
|
|
40
|
+
document.body.innerHTML = "";
|
|
41
|
+
});
|
|
42
|
+
describe("addLayer", () => {
|
|
43
|
+
it("adds a layer to the stack", () => {
|
|
44
|
+
const node = createNode();
|
|
45
|
+
addLayer(ctx, createLayer(node));
|
|
46
|
+
expect(ctx.layers).toHaveLength(1);
|
|
47
|
+
expect(ctx.layers[0].node).toBe(node);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("preserves insertion order", () => {
|
|
51
|
+
const a = createNode("a");
|
|
52
|
+
const b = createNode("b");
|
|
53
|
+
addLayer(ctx, createLayer(a));
|
|
54
|
+
addLayer(ctx, createLayer(b));
|
|
55
|
+
expect(ctx.layers[0].node).toBe(a);
|
|
56
|
+
expect(ctx.layers[1].node).toBe(b);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("replaces existing layer when same node is added twice", () => {
|
|
60
|
+
const node = createNode();
|
|
61
|
+
const first = createLayer(node, { blockPointerEvents: false });
|
|
62
|
+
const second = createLayer(node, { blockPointerEvents: true });
|
|
63
|
+
addLayer(ctx, first);
|
|
64
|
+
addLayer(ctx, second);
|
|
65
|
+
expect(ctx.layers).toHaveLength(1);
|
|
66
|
+
expect(ctx.layers[0].blockPointerEvents).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("removeLayer", () => {
|
|
71
|
+
it("removes the specified layer", () => {
|
|
72
|
+
const node = createNode();
|
|
73
|
+
addLayer(ctx, createLayer(node));
|
|
74
|
+
removeLayer(ctx, node);
|
|
75
|
+
expect(ctx.layers).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does nothing for unknown nodes", () => {
|
|
79
|
+
const node = createNode();
|
|
80
|
+
removeLayer(ctx, node);
|
|
81
|
+
expect(ctx.layers).toHaveLength(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not cascade-dismiss sibling layers (no parentNode)", () => {
|
|
85
|
+
const a = createNode("a");
|
|
86
|
+
const b = createNode("b");
|
|
87
|
+
const c = createNode("c");
|
|
88
|
+
const dismissB = mock(() => {});
|
|
89
|
+
const dismissC = mock(() => {});
|
|
90
|
+
|
|
91
|
+
addLayer(ctx, createLayer(a));
|
|
92
|
+
addLayer(ctx, createLayer(b, { dismiss: dismissB }));
|
|
93
|
+
addLayer(ctx, createLayer(c, { dismiss: dismissC }));
|
|
94
|
+
|
|
95
|
+
removeLayer(ctx, a);
|
|
96
|
+
|
|
97
|
+
expect(dismissB).not.toHaveBeenCalled();
|
|
98
|
+
expect(dismissC).not.toHaveBeenCalled();
|
|
99
|
+
expect(ctx.layers).toHaveLength(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("cascade-dismisses child layers (matching parentNode)", () => {
|
|
103
|
+
const parent = createNode("parent");
|
|
104
|
+
const child = createNode("child");
|
|
105
|
+
const grandchild = createNode("grandchild");
|
|
106
|
+
const dismissChild = mock(() => {});
|
|
107
|
+
const dismissGrandchild = mock(() => {});
|
|
108
|
+
|
|
109
|
+
addLayer(ctx, createLayer(parent));
|
|
110
|
+
addLayer(ctx, createLayer(child, { dismiss: dismissChild, parentNode: parent }));
|
|
111
|
+
addLayer(ctx, createLayer(grandchild, { dismiss: dismissGrandchild, parentNode: child }));
|
|
112
|
+
|
|
113
|
+
removeLayer(ctx, parent);
|
|
114
|
+
|
|
115
|
+
// Only direct children are dismissed; grandchild is dismissed
|
|
116
|
+
// transitively when the child's own removeLayer runs.
|
|
117
|
+
expect(dismissChild).toHaveBeenCalledTimes(1);
|
|
118
|
+
expect(dismissGrandchild).not.toHaveBeenCalled();
|
|
119
|
+
expect(ctx.layers).toHaveLength(2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("does not cascade-dismiss layers with a different parent", () => {
|
|
123
|
+
const dialog = createNode("dialog");
|
|
124
|
+
const menu = createNode("menu");
|
|
125
|
+
const siblingMenu = createNode("sibling-menu");
|
|
126
|
+
const dismissMenu = mock(() => {});
|
|
127
|
+
const dismissSibling = mock(() => {});
|
|
128
|
+
|
|
129
|
+
addLayer(ctx, createLayer(dialog));
|
|
130
|
+
addLayer(ctx, createLayer(menu, { dismiss: dismissMenu, parentNode: dialog }));
|
|
131
|
+
addLayer(ctx, createLayer(siblingMenu, { dismiss: dismissSibling }));
|
|
132
|
+
|
|
133
|
+
removeLayer(ctx, dialog);
|
|
134
|
+
|
|
135
|
+
expect(dismissMenu).toHaveBeenCalledTimes(1);
|
|
136
|
+
expect(dismissSibling).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("correctly removes parent when child dismiss triggers recursive removeLayer", () => {
|
|
140
|
+
const child = createNode("child");
|
|
141
|
+
const parent = createNode("parent");
|
|
142
|
+
const sibling = createNode("sibling");
|
|
143
|
+
|
|
144
|
+
// child is inserted BEFORE parent — possible in pure data structure usage.
|
|
145
|
+
// child.dismiss recursively calls removeLayer, shifting parent's index.
|
|
146
|
+
addLayer(
|
|
147
|
+
ctx,
|
|
148
|
+
createLayer(child, {
|
|
149
|
+
dismiss: () => removeLayer(ctx, child),
|
|
150
|
+
parentNode: parent,
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
addLayer(ctx, createLayer(parent));
|
|
154
|
+
addLayer(ctx, createLayer(sibling));
|
|
155
|
+
|
|
156
|
+
// Before: [child, parent, sibling]. parent is at index 1.
|
|
157
|
+
// removeLayer(parent) → cascade-dismisses child → child.dismiss() → removeLayer(child)
|
|
158
|
+
// After child removal: [parent, sibling]. parent shifts to index 0.
|
|
159
|
+
// The pre-computed index (1) is now stale — must re-compute.
|
|
160
|
+
removeLayer(ctx, parent);
|
|
161
|
+
|
|
162
|
+
// Only sibling should remain
|
|
163
|
+
expect(ctx.layers).toHaveLength(1);
|
|
164
|
+
expect(ctx.layers[0].node).toBe(sibling);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("adds node to recentlyRemoved", () => {
|
|
168
|
+
const node = createNode();
|
|
169
|
+
addLayer(ctx, createLayer(node));
|
|
170
|
+
removeLayer(ctx, node);
|
|
171
|
+
expect(ctx.recentlyRemoved.has(node)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("clears recentlyRemoved after microtask", async () => {
|
|
175
|
+
const node = createNode();
|
|
176
|
+
addLayer(ctx, createLayer(node));
|
|
177
|
+
removeLayer(ctx, node);
|
|
178
|
+
expect(ctx.recentlyRemoved.has(node)).toBe(true);
|
|
179
|
+
await new Promise((r) => queueMicrotask(r));
|
|
180
|
+
expect(ctx.recentlyRemoved.has(node)).toBe(false);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
describe("isTopMost", () => {
|
|
184
|
+
it("returns true for the last layer", () => {
|
|
185
|
+
const a = createNode("a");
|
|
186
|
+
const b = createNode("b");
|
|
187
|
+
addLayer(ctx, createLayer(a));
|
|
188
|
+
addLayer(ctx, createLayer(b));
|
|
189
|
+
expect(isTopMost(ctx, b)).toBe(true);
|
|
190
|
+
expect(isTopMost(ctx, a)).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("returns false for empty stack", () => {
|
|
194
|
+
const node = createNode();
|
|
195
|
+
expect(isTopMost(ctx, node)).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("isInNestedLayer", () => {
|
|
200
|
+
it("returns true when target is inside a nested layer", () => {
|
|
201
|
+
const dialog = createNode("dialog");
|
|
202
|
+
const menu = createNode("menu");
|
|
203
|
+
const menuChild = document.createElement("span");
|
|
204
|
+
menu.appendChild(menuChild);
|
|
205
|
+
|
|
206
|
+
addLayer(ctx, createLayer(dialog));
|
|
207
|
+
addLayer(ctx, createLayer(menu, {}));
|
|
208
|
+
|
|
209
|
+
expect(isInNestedLayer(ctx, dialog, menuChild)).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("returns false when target is not in any nested layer", () => {
|
|
213
|
+
const dialog = createNode("dialog");
|
|
214
|
+
const outside = createNode("outside");
|
|
215
|
+
|
|
216
|
+
addLayer(ctx, createLayer(dialog));
|
|
217
|
+
|
|
218
|
+
expect(isInNestedLayer(ctx, dialog, outside)).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("returns true when recentlyRemoved is non-empty (cascading prevention)", () => {
|
|
222
|
+
const dialog = createNode("dialog");
|
|
223
|
+
const menu = createNode("menu");
|
|
224
|
+
|
|
225
|
+
addLayer(ctx, createLayer(dialog));
|
|
226
|
+
addLayer(ctx, createLayer(menu, {}));
|
|
227
|
+
|
|
228
|
+
// Simulate removal — recentlyRemoved is populated
|
|
229
|
+
removeLayer(ctx, menu);
|
|
230
|
+
|
|
231
|
+
// Even though menu is removed, target is treated as "inside"
|
|
232
|
+
expect(isInNestedLayer(ctx, dialog, document.body)).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
describe("branches", () => {
|
|
236
|
+
it("isInBranch returns true for targets inside a branch", () => {
|
|
237
|
+
const branch = createNode("branch");
|
|
238
|
+
const child = document.createElement("span");
|
|
239
|
+
branch.appendChild(child);
|
|
240
|
+
|
|
241
|
+
addBranch(ctx, branch);
|
|
242
|
+
expect(isInBranch(ctx, child)).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("isInBranch returns false for targets outside branches", () => {
|
|
246
|
+
const branch = createNode("branch");
|
|
247
|
+
const outside = createNode("outside");
|
|
248
|
+
|
|
249
|
+
addBranch(ctx, branch);
|
|
250
|
+
expect(isInBranch(ctx, outside)).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("removeBranch removes the branch", () => {
|
|
254
|
+
const branch = createNode("branch");
|
|
255
|
+
addBranch(ctx, branch);
|
|
256
|
+
removeBranch(ctx, branch);
|
|
257
|
+
expect(ctx.branches).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
describe("pointer blocking", () => {
|
|
261
|
+
it("isBelowPointerBlockingLayer returns false when no blocking layers", () => {
|
|
262
|
+
const node = createNode();
|
|
263
|
+
addLayer(ctx, createLayer(node));
|
|
264
|
+
expect(isBelowPointerBlockingLayer(ctx, node)).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("isBelowPointerBlockingLayer returns true when below a blocking layer", () => {
|
|
268
|
+
const dialog = createNode("dialog");
|
|
269
|
+
const menu = createNode("menu");
|
|
270
|
+
addLayer(ctx, createLayer(dialog, { blockPointerEvents: true }));
|
|
271
|
+
addLayer(ctx, createLayer(menu, { blockPointerEvents: true }));
|
|
272
|
+
expect(isBelowPointerBlockingLayer(ctx, dialog)).toBe(true);
|
|
273
|
+
expect(isBelowPointerBlockingLayer(ctx, menu)).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("getPointerEventsEnabled returns true when no blocking layers", () => {
|
|
277
|
+
const node = createNode();
|
|
278
|
+
addLayer(ctx, createLayer(node));
|
|
279
|
+
expect(getPointerEventsEnabled(ctx, node)).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("getPointerEventsEnabled returns false for layers below the highest blocking layer", () => {
|
|
283
|
+
const dialog = createNode("dialog");
|
|
284
|
+
const menu = createNode("menu");
|
|
285
|
+
addLayer(ctx, createLayer(dialog, { blockPointerEvents: true }));
|
|
286
|
+
addLayer(ctx, createLayer(menu, { blockPointerEvents: true }));
|
|
287
|
+
expect(getPointerEventsEnabled(ctx, dialog)).toBe(false);
|
|
288
|
+
expect(getPointerEventsEnabled(ctx, menu)).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
export interface CascadeDismissDetail {
|
|
6
|
+
/** The layer node that was removed, causing this cascade dismiss. */
|
|
7
|
+
dismissedParent: HTMLElement;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Layer {
|
|
11
|
+
node: HTMLElement;
|
|
12
|
+
dismiss: (detail: CascadeDismissDetail) => void;
|
|
13
|
+
blockPointerEvents?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* The parent layer's node. Used to determine cascade-dismiss scope.
|
|
16
|
+
* Only layers whose parentNode matches the removed layer's node will be
|
|
17
|
+
* cascade-dismissed. Layers without a parentNode are top-level and never
|
|
18
|
+
* cascade-dismissed by sibling layer removal.
|
|
19
|
+
*/
|
|
20
|
+
parentNode?: HTMLElement | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LayerStackContextValue {
|
|
24
|
+
layers: Layer[];
|
|
25
|
+
branches: HTMLElement[];
|
|
26
|
+
recentlyRemoved: Set<HTMLElement>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const LAYER_UPDATE_EVENT = "seed:dismissible-update";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Shared layer stack context. No Provider needed — all consumers share the same
|
|
33
|
+
* default value (mutable arrays/sets). This mirrors Radix's DismissibleLayerContext
|
|
34
|
+
* pattern, with the option to scope via Provider if needed.
|
|
35
|
+
*/
|
|
36
|
+
export const LayerStackContext = createContext<LayerStackContextValue>({
|
|
37
|
+
layers: [],
|
|
38
|
+
branches: [],
|
|
39
|
+
recentlyRemoved: new Set(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export function useLayerStackContext() {
|
|
43
|
+
return useContext(LayerStackContext);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isTopMost(ctx: LayerStackContextValue, node: HTMLElement): boolean {
|
|
47
|
+
return ctx.layers.at(-1)?.node === node;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function contains(parent: HTMLElement | null, child: EventTarget | null): boolean {
|
|
51
|
+
if (!parent || !child) return false;
|
|
52
|
+
if (!(child instanceof Node)) return false;
|
|
53
|
+
return parent.contains(child);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isInNestedLayer(
|
|
57
|
+
ctx: LayerStackContextValue,
|
|
58
|
+
node: HTMLElement,
|
|
59
|
+
target: EventTarget | null,
|
|
60
|
+
): boolean {
|
|
61
|
+
const index = ctx.layers.findIndex((l) => l.node === node);
|
|
62
|
+
if (index === -1) return false;
|
|
63
|
+
|
|
64
|
+
const nested = ctx.layers.slice(index + 1);
|
|
65
|
+
if (nested.some((layer) => contains(layer.node, target))) return true;
|
|
66
|
+
|
|
67
|
+
// During layer removal, treat all focus events as "inside" to prevent
|
|
68
|
+
// cascading dismissals from focus transitions (Zag pattern).
|
|
69
|
+
if (ctx.recentlyRemoved.size > 0) return true;
|
|
70
|
+
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isInBranch(ctx: LayerStackContextValue, target: EventTarget | null): boolean {
|
|
75
|
+
return ctx.branches.some((branch) => contains(branch, target));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isBelowPointerBlockingLayer(
|
|
79
|
+
ctx: LayerStackContextValue,
|
|
80
|
+
node: HTMLElement,
|
|
81
|
+
): boolean {
|
|
82
|
+
const index = ctx.layers.findIndex((l) => l.node === node);
|
|
83
|
+
const blockingLayers = ctx.layers.filter((l) => l.blockPointerEvents);
|
|
84
|
+
const highestBlocking = blockingLayers.at(-1);
|
|
85
|
+
if (!highestBlocking) return false;
|
|
86
|
+
|
|
87
|
+
const highestBlockingIndex = ctx.layers.indexOf(highestBlocking);
|
|
88
|
+
return index < highestBlockingIndex;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getPointerEventsEnabled(ctx: LayerStackContextValue, node: HTMLElement): boolean {
|
|
92
|
+
const hasBlocking = ctx.layers.some((l) => l.blockPointerEvents);
|
|
93
|
+
if (!hasBlocking) return true;
|
|
94
|
+
|
|
95
|
+
const index = ctx.layers.findIndex((l) => l.node === node);
|
|
96
|
+
const blockingLayers = ctx.layers.filter((l) => l.blockPointerEvents);
|
|
97
|
+
const highestBlocking = blockingLayers.at(-1);
|
|
98
|
+
if (!highestBlocking) return true;
|
|
99
|
+
|
|
100
|
+
const highestBlockingIndex = ctx.layers.indexOf(highestBlocking);
|
|
101
|
+
return index >= highestBlockingIndex;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function notifyLayerChange() {
|
|
105
|
+
document.dispatchEvent(new CustomEvent(LAYER_UPDATE_EVENT));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { LAYER_UPDATE_EVENT };
|
|
109
|
+
|
|
110
|
+
export function addLayer(ctx: LayerStackContextValue, layer: Layer) {
|
|
111
|
+
// Guard against double-registration (e.g., React strict mode re-running effects).
|
|
112
|
+
const existing = ctx.layers.findIndex((l) => l.node === layer.node);
|
|
113
|
+
if (existing >= 0) ctx.layers.splice(existing, 1);
|
|
114
|
+
|
|
115
|
+
ctx.layers.push(layer);
|
|
116
|
+
notifyLayerChange();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function removeLayer(ctx: LayerStackContextValue, node: HTMLElement) {
|
|
120
|
+
const index = ctx.layers.findIndex((l) => l.node === node);
|
|
121
|
+
if (index < 0) return;
|
|
122
|
+
|
|
123
|
+
// Track recently removed to handle focus race conditions during cleanup.
|
|
124
|
+
// This prevents parent layers from incorrectly dismissing when focus
|
|
125
|
+
// moves from a closing nested layer.
|
|
126
|
+
ctx.recentlyRemoved.add(node);
|
|
127
|
+
queueMicrotask(() => ctx.recentlyRemoved.delete(node));
|
|
128
|
+
|
|
129
|
+
// Cascade-dismiss only layers that declare this node as their parent.
|
|
130
|
+
// This prevents sibling layers (independent menus, dialogs, etc.) from
|
|
131
|
+
// being incorrectly dismissed when an unrelated layer closes.
|
|
132
|
+
// Transitive children are handled by their own removeLayer calls.
|
|
133
|
+
const children = ctx.layers.filter((l) => l.parentNode === node);
|
|
134
|
+
for (const child of children) {
|
|
135
|
+
child.dismiss({ dismissedParent: node });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Re-compute index: cascade-dismiss may have recursively removed layers,
|
|
139
|
+
// shifting the target's position in the array. In React, parents mount
|
|
140
|
+
// before children so children are always at higher indices — but this is
|
|
141
|
+
// a pure data structure with no React dependency, so we defend against
|
|
142
|
+
// arbitrary insertion order.
|
|
143
|
+
const freshIndex = ctx.layers.findIndex((l) => l.node === node);
|
|
144
|
+
if (freshIndex >= 0) ctx.layers.splice(freshIndex, 1);
|
|
145
|
+
notifyLayerChange();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* React context that propagates a parent dismissible layer's node down the
|
|
150
|
+
* React tree (including through portals). Nested dismissible layers read this
|
|
151
|
+
* to register as children of the parent, enabling correct cascade-dismiss
|
|
152
|
+
* behavior without affecting unrelated sibling layers.
|
|
153
|
+
*
|
|
154
|
+
* Follows the same pattern as base-ui's FloatingNodeContext.
|
|
155
|
+
*/
|
|
156
|
+
export const DismissibleParentContext = createContext<HTMLElement | null>(null);
|
|
157
|
+
|
|
158
|
+
export function useDismissibleParentNode() {
|
|
159
|
+
return useContext(DismissibleParentContext);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function addBranch(ctx: LayerStackContextValue, node: HTMLElement) {
|
|
163
|
+
ctx.branches.push(node);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function removeBranch(ctx: LayerStackContextValue, node: HTMLElement) {
|
|
167
|
+
const index = ctx.branches.indexOf(node);
|
|
168
|
+
if (index >= 0) ctx.branches.splice(index, 1);
|
|
169
|
+
}
|