@navikt/ds-react 7.33.0 → 7.33.2

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.
Files changed (99) hide show
  1. package/cjs/date/datepicker/hooks/useRangeDatepicker.js +2 -2
  2. package/cjs/date/datepicker/hooks/useRangeDatepicker.js.map +1 -1
  3. package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +0 -1
  4. package/cjs/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
  5. package/cjs/overlays/action-menu/ActionMenu.js +2 -5
  6. package/cjs/overlays/action-menu/ActionMenu.js.map +1 -1
  7. package/cjs/overlays/dismissablelayer/DismissableLayer.d.ts +3 -30
  8. package/cjs/overlays/dismissablelayer/DismissableLayer.js +141 -134
  9. package/cjs/overlays/dismissablelayer/DismissableLayer.js.map +1 -1
  10. package/cjs/overlays/dismissablelayer/util/sort-layers.d.ts +18 -0
  11. package/cjs/overlays/dismissablelayer/util/sort-layers.js +51 -0
  12. package/cjs/overlays/dismissablelayer/util/sort-layers.js.map +1 -0
  13. package/cjs/overlays/floating-menu/Menu.d.ts +5 -7
  14. package/cjs/overlays/floating-menu/Menu.js +7 -15
  15. package/cjs/overlays/floating-menu/Menu.js.map +1 -1
  16. package/cjs/popover/Popover.js +0 -1
  17. package/cjs/popover/Popover.js.map +1 -1
  18. package/cjs/portal/Portal.d.ts +1 -3
  19. package/cjs/portal/Portal.js +49 -17
  20. package/cjs/portal/Portal.js.map +1 -1
  21. package/cjs/timeline/Timeline.d.ts +1 -1
  22. package/cjs/timeline/Timeline.js +1 -1
  23. package/cjs/timeline/Timeline.js.map +1 -1
  24. package/cjs/timeline/index.d.ts +1 -1
  25. package/cjs/timeline/index.js +1 -1
  26. package/cjs/timeline/index.js.map +1 -1
  27. package/cjs/timeline/pin/Pin.d.ts +9 -0
  28. package/cjs/timeline/pin/Pin.js +68 -0
  29. package/cjs/timeline/pin/Pin.js.map +1 -0
  30. package/cjs/timeline/pin/PinInternal.d.ts +13 -0
  31. package/cjs/timeline/{Pin.js → pin/PinInternal.js} +9 -10
  32. package/cjs/timeline/pin/PinInternal.js.map +1 -0
  33. package/cjs/tooltip/Tooltip.js +23 -22
  34. package/cjs/tooltip/Tooltip.js.map +1 -1
  35. package/cjs/util/focus-boundary/FocusBoundary.d.ts +19 -10
  36. package/cjs/util/focus-boundary/FocusBoundary.js +107 -63
  37. package/cjs/util/focus-boundary/FocusBoundary.js.map +1 -1
  38. package/cjs/util/hooks/useScrollLock.js +17 -3
  39. package/cjs/util/hooks/useScrollLock.js.map +1 -1
  40. package/esm/date/datepicker/hooks/useRangeDatepicker.js +2 -2
  41. package/esm/date/datepicker/hooks/useRangeDatepicker.js.map +1 -1
  42. package/esm/form/combobox/FilteredOptions/FilteredOptions.js +0 -1
  43. package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
  44. package/esm/overlays/action-menu/ActionMenu.js +2 -5
  45. package/esm/overlays/action-menu/ActionMenu.js.map +1 -1
  46. package/esm/overlays/dismissablelayer/DismissableLayer.d.ts +3 -30
  47. package/esm/overlays/dismissablelayer/DismissableLayer.js +140 -132
  48. package/esm/overlays/dismissablelayer/DismissableLayer.js.map +1 -1
  49. package/esm/overlays/dismissablelayer/util/sort-layers.d.ts +18 -0
  50. package/esm/overlays/dismissablelayer/util/sort-layers.js +49 -0
  51. package/esm/overlays/dismissablelayer/util/sort-layers.js.map +1 -0
  52. package/esm/overlays/floating-menu/Menu.d.ts +5 -7
  53. package/esm/overlays/floating-menu/Menu.js +7 -15
  54. package/esm/overlays/floating-menu/Menu.js.map +1 -1
  55. package/esm/popover/Popover.js +0 -1
  56. package/esm/popover/Popover.js.map +1 -1
  57. package/esm/portal/Portal.d.ts +1 -3
  58. package/esm/portal/Portal.js +50 -18
  59. package/esm/portal/Portal.js.map +1 -1
  60. package/esm/timeline/Timeline.d.ts +1 -1
  61. package/esm/timeline/Timeline.js +1 -1
  62. package/esm/timeline/Timeline.js.map +1 -1
  63. package/esm/timeline/index.d.ts +1 -1
  64. package/esm/timeline/index.js +1 -1
  65. package/esm/timeline/index.js.map +1 -1
  66. package/esm/timeline/pin/Pin.d.ts +9 -0
  67. package/esm/timeline/pin/Pin.js +29 -0
  68. package/esm/timeline/pin/Pin.js.map +1 -0
  69. package/esm/timeline/pin/PinInternal.d.ts +13 -0
  70. package/esm/timeline/{Pin.js → pin/PinInternal.js} +8 -9
  71. package/esm/timeline/pin/PinInternal.js.map +1 -0
  72. package/esm/tooltip/Tooltip.js +23 -22
  73. package/esm/tooltip/Tooltip.js.map +1 -1
  74. package/esm/util/focus-boundary/FocusBoundary.d.ts +19 -10
  75. package/esm/util/focus-boundary/FocusBoundary.js +108 -64
  76. package/esm/util/focus-boundary/FocusBoundary.js.map +1 -1
  77. package/esm/util/hooks/useScrollLock.js +17 -3
  78. package/esm/util/hooks/useScrollLock.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/date/datepicker/hooks/useRangeDatepicker.tsx +4 -2
  81. package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +0 -1
  82. package/src/overlays/action-menu/ActionMenu.tsx +2 -4
  83. package/src/overlays/dismissablelayer/DismissableLayer.tsx +219 -194
  84. package/src/overlays/dismissablelayer/util/sort-layers.test.ts +128 -0
  85. package/src/overlays/dismissablelayer/util/sort-layers.ts +61 -0
  86. package/src/overlays/floating-menu/Menu.tsx +11 -21
  87. package/src/popover/Popover.tsx +0 -1
  88. package/src/portal/Portal.tsx +89 -31
  89. package/src/timeline/Timeline.tsx +1 -1
  90. package/src/timeline/index.ts +1 -1
  91. package/src/timeline/pin/Pin.tsx +33 -0
  92. package/src/timeline/{Pin.tsx → pin/PinInternal.tsx} +8 -18
  93. package/src/tooltip/Tooltip.tsx +4 -4
  94. package/src/util/focus-boundary/FocusBoundary.tsx +164 -93
  95. package/src/util/hooks/useScrollLock.ts +22 -3
  96. package/cjs/timeline/Pin.d.ts +0 -17
  97. package/cjs/timeline/Pin.js.map +0 -1
  98. package/esm/timeline/Pin.d.ts +0 -17
  99. package/esm/timeline/Pin.js.map +0 -1
@@ -1,24 +1,21 @@
1
- import React, {
2
- CSSProperties,
3
- forwardRef,
4
- useEffect,
5
- useRef,
6
- useState,
7
- } from "react";
1
+ import React, { forwardRef, useContext, useEffect, useState } from "react";
8
2
  import { Slot } from "../../slot/Slot";
3
+ import { omit } from "../../util";
4
+ import { composeEventHandlers } from "../../util/composeEventHandlers";
9
5
  import { useMergeRefs } from "../../util/hooks";
10
- import { createDescendantContext } from "../../util/hooks/descendants/useDescendant";
11
6
  import { ownerDocument } from "../../util/owner";
12
7
  import { AsChild } from "../../util/types/AsChild";
13
8
  import {
14
9
  CustomFocusEvent,
15
10
  CustomPointerDownEvent,
16
11
  } from "./util/dispatchCustomEvent";
12
+ import { getSortedLayers } from "./util/sort-layers";
17
13
  import { useEscapeKeydown } from "./util/useEscapeKeydown";
18
14
  import { useFocusOutside } from "./util/useFocusOutside";
19
15
  import { usePointerDownOutside } from "./util/usePointerDownOutside";
20
16
 
21
- interface DismissableLayerBaseProps {
17
+ interface DismissableLayerBaseProps
18
+ extends React.HTMLAttributes<HTMLDivElement> {
22
19
  /**
23
20
  * When `true`, hover/focus/click interactions will be disabled on elements outside
24
21
  * the `DismissableLayer`. Users will need to click twice on outside elements to
@@ -54,185 +51,131 @@ interface DismissableLayerBaseProps {
54
51
  onDismiss?: () => void;
55
52
  /**
56
53
  * Stops `onDismiss` from beeing called when interacting with the `safeZone` elements.
57
- * `safeZone.dismissable` is only needed when its element does not have a `tabIndex` since it will not receive focus-events.
58
54
  */
59
55
  safeZone?: {
60
56
  anchor?: Element | null;
61
- dismissable?: Element | null;
62
57
  };
63
-
64
- style?: CSSProperties;
65
58
  /**
66
- * Disables layer from beeing counted in context for nested `DismissableLayer`.
59
+ * @default true
67
60
  */
68
61
  enabled?: boolean;
69
62
  }
70
63
 
71
64
  type DismissableLayerProps = DismissableLayerBaseProps & AsChild;
72
65
 
73
- export const [
74
- DismissableDescendantsProvider,
75
- useDismissableDescendantsContext,
76
- useDismissableDescendants,
77
- useDismissableDescendant,
78
- ] = createDescendantContext<
79
- HTMLDivElement,
80
- { disableOutsidePointerEvents: boolean; forceUpdate: () => void }
81
- >();
82
-
83
- /**
84
- * Number of layers with `disableOutsidePointerEvents` set to `true` currently enabled.
85
- */
86
- let bodyLockCount = 0;
87
- let originalBodyPointerEvents: string;
88
-
89
66
  const DismissableLayer = forwardRef<HTMLDivElement, DismissableLayerProps>(
90
- (props: DismissableLayerProps, ref) => {
91
- const context = useDismissableDescendantsContext(false);
67
+ ({ enabled = true, ...restProps }: DismissableLayerProps, forwardedRef) => {
68
+ if (!enabled) {
69
+ const Component = restProps.asChild ? Slot : "div";
70
+ return (
71
+ <Component
72
+ {...omit(restProps, [
73
+ "asChild",
74
+ "disableOutsidePointerEvents",
75
+ "onDismiss",
76
+ "onEscapeKeyDown",
77
+ "onFocusOutside",
78
+ "onInteractOutside",
79
+ "onPointerDownOutside",
80
+ "safeZone",
81
+ ])}
82
+ ref={forwardedRef}
83
+ />
84
+ );
85
+ }
92
86
 
93
- /**
94
- * To correctly handle nested DismissableLayer,
95
- * we only initialize the `Descendants`-API for the root layer to aboid resetting context
96
- */
97
- return context ? (
98
- <DismissableLayerNode ref={ref} {...props} />
99
- ) : (
100
- <DismissableRoot>
101
- <DismissableLayerNode ref={ref} {...props} />
102
- </DismissableRoot>
103
- );
87
+ return <DismissableLayerInternal {...restProps} ref={forwardedRef} />;
104
88
  },
105
89
  );
106
90
 
107
- /**
108
- * DismissableRoot
109
- *
110
- * Used to initialize the `Descendants`-API at the root layer.
111
- * All subsequent layers will use the same context.
112
- */
113
- const DismissableRoot = ({ children }: { children: React.ReactNode }) => {
114
- const descendants = useDismissableDescendants();
91
+ type DismissableLayerElement = React.ComponentRef<
92
+ typeof DismissableLayerInternal
93
+ >;
94
+
95
+ const BranchedLayerContext =
96
+ React.createContext<DismissableLayerElement | null>(null);
97
+
98
+ /* ------------------------ DismissableLayerInternal ------------------------ */
99
+ const CONTEXT_UPDATE_EVENT = "dismissableLayer.update";
100
+ let originalBodyPointerEvents: string;
115
101
 
116
- return (
117
- <DismissableDescendantsProvider value={descendants}>
118
- {children}
119
- </DismissableDescendantsProvider>
120
- );
121
- };
102
+ const DismissableLayerContext = React.createContext({
103
+ layers: new Set<DismissableLayerElement>(),
104
+ branchedLayers: new Map<
105
+ DismissableLayerElement,
106
+ Set<DismissableLayerElement>
107
+ >(),
108
+ layersWithOutsidePointerEventsDisabled: new Set<DismissableLayerElement>(),
109
+ });
122
110
 
123
- const DismissableLayerNode = forwardRef<HTMLDivElement, DismissableLayerProps>(
111
+ const DismissableLayerInternal = forwardRef<
112
+ HTMLDivElement,
113
+ DismissableLayerProps
114
+ >(
124
115
  (
125
116
  {
126
117
  children,
127
- asChild,
118
+ disableOutsidePointerEvents,
119
+ onDismiss,
120
+ onInteractOutside,
128
121
  onEscapeKeyDown,
129
- onPointerDownOutside,
130
122
  onFocusOutside,
131
- onInteractOutside,
132
- onDismiss,
123
+ onPointerDownOutside,
133
124
  safeZone,
134
- disableOutsidePointerEvents = false,
135
- enabled = true,
136
- ...rest
125
+ asChild,
126
+ ...restProps
137
127
  }: DismissableLayerProps,
138
- ref,
128
+ forwardedRef,
139
129
  ) => {
140
- const [, setForce] = useState({});
141
- const { register, index, descendants } = useDismissableDescendant({
142
- disableOutsidePointerEvents,
143
- disabled: !enabled,
144
- forceUpdate: () => setForce({}),
145
- });
146
-
147
- /**
148
- * `node` will be set to the ref of the component or nested component
149
- * Ex: If
150
- * ```
151
- * <DismissableLayer asChild>
152
- * <Popover />
153
- * </DismissableLayer>
154
- * ```
155
- * `node` will in this case be the Popover-element.
156
- * We use State her and not ref since we want to trigger a rerender when the node changes.
157
- */
158
- const [node, setNode] = useState<HTMLDivElement | null>(null);
159
-
160
- const mergedRefs = useMergeRefs(setNode, register, ref);
130
+ const context = useContext(DismissableLayerContext);
161
131
 
132
+ const [, forceRerender] = useState({});
133
+ const [node, setNode] = React.useState<DismissableLayerElement | null>(
134
+ null,
135
+ );
136
+ const mergedRefs = useMergeRefs(forwardedRef, setNode);
162
137
  const ownerDoc = ownerDocument(node);
163
138
 
164
- const hasInteractedOutsideRef = useRef(false);
165
- const hasPointerDownOutsideRef = useRef(false);
166
-
167
- const pointerState = (() => {
168
- let lastIndex = -1;
169
-
170
- const descendantNodes = descendants.enabledValues();
171
-
172
- descendantNodes.forEach((obj, _index) => {
173
- if (obj.disableOutsidePointerEvents) {
174
- lastIndex = _index;
175
- }
176
- });
177
-
178
- return {
179
- /**
180
- * Makes sure we stop events at the highest layer with pointer events disabled.
181
- * If not checked, we risk closing every layer when clicking outside the layer.
182
- */
183
- isPointerEventsEnabled: index >= lastIndex,
184
- /**
185
- * If we find a node with `disableOutsidePointerEvents` we want to disable pointer events on the body.
186
- */
187
- isBodyPointerEventsDisabled: bodyLockCount > 0,
188
- pointerStyle: (index >= lastIndex && bodyLockCount > 0
189
- ? "auto"
190
- : undefined) as CSSProperties["pointerEvents"] | undefined,
191
- };
192
- })();
139
+ /* Layer handling */
140
+ const layers = getSortedLayers(context.layers, context.branchedLayers);
141
+ const highestLayerWithOutsidePointerEventsDisabledIndex =
142
+ findHighestLayerIndex(
143
+ layers,
144
+ context.layersWithOutsidePointerEventsDisabled,
145
+ );
146
+ const index = node ? layers.indexOf(node) : -1;
147
+ const isBodyPointerEventsDisabled =
148
+ context.layersWithOutsidePointerEventsDisabled.size > 0;
149
+ const shouldEnablePointerEvents =
150
+ highestLayerWithOutsidePointerEventsDisabledIndex === -1 ||
151
+ index >= highestLayerWithOutsidePointerEventsDisabledIndex;
193
152
 
194
153
  /**
195
- * We want to prevent the Layer from closing when the trigger, anchor element, or its child elements are interacted with.
196
- *
197
- * To achieve this, we check if the event target is the trigger, anchor or a child. If it is, we prevent default event behavior.
198
- *
199
- * The `pointerDownOutside` and `focusOutside` handlers already check if the event target is within the DismissableLayer (`node`).
200
- * However, since we don't add a `tabIndex` to the Popover/Tooltip, the `focusOutside` handler doesn't correctly handle focus events.
201
- * Therefore, we also need to check that neither the trigger (`anchor`) nor the DismissableLayer (`dismissable`) are the event targets.
154
+ * We want to prevent the Layer from closing when the trigger/anchor element or its child elements are interacted with.
155
+ * To achieve this, we check if the event target is the trigger/anchor or a child. If it is, we prevent default event behavior.
202
156
  */
203
157
  function handleOutsideEvent(
204
158
  event: CustomFocusEvent | CustomPointerDownEvent,
205
159
  ) {
206
- if ((!safeZone?.anchor && !safeZone?.dismissable) || !enabled) {
160
+ if (!safeZone?.anchor) {
207
161
  return;
208
162
  }
209
163
 
164
+ let hasPointerDownOutside = false;
165
+
210
166
  if (!event.defaultPrevented) {
211
- hasInteractedOutsideRef.current = true;
212
167
  if (event.detail.originalEvent.type === "pointerdown") {
213
- hasPointerDownOutsideRef.current = true;
168
+ hasPointerDownOutside = true;
214
169
  }
215
170
  }
216
171
 
217
172
  const target = event.target as HTMLElement;
218
173
 
219
- /**
220
- * pointerdown-events works as expected, but focus-events does not.
221
- * For focus-event we need to also check `safeZone.dismissable` (the Popover/Tooltip itself) since it does not have a tabIndex.
222
- */
223
- if (event.detail.originalEvent.type === "pointerdown") {
224
- const targetIsTrigger =
225
- safeZone?.anchor?.contains(target) || target === safeZone?.anchor;
226
- targetIsTrigger && event.preventDefault();
227
- } else {
228
- const targetIsNotTrigger =
229
- target instanceof HTMLElement &&
230
- ![safeZone?.anchor, safeZone?.dismissable].some(
231
- (element) => element?.contains(target as Node),
232
- ) &&
233
- !target.contains(safeZone?.dismissable ?? null);
234
-
235
- !targetIsNotTrigger && event.preventDefault();
174
+ const targetIsAnchor =
175
+ safeZone.anchor.contains(target) || target === safeZone.anchor;
176
+
177
+ if (targetIsAnchor) {
178
+ event.preventDefault();
236
179
  }
237
180
 
238
181
  /**
@@ -245,21 +188,19 @@ const DismissableLayerNode = forwardRef<HTMLDivElement, DismissableLayerProps>(
245
188
  */
246
189
  if (
247
190
  event.detail.originalEvent.type === "focusin" &&
248
- hasPointerDownOutsideRef.current
191
+ hasPointerDownOutside
249
192
  ) {
250
193
  event.preventDefault();
251
194
  }
252
- hasPointerDownOutsideRef.current = false;
253
- hasInteractedOutsideRef.current = false;
254
195
  }
255
196
 
256
197
  const pointerDownOutside = usePointerDownOutside((event) => {
257
- if (!pointerState.isPointerEventsEnabled || !enabled) {
198
+ if (!shouldEnablePointerEvents) {
258
199
  return;
259
200
  }
260
201
 
261
202
  /**
262
- * We call these before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault based certain cases.
203
+ * We call these before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault.
263
204
  */
264
205
  onPointerDownOutside?.(event);
265
206
  onInteractOutside?.(event);
@@ -269,21 +210,14 @@ const DismissableLayerNode = forwardRef<HTMLDivElement, DismissableLayerProps>(
269
210
  */
270
211
  safeZone && handleOutsideEvent(event);
271
212
 
272
- /**
273
- * Both `onPointerDownOutside` and `onInteractOutside` are able to preventDefault the event, thus stopping call for `onDismiss`.
274
- */
275
213
  if (!event.defaultPrevented && onDismiss) {
276
214
  onDismiss();
277
215
  }
278
216
  }, ownerDoc);
279
217
 
280
218
  const focusOutside = useFocusOutside((event) => {
281
- if (!enabled) {
282
- return;
283
- }
284
-
285
219
  /**
286
- * We call these before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault based certain cases.
220
+ * We call these before letting `handleOutsideEvent` do its checks to give consumer a chance to preventDefault.
287
221
  */
288
222
  onFocusOutside?.(event);
289
223
  onInteractOutside?.(event);
@@ -293,25 +227,17 @@ const DismissableLayerNode = forwardRef<HTMLDivElement, DismissableLayerProps>(
293
227
  */
294
228
  safeZone && handleOutsideEvent(event);
295
229
 
296
- /**
297
- * Both `onFocusOutside` and `onInteractOutside` are able to preventDefault the event, thus stopping call for `onDismiss`.
298
- */
299
230
  if (!event.defaultPrevented && onDismiss) {
300
231
  onDismiss();
301
232
  }
302
233
  }, ownerDoc);
303
234
 
304
235
  useEscapeKeydown((event) => {
305
- if (!enabled) {
306
- return;
307
- }
308
236
  /**
309
237
  * The deepest nested element will always be last in the descendants list.
310
238
  * This allows us to only close the highest layer when pressing escape.
311
- *
312
- * In some cases a layer might still exist, but be disabled. We want to ignore these layers.
313
239
  */
314
- const isHighestLayer = index === descendants.enabledCount() - 1;
240
+ const isHighestLayer = index === context.layers.size - 1;
315
241
  if (!isHighestLayer) {
316
242
  return;
317
243
  }
@@ -331,53 +257,152 @@ const DismissableLayerNode = forwardRef<HTMLDivElement, DismissableLayerProps>(
331
257
  }, ownerDoc);
332
258
 
333
259
  /**
334
- * If `disableOutsidePointerEvents` is true,
335
- * we want to disable pointer events on the body when the first layer is opened.
260
+ * Handles registering `layers` and `layersWithOutsidePointerEventsDisabled`.
336
261
  */
337
-
338
- // biome-ignore lint/correctness/useExhaustiveDependencies: Every time the descendants change, we want to update the body pointer events since we might have added or removed a layer.
339
262
  useEffect(() => {
340
- if (!node || !enabled || !disableOutsidePointerEvents) return;
263
+ if (!node) {
264
+ return;
265
+ }
341
266
 
342
- if (bodyLockCount === 0) {
343
- originalBodyPointerEvents = ownerDoc.body.style.pointerEvents;
344
- ownerDoc.body.style.pointerEvents = "none";
267
+ if (disableOutsidePointerEvents) {
268
+ if (context.layersWithOutsidePointerEventsDisabled.size === 0) {
269
+ originalBodyPointerEvents = ownerDoc.body.style.pointerEvents;
270
+ ownerDoc.body.style.pointerEvents = "none";
271
+ }
272
+ context.layersWithOutsidePointerEventsDisabled.add(node);
345
273
  }
346
- bodyLockCount++;
274
+ context.layers.add(node);
275
+ dispatchUpdate();
276
+
347
277
  return () => {
348
- if (bodyLockCount === 1) {
278
+ if (
279
+ disableOutsidePointerEvents &&
280
+ context.layersWithOutsidePointerEventsDisabled.size === 1
281
+ ) {
349
282
  ownerDoc.body.style.pointerEvents = originalBodyPointerEvents;
350
283
  }
351
- bodyLockCount--;
352
284
  };
353
- }, [node, ownerDoc, disableOutsidePointerEvents, descendants, enabled]);
285
+ }, [node, disableOutsidePointerEvents, context, ownerDoc]);
354
286
 
355
287
  /**
356
- * To make sure pointerEvents are enabled for all parents and siblings when the layer is removed from the DOM
288
+ * We purposefully prevent combining this effect with the `disableOutsidePointerEvents` effect
289
+ * because a change to `disableOutsidePointerEvents` would remove this layer from the stack
290
+ * and add it to the end again so the layering order wouldn't be creation order.
291
+ * We only want them to be removed from context stacks when unmounted.
357
292
  */
358
- // biome-ignore lint/correctness/useExhaustiveDependencies: We explicitly want to run this on unmount, including every time the node updates to make sure we don't lock the application behind pointer-events: none.
359
293
  useEffect(() => {
360
- return () => descendants.values().forEach((x) => x.forceUpdate());
361
- }, [descendants, node]);
294
+ return () => {
295
+ if (!node) {
296
+ return;
297
+ }
298
+
299
+ context.layers.delete(node);
300
+ context.layersWithOutsidePointerEventsDisabled.delete(node);
301
+ dispatchUpdate();
302
+ };
303
+ }, [node, context]);
304
+
305
+ const parentBranchedLayer = useContext(BranchedLayerContext);
306
+
307
+ /**
308
+ * Handles registering and unregistering branched (nested) layers.
309
+ * When this layer has a parent, we register it as a child of the parent.
310
+ */
311
+ useEffect(() => {
312
+ if (!node || !parentBranchedLayer || node === parentBranchedLayer) {
313
+ return;
314
+ }
315
+
316
+ if (!context.branchedLayers.has(parentBranchedLayer)) {
317
+ context.branchedLayers.set(parentBranchedLayer, new Set());
318
+ }
319
+
320
+ const branchedChildren = context.branchedLayers.get(parentBranchedLayer)!;
321
+ branchedChildren.add(node);
322
+ dispatchUpdate();
323
+
324
+ return () => {
325
+ // Remove this node from the parent's children
326
+ branchedChildren.delete(node);
327
+
328
+ // If the parent has no more children, remove the parent from branchedLayers
329
+ if (branchedChildren.size === 0) {
330
+ context.branchedLayers.delete(parentBranchedLayer);
331
+ }
332
+
333
+ dispatchUpdate();
334
+ };
335
+ }, [node, parentBranchedLayer, context]);
336
+
337
+ /**
338
+ * Synchronizes layer state across all mounted `DismissableLayer` instances.
339
+ * All layers re-render on every context change to recalculate their position and pointer-events.
340
+ */
341
+ useEffect(() => {
342
+ const handleUpdate = () => forceRerender({});
343
+ document.addEventListener(CONTEXT_UPDATE_EVENT, handleUpdate);
344
+ return () =>
345
+ document.removeEventListener(CONTEXT_UPDATE_EVENT, handleUpdate);
346
+ }, []);
362
347
 
363
348
  const Comp = asChild ? Slot : "div";
364
349
 
365
350
  return (
366
- <Comp
367
- ref={mergedRefs}
368
- {...rest}
369
- onFocusCapture={focusOutside.onFocusCapture}
370
- onBlurCapture={focusOutside.onBlurCapture}
371
- onPointerDownCapture={pointerDownOutside.onPointerDownCapture}
372
- style={{
373
- pointerEvents: pointerState.pointerStyle,
374
- ...rest.style,
375
- }}
376
- >
377
- {children}
378
- </Comp>
351
+ <BranchedLayerContext.Provider value={node}>
352
+ <Comp
353
+ {...restProps}
354
+ ref={mergedRefs}
355
+ style={{
356
+ pointerEvents: isBodyPointerEventsDisabled
357
+ ? shouldEnablePointerEvents
358
+ ? "auto"
359
+ : "none"
360
+ : undefined,
361
+ ...restProps.style,
362
+ }}
363
+ onFocusCapture={composeEventHandlers(
364
+ restProps.onFocusCapture,
365
+ focusOutside.onFocusCapture,
366
+ )}
367
+ onBlurCapture={composeEventHandlers(
368
+ restProps.onBlurCapture,
369
+ focusOutside.onBlurCapture,
370
+ )}
371
+ onPointerDownCapture={composeEventHandlers(
372
+ restProps.onPointerDownCapture,
373
+ pointerDownOutside.onPointerDownCapture,
374
+ )}
375
+ >
376
+ {children}
377
+ </Comp>
378
+ </BranchedLayerContext.Provider>
379
379
  );
380
380
  },
381
381
  );
382
382
 
383
+ /**
384
+ * Dispatches a custom event to inform all `DismissableLayer` components to update.
385
+ */
386
+ function dispatchUpdate() {
387
+ const event = new CustomEvent(CONTEXT_UPDATE_EVENT);
388
+ document.dispatchEvent(event);
389
+ }
390
+
391
+ /**
392
+ * Returns the index of the last layer that is found in the given subset.
393
+ * Returns -1 if no layers are found.
394
+ */
395
+ function findHighestLayerIndex(
396
+ orderedLayers: DismissableLayerElement[],
397
+ layersWithOutsidePointerEventsDisabled: Set<DismissableLayerElement>,
398
+ ): number {
399
+ for (let i = orderedLayers.length - 1; i >= 0; i -= 1) {
400
+ if (layersWithOutsidePointerEventsDisabled.has(orderedLayers[i])) {
401
+ return i;
402
+ }
403
+ }
404
+
405
+ return -1;
406
+ }
407
+
383
408
  export { DismissableLayer, type DismissableLayerProps };
@@ -0,0 +1,128 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { getSortedLayers } from "./sort-layers";
3
+
4
+ type DismissableLayerElement = HTMLDivElement;
5
+
6
+ describe("DismissableLayer: getSortedLayers", () => {
7
+ beforeAll(() => {
8
+ /* Reset id counter before tests to ensure consistent element ids */
9
+ idCounter = 0;
10
+ });
11
+
12
+ test("should return empty array when no layers", () => {
13
+ const layers = new Set<DismissableLayerElement>();
14
+ const branchedLayers = new Map();
15
+
16
+ const result = getSortedLayers(layers, branchedLayers);
17
+ expect(result).toEqual([]);
18
+ });
19
+
20
+ test("should return single layer", () => {
21
+ const layer = createTestElement();
22
+ const layers = new Set([layer]);
23
+ const branchedLayers = new Map();
24
+
25
+ const result = getSortedLayers(layers, branchedLayers);
26
+ expect(result).toEqual([layer]);
27
+ });
28
+
29
+ test("should return multiple independent layers in order", () => {
30
+ const layer1 = createTestElement();
31
+ const layer2 = createTestElement();
32
+ const layer3 = createTestElement();
33
+ const layers = new Set([layer1, layer2, layer3]);
34
+ const branchedLayers = new Map();
35
+
36
+ const result = getSortedLayers(layers, branchedLayers);
37
+ expect(result).toEqual([layer1, layer2, layer3]);
38
+ });
39
+
40
+ test("should sort parent before child", () => {
41
+ const parent = createTestElement();
42
+ const child = createTestElement();
43
+ const layers = new Set([child, parent]);
44
+ const branchedLayers = new Map([[parent, new Set([child])]]);
45
+
46
+ const result = getSortedLayers(layers, branchedLayers);
47
+ expect(result).toEqual([parent, child]);
48
+ });
49
+
50
+ test("should handle nested parent-child relationships", () => {
51
+ const grandparent = createTestElement();
52
+ const parent = createTestElement();
53
+ const child = createTestElement();
54
+ const layers = new Set([child, parent, grandparent]);
55
+ const branchedLayers = new Map([
56
+ [grandparent, new Set([parent])],
57
+ [parent, new Set([child])],
58
+ ]);
59
+
60
+ const result = getSortedLayers(layers, branchedLayers);
61
+ expect(result).toEqual([grandparent, parent, child]);
62
+ });
63
+
64
+ test("should handle multiple children of same parent", () => {
65
+ const parent = createTestElement();
66
+ const child1 = createTestElement();
67
+ const child2 = createTestElement();
68
+ const layers = new Set([child1, child2, parent]);
69
+ const branchedLayers = new Map([[parent, new Set([child1, child2])]]);
70
+
71
+ const result = getSortedLayers(layers, branchedLayers);
72
+ expect(result[0]).toBe(parent);
73
+ expect(result.slice(1)).toContain(child1);
74
+ expect(result.slice(1)).toContain(child2);
75
+ expect(result).toHaveLength(3);
76
+ });
77
+
78
+ test("should handle complex branched structure", () => {
79
+ const root = createTestElement();
80
+ const branch1 = createTestElement();
81
+ const branch2 = createTestElement();
82
+ const leaf1 = createTestElement();
83
+ const leaf2 = createTestElement();
84
+
85
+ const layers = new Set([root, branch1, branch2, leaf1, leaf2]);
86
+ const branchedLayers = new Map([
87
+ [root, new Set([branch1, branch2])],
88
+ [branch1, new Set([leaf1])],
89
+ [branch2, new Set([leaf2])],
90
+ ]);
91
+
92
+ const result = getSortedLayers(layers, branchedLayers);
93
+ expect(result[0]).toBe(root);
94
+ expect(result.indexOf(branch1)).toBeLessThan(result.indexOf(leaf1));
95
+ expect(result.indexOf(branch2)).toBeLessThan(result.indexOf(leaf2));
96
+ expect(result).toHaveLength(5);
97
+ });
98
+
99
+ test("should ignore self-referential children", () => {
100
+ const layer = createTestElement();
101
+ const layers = new Set([layer]);
102
+ const branchedLayers = new Map([[layer, new Set([layer])]]);
103
+
104
+ const result = getSortedLayers(layers, branchedLayers);
105
+ expect(result).toEqual([layer]);
106
+ });
107
+
108
+ test("should handle mixed independent and branched layers", () => {
109
+ const independent = createTestElement();
110
+ const parent = createTestElement();
111
+ const child = createTestElement();
112
+ const layers = new Set([independent, parent, child]);
113
+ const branchedLayers = new Map([[parent, new Set([child])]]);
114
+
115
+ const result = getSortedLayers(layers, branchedLayers);
116
+ expect(result).toContain(independent);
117
+ expect(result.indexOf(parent)).toBeLessThan(result.indexOf(child));
118
+ expect(result).toHaveLength(3);
119
+ });
120
+ });
121
+
122
+ let idCounter = 0;
123
+
124
+ function createTestElement() {
125
+ const element = document.createElement("div");
126
+ element.id = `layer-${idCounter++}`;
127
+ return element;
128
+ }