@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,31 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { isTopMost, type LayerStackContextValue } from "./layer-stack";
5
+
6
+ /**
7
+ * Tracks escape keydown on the document (capture phase).
8
+ * Only fires callback when the given node is the topmost layer.
9
+ */
10
+ export function useEscapeKeydown(
11
+ node: HTMLElement | null,
12
+ ctx: LayerStackContextValue,
13
+ onEscapeKeyDown: (event: KeyboardEvent) => void,
14
+ ) {
15
+ const callbackRef = useRef(onEscapeKeyDown);
16
+ callbackRef.current = onEscapeKeyDown;
17
+
18
+ useEffect(() => {
19
+ if (!node) return;
20
+
21
+ const handler = (event: KeyboardEvent) => {
22
+ if (event.key !== "Escape") return;
23
+ if (event.isComposing) return;
24
+ if (!isTopMost(ctx, node)) return;
25
+ callbackRef.current(event);
26
+ };
27
+
28
+ document.addEventListener("keydown", handler, { capture: true });
29
+ return () => document.removeEventListener("keydown", handler, { capture: true });
30
+ }, [node, ctx]);
31
+ }
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { isInBranch, isInNestedLayer, type LayerStackContextValue } from "./layer-stack";
5
+
6
+ export interface UseFocusOutsideOptions {
7
+ enabled: boolean;
8
+ exclude?: (target: HTMLElement) => boolean;
9
+ onFocusOutside: (event: FocusEvent) => void;
10
+ }
11
+
12
+ /**
13
+ * Detects focus events outside a React subtree.
14
+ * Uses `onFocusCapture`/`onBlurCapture` for React-tree-based detection.
15
+ *
16
+ * Ported from Radix's useFocusOutside with layer stack integration.
17
+ */
18
+ export function useFocusOutside(
19
+ node: HTMLElement | null,
20
+ ctx: LayerStackContextValue,
21
+ options: UseFocusOutsideOptions,
22
+ ): { onFocusCapture: () => void; onBlurCapture: () => void } {
23
+ const callbackRef = useRef(options.onFocusOutside);
24
+ callbackRef.current = options.onFocusOutside;
25
+
26
+ const excludeRef = useRef(options.exclude);
27
+ excludeRef.current = options.exclude;
28
+
29
+ const isFocusInsideReactTreeRef = useRef(false);
30
+
31
+ useEffect(() => {
32
+ if (!node || !options.enabled) return;
33
+
34
+ const ownerDocument = node.ownerDocument ?? document;
35
+
36
+ const handleFocus = (event: FocusEvent) => {
37
+ const target = event.target;
38
+
39
+ if (target && !isFocusInsideReactTreeRef.current) {
40
+ if (isInBranch(ctx, target)) return;
41
+ if (isInNestedLayer(ctx, node, target)) return;
42
+ if (target instanceof HTMLElement && excludeRef.current?.(target)) return;
43
+ callbackRef.current(event);
44
+ }
45
+ };
46
+
47
+ ownerDocument.addEventListener("focusin", handleFocus);
48
+ return () => {
49
+ ownerDocument.removeEventListener("focusin", handleFocus);
50
+ isFocusInsideReactTreeRef.current = false;
51
+ };
52
+ }, [node, ctx, options.enabled]);
53
+
54
+ return {
55
+ onFocusCapture: () => {
56
+ isFocusInsideReactTreeRef.current = true;
57
+ },
58
+ onBlurCapture: () => {
59
+ isFocusInsideReactTreeRef.current = false;
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,272 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import {
5
+ isBelowPointerBlockingLayer,
6
+ isInBranch,
7
+ isInNestedLayer,
8
+ isTopMost,
9
+ type LayerStackContextValue,
10
+ } from "./layer-stack";
11
+
12
+ export interface UsePointerDownOutsideOptions {
13
+ enabled: boolean;
14
+ exclude?: (target: HTMLElement) => boolean;
15
+ onPressOutside: (event: PointerEvent | TouchEvent) => void;
16
+
17
+ /**
18
+ * Determines when an outside press triggers dismiss.
19
+ *
20
+ * - `"confirm"` (default): Both mouse and touch defer to the `click` event,
21
+ * requiring a full press-and-release before dismiss.
22
+ *
23
+ * - `"eager"`: Mouse dismisses on `pointerdown` immediately.
24
+ * Touch defers to the `click` event to prevent click-through.
25
+ *
26
+ * - `"drag"`: Mouse dismisses on `pointerdown` immediately.
27
+ * Touch tracks movement and dismisses when the finger moves beyond a
28
+ * distance threshold (>10px immediate, >5px on touchend).
29
+ * Suppresses the synthetic click/pointerdown that follows for 1000ms.
30
+ */
31
+ pressBehavior?: "eager" | "confirm" | "drag";
32
+ }
33
+
34
+ interface TouchState {
35
+ startX: number;
36
+ startY: number;
37
+ dismissOnTouchEnd: boolean;
38
+ startedOnExcluded: boolean;
39
+ }
40
+
41
+ /**
42
+ * Detects pointer-down events outside a React subtree.
43
+ * Uses `onPointerDownCapture` on the element to distinguish React-tree-inside
44
+ * from DOM-tree-inside (Portal support).
45
+ *
46
+ * Ported from Radix's usePointerDownOutside with layer stack integration.
47
+ */
48
+ export function usePointerDownOutside(
49
+ node: HTMLElement | null,
50
+ ctx: LayerStackContextValue,
51
+ options: UsePointerDownOutsideOptions,
52
+ ): { onPointerDownCapture: () => void } {
53
+ const callbackRef = useRef(options.onPressOutside);
54
+ callbackRef.current = options.onPressOutside;
55
+
56
+ const excludeRef = useRef(options.exclude);
57
+ excludeRef.current = options.exclude;
58
+
59
+ const pressBehaviorRef = useRef(options.pressBehavior);
60
+ pressBehaviorRef.current = options.pressBehavior;
61
+
62
+ const isPointerInsideReactTreeRef = useRef(false);
63
+ const handleClickRef = useRef<() => void>(() => {});
64
+
65
+ // Touch drag state (only used in "drag" mode)
66
+ const touchStateRef = useRef<TouchState | null>(null);
67
+ const suppressPointerRef = useRef(false);
68
+ const suppressTimerRef = useRef<ReturnType<typeof setTimeout>>();
69
+
70
+ useEffect(() => {
71
+ if (!node || !options.enabled) return;
72
+
73
+ const ownerDocument = node.ownerDocument ?? document;
74
+
75
+ function isOutsideLayer(target: HTMLElement): boolean {
76
+ if (!isTopMost(ctx, node!)) return false;
77
+ if (isBelowPointerBlockingLayer(ctx, node!)) return false;
78
+ if (isInBranch(ctx, target)) return false;
79
+ if (isInNestedLayer(ctx, node!, target)) return false;
80
+ return true;
81
+ }
82
+
83
+ function isOutsideTarget(target: HTMLElement): boolean {
84
+ if (!isOutsideLayer(target)) return false;
85
+ if (excludeRef.current?.(target)) return false;
86
+ return true;
87
+ }
88
+
89
+ function deferToClick(handleAndDispatch: () => void) {
90
+ ownerDocument.removeEventListener("click", handleClickRef.current);
91
+ handleClickRef.current = handleAndDispatch;
92
+ ownerDocument.addEventListener("click", handleClickRef.current, { once: true });
93
+ }
94
+
95
+ const handlePointerDown = (event: PointerEvent) => {
96
+ const target = event.target as HTMLElement;
97
+
98
+ if (target && !isPointerInsideReactTreeRef.current) {
99
+ if (!isOutsideTarget(target)) {
100
+ isPointerInsideReactTreeRef.current = false;
101
+ return;
102
+ }
103
+
104
+ // In drag mode, suppress synthetic pointerdown that follows a touch dismiss.
105
+ if (pressBehaviorRef.current === "drag" && suppressPointerRef.current) {
106
+ isPointerInsideReactTreeRef.current = false;
107
+ return;
108
+ }
109
+
110
+ function handleAndDispatch() {
111
+ callbackRef.current(event);
112
+ }
113
+
114
+ const behavior = pressBehaviorRef.current ?? "confirm";
115
+
116
+ if (behavior === "confirm") {
117
+ // Both mouse and touch defer to click
118
+ deferToClick(handleAndDispatch);
119
+ } else if (behavior === "drag") {
120
+ // Touch is handled by touchstart/move/end listeners.
121
+ // Only process non-touch pointerdown (mouse, pen).
122
+ if (event.pointerType === "touch") {
123
+ isPointerInsideReactTreeRef.current = false;
124
+ return;
125
+ }
126
+ handleAndDispatch();
127
+ } else {
128
+ // "eager" (default): mouse immediate, touch defers to click
129
+ if (event.pointerType === "touch") {
130
+ deferToClick(handleAndDispatch);
131
+ } else {
132
+ handleAndDispatch();
133
+ }
134
+ }
135
+ } else {
136
+ ownerDocument.removeEventListener("click", handleClickRef.current);
137
+ }
138
+
139
+ isPointerInsideReactTreeRef.current = false;
140
+ };
141
+
142
+ // -- Touch drag handlers (only active in "drag" mode) --
143
+
144
+ const isTouchInsideReactTreeRef = { current: false };
145
+
146
+ const handleTouchStartCapture = () => {
147
+ isTouchInsideReactTreeRef.current = true;
148
+ };
149
+
150
+ const handleTouchStart = (event: TouchEvent) => {
151
+ if (pressBehaviorRef.current !== "drag") return;
152
+ if (isTouchInsideReactTreeRef.current) {
153
+ isTouchInsideReactTreeRef.current = false;
154
+ return;
155
+ }
156
+
157
+ const target = event.target as HTMLElement;
158
+ if (!target || !isOutsideLayer(target)) return;
159
+
160
+ const touch = event.touches[0];
161
+ if (!touch) return;
162
+
163
+ const startedOnExcluded = !!excludeRef.current?.(target);
164
+
165
+ touchStateRef.current = {
166
+ startX: touch.clientX,
167
+ startY: touch.clientY,
168
+ dismissOnTouchEnd: false,
169
+ startedOnExcluded,
170
+ };
171
+
172
+ // Activate click-through guard
173
+ suppressPointerRef.current = true;
174
+ clearTimeout(suppressTimerRef.current);
175
+ suppressTimerRef.current = setTimeout(() => {
176
+ suppressPointerRef.current = false;
177
+ if (touchStateRef.current) {
178
+ touchStateRef.current.dismissOnTouchEnd = false;
179
+ }
180
+ }, 1000);
181
+ };
182
+
183
+ const handleTouchMove = (event: TouchEvent) => {
184
+ if (pressBehaviorRef.current !== "drag" || !touchStateRef.current) return;
185
+
186
+ const target = event.target as HTMLElement;
187
+ if (target && node!.contains(target)) return;
188
+
189
+ const touch = event.touches[0];
190
+ if (!touch) return;
191
+
192
+ const deltaX = Math.abs(touch.clientX - touchStateRef.current.startX);
193
+ const deltaY = Math.abs(touch.clientY - touchStateRef.current.startY);
194
+ const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
195
+
196
+ if (distance > 5) {
197
+ touchStateRef.current.dismissOnTouchEnd = true;
198
+ }
199
+
200
+ if (distance > 10) {
201
+ callbackRef.current(event);
202
+ clearTimeout(suppressTimerRef.current);
203
+ suppressTimerRef.current = setTimeout(() => {
204
+ suppressPointerRef.current = false;
205
+ }, 1000);
206
+ touchStateRef.current = null;
207
+ }
208
+ };
209
+
210
+ const handleTouchEnd = () => {
211
+ if (pressBehaviorRef.current !== "drag" || !touchStateRef.current) return;
212
+
213
+ if (touchStateRef.current.dismissOnTouchEnd) {
214
+ // Drag detected (>5px): dismiss immediately
215
+ callbackRef.current(new PointerEvent("pointerdown"));
216
+ clearTimeout(suppressTimerRef.current);
217
+ suppressTimerRef.current = setTimeout(() => {
218
+ suppressPointerRef.current = false;
219
+ }, 1000);
220
+ } else if (!touchStateRef.current.startedOnExcluded) {
221
+ // Tap (no significant movement): defer to click, like "confirm" mode.
222
+ // Skip if tap started on an excluded target (e.g., trigger) — toggle handles it.
223
+ suppressPointerRef.current = false;
224
+ clearTimeout(suppressTimerRef.current);
225
+ deferToClick(() => callbackRef.current(new PointerEvent("pointerdown")));
226
+ } else {
227
+ // Tap on excluded target — clean up guard without dismissing
228
+ suppressPointerRef.current = false;
229
+ clearTimeout(suppressTimerRef.current);
230
+ }
231
+
232
+ touchStateRef.current = null;
233
+ };
234
+
235
+ // Delay registration to prevent the mount-triggering pointerdown from
236
+ // being detected as "outside". This is a DOM behavior, not React-specific.
237
+ const timerId = window.setTimeout(() => {
238
+ ownerDocument.addEventListener("pointerdown", handlePointerDown);
239
+
240
+ if (pressBehaviorRef.current === "drag") {
241
+ ownerDocument.addEventListener("touchstart", handleTouchStart);
242
+ ownerDocument.addEventListener("touchmove", handleTouchMove);
243
+ ownerDocument.addEventListener("touchend", handleTouchEnd);
244
+ }
245
+ }, 0);
246
+
247
+ // Register capture handler on node for React tree detection
248
+ if (pressBehaviorRef.current === "drag") {
249
+ node.addEventListener("touchstart", handleTouchStartCapture, true);
250
+ }
251
+
252
+ return () => {
253
+ window.clearTimeout(timerId);
254
+ clearTimeout(suppressTimerRef.current);
255
+ ownerDocument.removeEventListener("pointerdown", handlePointerDown);
256
+ ownerDocument.removeEventListener("click", handleClickRef.current);
257
+ ownerDocument.removeEventListener("touchstart", handleTouchStart);
258
+ ownerDocument.removeEventListener("touchmove", handleTouchMove);
259
+ ownerDocument.removeEventListener("touchend", handleTouchEnd);
260
+ node?.removeEventListener("touchstart", handleTouchStartCapture, true);
261
+ touchStateRef.current = null;
262
+ suppressPointerRef.current = false;
263
+ };
264
+ }, [node, ctx, options.enabled]);
265
+
266
+ return {
267
+ // React synthetic event — fires for React-tree descendants (including Portals).
268
+ onPointerDownCapture: () => {
269
+ isPointerInsideReactTreeRef.current = true;
270
+ },
271
+ };
272
+ }