@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.
- package/cjs/date/datepicker/hooks/useRangeDatepicker.js +2 -2
- package/cjs/date/datepicker/hooks/useRangeDatepicker.js.map +1 -1
- package/cjs/form/combobox/FilteredOptions/FilteredOptions.js +0 -1
- package/cjs/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
- package/cjs/overlays/action-menu/ActionMenu.js +2 -5
- package/cjs/overlays/action-menu/ActionMenu.js.map +1 -1
- package/cjs/overlays/dismissablelayer/DismissableLayer.d.ts +3 -30
- package/cjs/overlays/dismissablelayer/DismissableLayer.js +141 -134
- package/cjs/overlays/dismissablelayer/DismissableLayer.js.map +1 -1
- package/cjs/overlays/dismissablelayer/util/sort-layers.d.ts +18 -0
- package/cjs/overlays/dismissablelayer/util/sort-layers.js +51 -0
- package/cjs/overlays/dismissablelayer/util/sort-layers.js.map +1 -0
- package/cjs/overlays/floating-menu/Menu.d.ts +5 -7
- package/cjs/overlays/floating-menu/Menu.js +7 -15
- package/cjs/overlays/floating-menu/Menu.js.map +1 -1
- package/cjs/popover/Popover.js +0 -1
- package/cjs/popover/Popover.js.map +1 -1
- package/cjs/portal/Portal.d.ts +1 -3
- package/cjs/portal/Portal.js +49 -17
- package/cjs/portal/Portal.js.map +1 -1
- package/cjs/timeline/Timeline.d.ts +1 -1
- package/cjs/timeline/Timeline.js +1 -1
- package/cjs/timeline/Timeline.js.map +1 -1
- package/cjs/timeline/index.d.ts +1 -1
- package/cjs/timeline/index.js +1 -1
- package/cjs/timeline/index.js.map +1 -1
- package/cjs/timeline/pin/Pin.d.ts +9 -0
- package/cjs/timeline/pin/Pin.js +68 -0
- package/cjs/timeline/pin/Pin.js.map +1 -0
- package/cjs/timeline/pin/PinInternal.d.ts +13 -0
- package/cjs/timeline/{Pin.js → pin/PinInternal.js} +9 -10
- package/cjs/timeline/pin/PinInternal.js.map +1 -0
- package/cjs/tooltip/Tooltip.js +23 -22
- package/cjs/tooltip/Tooltip.js.map +1 -1
- package/cjs/util/focus-boundary/FocusBoundary.d.ts +19 -10
- package/cjs/util/focus-boundary/FocusBoundary.js +107 -63
- package/cjs/util/focus-boundary/FocusBoundary.js.map +1 -1
- package/cjs/util/hooks/useScrollLock.js +17 -3
- package/cjs/util/hooks/useScrollLock.js.map +1 -1
- package/esm/date/datepicker/hooks/useRangeDatepicker.js +2 -2
- package/esm/date/datepicker/hooks/useRangeDatepicker.js.map +1 -1
- package/esm/form/combobox/FilteredOptions/FilteredOptions.js +0 -1
- package/esm/form/combobox/FilteredOptions/FilteredOptions.js.map +1 -1
- package/esm/overlays/action-menu/ActionMenu.js +2 -5
- package/esm/overlays/action-menu/ActionMenu.js.map +1 -1
- package/esm/overlays/dismissablelayer/DismissableLayer.d.ts +3 -30
- package/esm/overlays/dismissablelayer/DismissableLayer.js +140 -132
- package/esm/overlays/dismissablelayer/DismissableLayer.js.map +1 -1
- package/esm/overlays/dismissablelayer/util/sort-layers.d.ts +18 -0
- package/esm/overlays/dismissablelayer/util/sort-layers.js +49 -0
- package/esm/overlays/dismissablelayer/util/sort-layers.js.map +1 -0
- package/esm/overlays/floating-menu/Menu.d.ts +5 -7
- package/esm/overlays/floating-menu/Menu.js +7 -15
- package/esm/overlays/floating-menu/Menu.js.map +1 -1
- package/esm/popover/Popover.js +0 -1
- package/esm/popover/Popover.js.map +1 -1
- package/esm/portal/Portal.d.ts +1 -3
- package/esm/portal/Portal.js +50 -18
- package/esm/portal/Portal.js.map +1 -1
- package/esm/timeline/Timeline.d.ts +1 -1
- package/esm/timeline/Timeline.js +1 -1
- package/esm/timeline/Timeline.js.map +1 -1
- package/esm/timeline/index.d.ts +1 -1
- package/esm/timeline/index.js +1 -1
- package/esm/timeline/index.js.map +1 -1
- package/esm/timeline/pin/Pin.d.ts +9 -0
- package/esm/timeline/pin/Pin.js +29 -0
- package/esm/timeline/pin/Pin.js.map +1 -0
- package/esm/timeline/pin/PinInternal.d.ts +13 -0
- package/esm/timeline/{Pin.js → pin/PinInternal.js} +8 -9
- package/esm/timeline/pin/PinInternal.js.map +1 -0
- package/esm/tooltip/Tooltip.js +23 -22
- package/esm/tooltip/Tooltip.js.map +1 -1
- package/esm/util/focus-boundary/FocusBoundary.d.ts +19 -10
- package/esm/util/focus-boundary/FocusBoundary.js +108 -64
- package/esm/util/focus-boundary/FocusBoundary.js.map +1 -1
- package/esm/util/hooks/useScrollLock.js +17 -3
- package/esm/util/hooks/useScrollLock.js.map +1 -1
- package/package.json +3 -3
- package/src/date/datepicker/hooks/useRangeDatepicker.tsx +4 -2
- package/src/form/combobox/FilteredOptions/FilteredOptions.tsx +0 -1
- package/src/overlays/action-menu/ActionMenu.tsx +2 -4
- package/src/overlays/dismissablelayer/DismissableLayer.tsx +219 -194
- package/src/overlays/dismissablelayer/util/sort-layers.test.ts +128 -0
- package/src/overlays/dismissablelayer/util/sort-layers.ts +61 -0
- package/src/overlays/floating-menu/Menu.tsx +11 -21
- package/src/popover/Popover.tsx +0 -1
- package/src/portal/Portal.tsx +89 -31
- package/src/timeline/Timeline.tsx +1 -1
- package/src/timeline/index.ts +1 -1
- package/src/timeline/pin/Pin.tsx +33 -0
- package/src/timeline/{Pin.tsx → pin/PinInternal.tsx} +8 -18
- package/src/tooltip/Tooltip.tsx +4 -4
- package/src/util/focus-boundary/FocusBoundary.tsx +164 -93
- package/src/util/hooks/useScrollLock.ts +22 -3
- package/cjs/timeline/Pin.d.ts +0 -17
- package/cjs/timeline/Pin.js.map +0 -1
- package/esm/timeline/Pin.d.ts +0 -17
- 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
|
-
*
|
|
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
|
-
(
|
|
91
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
111
|
+
const DismissableLayerInternal = forwardRef<
|
|
112
|
+
HTMLDivElement,
|
|
113
|
+
DismissableLayerProps
|
|
114
|
+
>(
|
|
124
115
|
(
|
|
125
116
|
{
|
|
126
117
|
children,
|
|
127
|
-
|
|
118
|
+
disableOutsidePointerEvents,
|
|
119
|
+
onDismiss,
|
|
120
|
+
onInteractOutside,
|
|
128
121
|
onEscapeKeyDown,
|
|
129
|
-
onPointerDownOutside,
|
|
130
122
|
onFocusOutside,
|
|
131
|
-
|
|
132
|
-
onDismiss,
|
|
123
|
+
onPointerDownOutside,
|
|
133
124
|
safeZone,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
...rest
|
|
125
|
+
asChild,
|
|
126
|
+
...restProps
|
|
137
127
|
}: DismissableLayerProps,
|
|
138
|
-
|
|
128
|
+
forwardedRef,
|
|
139
129
|
) => {
|
|
140
|
-
const
|
|
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
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
168
|
+
hasPointerDownOutside = true;
|
|
214
169
|
}
|
|
215
170
|
}
|
|
216
171
|
|
|
217
172
|
const target = event.target as HTMLElement;
|
|
218
173
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
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 ===
|
|
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
|
-
*
|
|
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
|
|
263
|
+
if (!node) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
341
266
|
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
274
|
+
context.layers.add(node);
|
|
275
|
+
dispatchUpdate();
|
|
276
|
+
|
|
347
277
|
return () => {
|
|
348
|
-
if (
|
|
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,
|
|
285
|
+
}, [node, disableOutsidePointerEvents, context, ownerDoc]);
|
|
354
286
|
|
|
355
287
|
/**
|
|
356
|
-
*
|
|
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 () =>
|
|
361
|
-
|
|
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
|
-
<
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
+
}
|