@navikt/ds-react 7.32.3 → 7.32.5
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/accordion/AccordionHeader.js +1 -1
- package/cjs/accordion/AccordionHeader.js.map +1 -1
- package/cjs/form/combobox/Input/ToggleListButton.js +2 -1
- package/cjs/form/combobox/Input/ToggleListButton.js.map +1 -1
- package/cjs/modal/Modal.js +12 -0
- package/cjs/modal/Modal.js.map +1 -1
- package/cjs/modal/ModalUtils.d.ts +3 -2
- package/cjs/modal/ModalUtils.js +60 -10
- package/cjs/modal/ModalUtils.js.map +1 -1
- package/cjs/util/detectBrowser.d.ts +3 -1
- package/cjs/util/detectBrowser.js +27 -1
- package/cjs/util/detectBrowser.js.map +1 -1
- package/cjs/util/hideNonTargetElements.d.ts +8 -0
- package/cjs/util/hideNonTargetElements.js +141 -0
- package/cjs/util/hideNonTargetElements.js.map +1 -0
- package/cjs/util/hooks/useScrollLock.d.ts +11 -0
- package/cjs/util/hooks/useScrollLock.js +270 -0
- package/cjs/util/hooks/useScrollLock.js.map +1 -0
- package/esm/accordion/AccordionHeader.js +1 -1
- package/esm/accordion/AccordionHeader.js.map +1 -1
- package/esm/form/combobox/Input/ToggleListButton.js +2 -1
- package/esm/form/combobox/Input/ToggleListButton.js.map +1 -1
- package/esm/modal/Modal.js +13 -1
- package/esm/modal/Modal.js.map +1 -1
- package/esm/modal/ModalUtils.d.ts +3 -2
- package/esm/modal/ModalUtils.js +27 -7
- package/esm/modal/ModalUtils.js.map +1 -1
- package/esm/util/detectBrowser.d.ts +3 -1
- package/esm/util/detectBrowser.js +25 -1
- package/esm/util/detectBrowser.js.map +1 -1
- package/esm/util/hideNonTargetElements.d.ts +8 -0
- package/esm/util/hideNonTargetElements.js +139 -0
- package/esm/util/hideNonTargetElements.js.map +1 -0
- package/esm/util/hooks/useScrollLock.d.ts +11 -0
- package/esm/util/hooks/useScrollLock.js +268 -0
- package/esm/util/hooks/useScrollLock.js.map +1 -0
- package/package.json +5 -5
- package/src/accordion/AccordionHeader.tsx +1 -1
- package/src/form/combobox/Input/ToggleListButton.tsx +2 -1
- package/src/form/combobox/__tests__/combobox.test.tsx +45 -106
- package/src/modal/Modal.test.tsx +13 -24
- package/src/modal/Modal.tsx +16 -0
- package/src/modal/ModalUtils.ts +35 -7
- package/src/util/__tests__/hideNonTargetElements.test.ts +147 -0
- package/src/util/detectBrowser.ts +41 -1
- package/src/util/hideNonTargetElements.ts +179 -0
- package/src/util/hooks/useScrollLock.ts +317 -0
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
import { act, render, screen } from "@testing-library/react";
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
3
2
|
import userEvent from "@testing-library/user-event";
|
|
4
3
|
import React from "react";
|
|
5
4
|
import { describe, expect, test, vi } from "vitest";
|
|
@@ -40,34 +39,20 @@ describe("Render combobox", () => {
|
|
|
40
39
|
test("Should be able to search, select and remove selections", async () => {
|
|
41
40
|
render(<App isMultiSelect options={options} />);
|
|
42
41
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
screen.getByRole("combobox", {
|
|
46
|
-
name: "Hva er dine favorittfrukter?",
|
|
47
|
-
}),
|
|
48
|
-
);
|
|
49
|
-
});
|
|
50
|
-
await act(async () => {
|
|
51
|
-
await userEvent.type(
|
|
52
|
-
screen.getByRole("combobox", {
|
|
53
|
-
name: "Hva er dine favorittfrukter?",
|
|
54
|
-
}),
|
|
55
|
-
"apple",
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
await act(async () => {
|
|
59
|
-
await userEvent.click(
|
|
60
|
-
await screen.findByRole("option", { name: "apple" }),
|
|
61
|
-
);
|
|
42
|
+
const input = screen.getByRole("combobox", {
|
|
43
|
+
name: "Hva er dine favorittfrukter?",
|
|
62
44
|
});
|
|
45
|
+
await userEvent.click(input);
|
|
46
|
+
await userEvent.type(input, "apple");
|
|
47
|
+
await userEvent.click(
|
|
48
|
+
await screen.findByRole("option", { name: "apple" }),
|
|
49
|
+
);
|
|
63
50
|
expect(
|
|
64
51
|
await screen.findByRole("option", { name: "apple", selected: true }),
|
|
65
52
|
).toBeInTheDocument();
|
|
66
|
-
await
|
|
67
|
-
await
|
|
68
|
-
|
|
69
|
-
);
|
|
70
|
-
});
|
|
53
|
+
await userEvent.click(
|
|
54
|
+
await screen.findByRole("button", { name: "apple slett" }),
|
|
55
|
+
);
|
|
71
56
|
});
|
|
72
57
|
});
|
|
73
58
|
|
|
@@ -81,24 +66,14 @@ describe("Render combobox", () => {
|
|
|
81
66
|
test("Should not select previous focused element when closes", async () => {
|
|
82
67
|
render(<App options={options} />);
|
|
83
68
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
screen.getByRole("combobox", {
|
|
87
|
-
name: "Hva er dine favorittfrukter?",
|
|
88
|
-
}),
|
|
89
|
-
);
|
|
90
|
-
});
|
|
91
|
-
await act(async () => {
|
|
92
|
-
await userEvent.type(
|
|
93
|
-
screen.getByRole("combobox", {
|
|
94
|
-
name: "Hva er dine favorittfrukter?",
|
|
95
|
-
}),
|
|
96
|
-
"ban",
|
|
97
|
-
);
|
|
98
|
-
await userEvent.keyboard("{ArrowDown}");
|
|
99
|
-
await userEvent.keyboard("{ArrowUp}");
|
|
100
|
-
await userEvent.keyboard("{Enter}");
|
|
69
|
+
const input = screen.getByRole("combobox", {
|
|
70
|
+
name: "Hva er dine favorittfrukter?",
|
|
101
71
|
});
|
|
72
|
+
await userEvent.click(input);
|
|
73
|
+
await userEvent.type(input, "ban");
|
|
74
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
75
|
+
await userEvent.keyboard("{ArrowUp}");
|
|
76
|
+
await userEvent.keyboard("{Enter}");
|
|
102
77
|
|
|
103
78
|
expect(screen.queryByRole("button", { name: "banana slett" })).toBeNull();
|
|
104
79
|
});
|
|
@@ -106,24 +81,14 @@ describe("Render combobox", () => {
|
|
|
106
81
|
test("Should reset list when resetting input (ESC)", async () => {
|
|
107
82
|
render(<App options={options} />);
|
|
108
83
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
screen.getByRole("combobox", {
|
|
112
|
-
name: "Hva er dine favorittfrukter?",
|
|
113
|
-
}),
|
|
114
|
-
);
|
|
115
|
-
});
|
|
116
|
-
await act(async () => {
|
|
117
|
-
await userEvent.type(
|
|
118
|
-
screen.getByRole("combobox", {
|
|
119
|
-
name: "Hva er dine favorittfrukter?",
|
|
120
|
-
}),
|
|
121
|
-
"apple",
|
|
122
|
-
);
|
|
123
|
-
await userEvent.keyboard("{ArrowDown}");
|
|
124
|
-
await userEvent.keyboard("{Escape}");
|
|
125
|
-
await userEvent.keyboard("{ArrowDown}");
|
|
84
|
+
const input = screen.getByRole("combobox", {
|
|
85
|
+
name: "Hva er dine favorittfrukter?",
|
|
126
86
|
});
|
|
87
|
+
await userEvent.click(input);
|
|
88
|
+
await userEvent.type(input, "apple");
|
|
89
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
90
|
+
await userEvent.keyboard("{Escape}");
|
|
91
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
127
92
|
|
|
128
93
|
expect(
|
|
129
94
|
await screen.findByRole("option", { name: "banana" }),
|
|
@@ -145,13 +110,11 @@ describe("Render combobox", () => {
|
|
|
145
110
|
);
|
|
146
111
|
|
|
147
112
|
expect(screen.getByRole("combobox")).toBeInTheDocument();
|
|
148
|
-
const
|
|
113
|
+
const option = screen.getByRole("option", {
|
|
149
114
|
name: "Hjelpemidler [HJE]",
|
|
150
115
|
selected: false,
|
|
151
116
|
});
|
|
152
|
-
await
|
|
153
|
-
await userEvent.click(bananaOption);
|
|
154
|
-
});
|
|
117
|
+
await userEvent.click(option);
|
|
155
118
|
expect(onToggleSelected).toHaveBeenCalledWith("HJE", true, false);
|
|
156
119
|
expect(
|
|
157
120
|
screen.getByRole("option", {
|
|
@@ -175,10 +138,8 @@ describe("Render combobox", () => {
|
|
|
175
138
|
const combobox = screen.getByRole("combobox");
|
|
176
139
|
expect(combobox).toBeInTheDocument();
|
|
177
140
|
|
|
178
|
-
await
|
|
179
|
-
|
|
180
|
-
await userEvent.type(combobox, "Lemon");
|
|
181
|
-
});
|
|
141
|
+
await userEvent.click(combobox);
|
|
142
|
+
await userEvent.type(combobox, "Lemon");
|
|
182
143
|
expect(onChange).toHaveBeenNthCalledWith(1, "L");
|
|
183
144
|
expect(onChange).toHaveBeenNthCalledWith(2, "Le");
|
|
184
145
|
expect(onChange).toHaveBeenNthCalledWith(3, "Lem");
|
|
@@ -205,13 +166,11 @@ describe("Render combobox", () => {
|
|
|
205
166
|
const combobox = screen.getByRole("combobox");
|
|
206
167
|
expect(combobox).toBeInTheDocument();
|
|
207
168
|
|
|
208
|
-
await
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
await userEvent.keyboard("{Enter}");
|
|
214
|
-
});
|
|
169
|
+
await userEvent.click(combobox);
|
|
170
|
+
await userEvent.type(combobox, "Syke");
|
|
171
|
+
await userEvent.keyboard("{ArrowRight}");
|
|
172
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
173
|
+
await userEvent.keyboard("{Enter}");
|
|
215
174
|
expect(onChange).toHaveBeenNthCalledWith(1, "S");
|
|
216
175
|
expect(onChange).toHaveBeenNthCalledWith(2, "Sy");
|
|
217
176
|
expect(onChange).toHaveBeenNthCalledWith(3, "Syk");
|
|
@@ -228,18 +187,14 @@ describe("Render combobox", () => {
|
|
|
228
187
|
});
|
|
229
188
|
|
|
230
189
|
describe("search", () => {
|
|
231
|
-
test("should find
|
|
190
|
+
test("should find matches anywhere in the label", async () => {
|
|
232
191
|
render(<App options={options} />);
|
|
233
192
|
|
|
234
193
|
const combobox = screen.getByRole("combobox", {
|
|
235
194
|
name: "Hva er dine favorittfrukter?",
|
|
236
195
|
});
|
|
237
|
-
|
|
238
|
-
await
|
|
239
|
-
await userEvent.click(combobox);
|
|
240
|
-
|
|
241
|
-
await userEvent.type(combobox, "p");
|
|
242
|
-
});
|
|
196
|
+
await userEvent.click(combobox);
|
|
197
|
+
await userEvent.type(combobox, "p");
|
|
243
198
|
|
|
244
199
|
const searchHits = [
|
|
245
200
|
"apple",
|
|
@@ -273,18 +228,12 @@ describe("Render combobox", () => {
|
|
|
273
228
|
const combobox = screen.getByRole("combobox", {
|
|
274
229
|
name: "Hva er dine favorittfrukter?",
|
|
275
230
|
});
|
|
276
|
-
|
|
277
|
-
await
|
|
278
|
-
await userEvent.click(combobox);
|
|
279
|
-
|
|
280
|
-
await userEvent.type(combobox, "p");
|
|
281
|
-
});
|
|
231
|
+
await userEvent.click(combobox);
|
|
232
|
+
await userEvent.type(combobox, "p");
|
|
282
233
|
|
|
283
234
|
expect(combobox.getAttribute("value")).toBe("passion fruit");
|
|
284
235
|
|
|
285
|
-
await
|
|
286
|
-
await userEvent.keyboard("{Enter}");
|
|
287
|
-
});
|
|
236
|
+
await userEvent.keyboard("{Enter}");
|
|
288
237
|
|
|
289
238
|
expect(onToggleSelected).toHaveBeenCalledWith(
|
|
290
239
|
"passion fruit",
|
|
@@ -310,20 +259,14 @@ describe("Render combobox", () => {
|
|
|
310
259
|
const combobox = screen.getByRole("combobox", {
|
|
311
260
|
name: "Hva er dine favorittfrukter?",
|
|
312
261
|
});
|
|
313
|
-
|
|
314
|
-
await
|
|
315
|
-
await userEvent.click(combobox);
|
|
316
|
-
|
|
317
|
-
await userEvent.type(combobox, "p");
|
|
318
|
-
});
|
|
262
|
+
await userEvent.click(combobox);
|
|
263
|
+
await userEvent.type(combobox, "p");
|
|
319
264
|
|
|
320
265
|
expect(combobox.getAttribute("value")).toBe(
|
|
321
266
|
"passion fruit (passion fruit)",
|
|
322
267
|
);
|
|
323
268
|
|
|
324
|
-
await
|
|
325
|
-
await userEvent.keyboard("{Enter}");
|
|
326
|
-
});
|
|
269
|
+
await userEvent.keyboard("{Enter}");
|
|
327
270
|
|
|
328
271
|
expect(onToggleSelected).toHaveBeenCalledWith(
|
|
329
272
|
"passion fruit",
|
|
@@ -342,9 +285,7 @@ describe("Render combobox", () => {
|
|
|
342
285
|
});
|
|
343
286
|
|
|
344
287
|
const pressKey = async (key: string) => {
|
|
345
|
-
await
|
|
346
|
-
await userEvent.keyboard(`{${key}}`);
|
|
347
|
-
});
|
|
288
|
+
await userEvent.keyboard(`{${key}}`);
|
|
348
289
|
};
|
|
349
290
|
|
|
350
291
|
const hasVirtualFocus = (option: string) =>
|
|
@@ -352,9 +293,7 @@ describe("Render combobox", () => {
|
|
|
352
293
|
screen.getByRole("option", { name: option }).id,
|
|
353
294
|
);
|
|
354
295
|
|
|
355
|
-
await
|
|
356
|
-
await userEvent.click(combobox);
|
|
357
|
-
});
|
|
296
|
+
await userEvent.click(combobox);
|
|
358
297
|
|
|
359
298
|
await pressKey("ArrowDown");
|
|
360
299
|
hasVirtualFocus("apple");
|
package/src/modal/Modal.test.tsx
CHANGED
|
@@ -2,7 +2,6 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
|
2
2
|
import React, { useState } from "react";
|
|
3
3
|
import { describe, expect, test } from "vitest";
|
|
4
4
|
import { Button, Modal } from "..";
|
|
5
|
-
import { BODY_CLASS, BODY_CLASS_LEGACY } from "./ModalUtils";
|
|
6
5
|
|
|
7
6
|
const Test = () => {
|
|
8
7
|
const [open, setOpen] = useState(true);
|
|
@@ -29,35 +28,25 @@ describe("Modal", () => {
|
|
|
29
28
|
expect(screen.getByText("Foobar")).not.toBeVisible();
|
|
30
29
|
});
|
|
31
30
|
|
|
32
|
-
test("should toggle
|
|
31
|
+
test("should toggle scroll lock", async () => {
|
|
33
32
|
render(<Test />);
|
|
34
|
-
expect(document.body.classList).toContain(BODY_CLASS);
|
|
35
|
-
expect(document.body.classList).toContain(BODY_CLASS_LEGACY);
|
|
36
33
|
|
|
37
|
-
fireEvent.click(screen.getByText("Close"));
|
|
38
34
|
await waitFor(() => {
|
|
39
|
-
expect(document.
|
|
35
|
+
expect(document.documentElement.style.overflowX).toBe("hidden");
|
|
40
36
|
});
|
|
41
37
|
await waitFor(() => {
|
|
42
|
-
expect(document.
|
|
38
|
+
expect(document.documentElement.style.overflowY).toBe("hidden");
|
|
39
|
+
});
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(document.documentElement.style.scrollBehavior).toBe("unset");
|
|
42
|
+
});
|
|
43
|
+
await waitFor(() => {
|
|
44
|
+
expect(document.documentElement.style.scrollbarGutter).toBe("stable");
|
|
43
45
|
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("should toggle body class when using portal", async () => {
|
|
47
|
-
render(
|
|
48
|
-
<Modal portal open onClose={() => null} aria-label="Test">
|
|
49
|
-
<Modal.Header />
|
|
50
|
-
</Modal>,
|
|
51
|
-
);
|
|
52
|
-
expect(document.body.classList).toContain(BODY_CLASS);
|
|
53
|
-
expect(document.body.classList).toContain(BODY_CLASS_LEGACY);
|
|
54
46
|
|
|
55
|
-
fireEvent.click(screen.
|
|
56
|
-
await waitFor(() =>
|
|
57
|
-
expect(document.
|
|
58
|
-
);
|
|
59
|
-
await waitFor(() =>
|
|
60
|
-
expect(document.body.classList).not.toContain(BODY_CLASS_LEGACY),
|
|
61
|
-
);
|
|
47
|
+
fireEvent.click(screen.getByText("Close"));
|
|
48
|
+
await waitFor(() => {
|
|
49
|
+
expect(document.documentElement.style.cssText).toBe("");
|
|
50
|
+
});
|
|
62
51
|
});
|
|
63
52
|
});
|
package/src/modal/Modal.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { Detail, Heading } from "../typography";
|
|
|
8
8
|
import { composeEventHandlers } from "../util/composeEventHandlers";
|
|
9
9
|
import { useId } from "../util/hooks";
|
|
10
10
|
import { useMergeRefs } from "../util/hooks/useMergeRefs";
|
|
11
|
+
import { useScrollLock } from "../util/hooks/useScrollLock";
|
|
11
12
|
import { ModalContextProvider, useModalContext } from "./Modal.context";
|
|
12
13
|
import ModalBody from "./ModalBody";
|
|
13
14
|
import ModalFooter from "./ModalFooter";
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
coordsAreInside,
|
|
18
19
|
getCloseHandler,
|
|
19
20
|
useBodyScrollLock,
|
|
21
|
+
useIsModalOpen,
|
|
20
22
|
} from "./ModalUtils";
|
|
21
23
|
import dialogPolyfill, { needPolyfill } from "./dialog-polyfill";
|
|
22
24
|
import { ModalProps } from "./types";
|
|
@@ -110,6 +112,9 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
|
|
|
110
112
|
|
|
111
113
|
const dateContext = useDateInputContext(false);
|
|
112
114
|
const isNested = useModalContext(false) !== undefined;
|
|
115
|
+
|
|
116
|
+
const isModalOpen = useIsModalOpen(modalRef.current);
|
|
117
|
+
|
|
113
118
|
if (isNested && !dateContext) {
|
|
114
119
|
console.error("Modals should not be nested");
|
|
115
120
|
}
|
|
@@ -144,6 +149,17 @@ export const Modal = forwardRef<HTMLDialogElement, ModalProps>(
|
|
|
144
149
|
}
|
|
145
150
|
}, [portalNode, open]);
|
|
146
151
|
|
|
152
|
+
useScrollLock({
|
|
153
|
+
enabled: isModalOpen,
|
|
154
|
+
mounted: isModalOpen,
|
|
155
|
+
open: isModalOpen,
|
|
156
|
+
referenceElement: modalRef.current,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* TODO: Kept for legacy support.
|
|
161
|
+
* - Remove utility in v8 and deprecate body-classes in ModalUtils.ts
|
|
162
|
+
*/
|
|
147
163
|
useBodyScrollLock(modalRef, portalNode, isNested);
|
|
148
164
|
|
|
149
165
|
const isWidthPreset =
|
package/src/modal/ModalUtils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
2
|
import { ownerDocument } from "../util/owner";
|
|
3
3
|
import type { ModalProps } from "./types";
|
|
4
4
|
|
|
@@ -28,10 +28,36 @@ export function getCloseHandler(
|
|
|
28
28
|
return () => modalRef.current?.close();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function useIsModalOpen(modalRef: HTMLDialogElement | null) {
|
|
32
|
+
const [isOpen, setIsOpen] = React.useState<boolean>(false);
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!modalRef) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setIsOpen(modalRef.open);
|
|
40
|
+
|
|
41
|
+
const observer = new MutationObserver(() => {
|
|
42
|
+
setIsOpen(modalRef.open);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
observer.observe(modalRef, {
|
|
46
|
+
attributes: true,
|
|
47
|
+
attributeFilter: ["open"],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return () => {
|
|
51
|
+
observer.disconnect();
|
|
52
|
+
};
|
|
53
|
+
}, [modalRef]);
|
|
54
|
+
|
|
55
|
+
return isOpen;
|
|
56
|
+
}
|
|
57
|
+
|
|
31
58
|
export const BODY_CLASS_LEGACY = "navds-modal__document-body";
|
|
32
|
-
export const BODY_CLASS = "aksel-modal__document-body";
|
|
33
59
|
|
|
34
|
-
|
|
60
|
+
function useBodyScrollLock(
|
|
35
61
|
modalRef: React.RefObject<HTMLDialogElement | null>,
|
|
36
62
|
portalNode: HTMLElement | null,
|
|
37
63
|
isNested: boolean,
|
|
@@ -50,14 +76,14 @@ export function useBodyScrollLock(
|
|
|
50
76
|
|
|
51
77
|
// In case `open` is true initially
|
|
52
78
|
if (modalRef.current.open) {
|
|
53
|
-
ownerDoc.body.classList.add(
|
|
79
|
+
ownerDoc.body.classList.add(BODY_CLASS_LEGACY);
|
|
54
80
|
}
|
|
55
81
|
|
|
56
82
|
const observer = new MutationObserver(() => {
|
|
57
83
|
if (modalRef.current?.open) {
|
|
58
|
-
ownerDoc.body.classList.add(
|
|
84
|
+
ownerDoc.body.classList.add(BODY_CLASS_LEGACY);
|
|
59
85
|
} else {
|
|
60
|
-
ownerDoc.body.classList.remove(
|
|
86
|
+
ownerDoc.body.classList.remove(BODY_CLASS_LEGACY);
|
|
61
87
|
}
|
|
62
88
|
});
|
|
63
89
|
|
|
@@ -69,7 +95,9 @@ export function useBodyScrollLock(
|
|
|
69
95
|
return () => {
|
|
70
96
|
observer.disconnect();
|
|
71
97
|
// In case modal is unmounted before it's closed
|
|
72
|
-
ownerDoc.body.classList.remove(
|
|
98
|
+
ownerDoc.body.classList.remove(BODY_CLASS_LEGACY);
|
|
73
99
|
};
|
|
74
100
|
}, [modalRef, portalNode, isNested]);
|
|
75
101
|
}
|
|
102
|
+
|
|
103
|
+
export { useIsModalOpen, useBodyScrollLock };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "vitest";
|
|
2
|
+
import { hideNonTargetElements } from "../hideNonTargetElements";
|
|
3
|
+
|
|
4
|
+
describe("hideNonTargetElements util", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
document.body.innerHTML = "";
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("marks non-avoided siblings with marker and cleans up on undo", () => {
|
|
10
|
+
document.body.innerHTML = `
|
|
11
|
+
<div id="root">
|
|
12
|
+
<div id="target"></div>
|
|
13
|
+
<div id="hidden-1"></div>
|
|
14
|
+
<div aria-live="polite" id="live-region"></div>
|
|
15
|
+
<script id="script-el"></script>
|
|
16
|
+
</div>
|
|
17
|
+
`;
|
|
18
|
+
const target = document.getElementById("target") as Element;
|
|
19
|
+
const hidden1 = document.getElementById("hidden-1") as Element;
|
|
20
|
+
const live = document.getElementById("live-region") as Element;
|
|
21
|
+
const script = document.getElementById("script-el") as Element;
|
|
22
|
+
|
|
23
|
+
const undo = hideNonTargetElements([target]);
|
|
24
|
+
|
|
25
|
+
expect(hidden1.hasAttribute("data-aksel-hidden")).toBe(true);
|
|
26
|
+
expect(live.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
27
|
+
expect(script.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
28
|
+
expect(target.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
29
|
+
|
|
30
|
+
undo();
|
|
31
|
+
|
|
32
|
+
expect(hidden1.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
33
|
+
expect(live.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
34
|
+
expect(script.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
35
|
+
expect(target.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("nested: marks non-avoided siblings with marker and cleans up on undo", () => {
|
|
39
|
+
document.body.innerHTML = `
|
|
40
|
+
<div id="root">
|
|
41
|
+
<div id="inner">
|
|
42
|
+
<div id="target"></div>
|
|
43
|
+
<div id="hidden-1"></div>
|
|
44
|
+
</div>
|
|
45
|
+
<div id="hidden-2"></div>
|
|
46
|
+
</div>
|
|
47
|
+
`;
|
|
48
|
+
const target = document.getElementById("target") as Element;
|
|
49
|
+
const hidden1 = document.getElementById("hidden-1") as Element;
|
|
50
|
+
const hidden2 = document.getElementById("hidden-2") as Element;
|
|
51
|
+
|
|
52
|
+
const undo = hideNonTargetElements([target]);
|
|
53
|
+
|
|
54
|
+
expect(hidden1.hasAttribute("data-aksel-hidden")).toBe(true);
|
|
55
|
+
expect(hidden2.hasAttribute("data-aksel-hidden")).toBe(true);
|
|
56
|
+
expect(target.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
57
|
+
|
|
58
|
+
undo();
|
|
59
|
+
|
|
60
|
+
expect(hidden1.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
61
|
+
expect(hidden2.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
62
|
+
expect(target.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("applies aria-hidden when requested and restores previous state", () => {
|
|
66
|
+
document.body.innerHTML = `
|
|
67
|
+
<div id="root">
|
|
68
|
+
<div id="target"></div>
|
|
69
|
+
<div id="hidden-2"></div>
|
|
70
|
+
<div id="pre-hidden" aria-hidden="true"></div>
|
|
71
|
+
</div>
|
|
72
|
+
`;
|
|
73
|
+
const target = document.getElementById("target") as Element;
|
|
74
|
+
const hidden2 = document.getElementById("hidden-2") as Element;
|
|
75
|
+
const preHidden = document.getElementById("pre-hidden") as Element;
|
|
76
|
+
|
|
77
|
+
const undo = hideNonTargetElements([target]);
|
|
78
|
+
|
|
79
|
+
expect(hidden2.getAttribute("aria-hidden")).toBe("true");
|
|
80
|
+
expect(preHidden.getAttribute("aria-hidden")).toBe("true");
|
|
81
|
+
expect(hidden2.hasAttribute("data-aksel-hidden")).toBe(true);
|
|
82
|
+
expect(preHidden.hasAttribute("data-aksel-hidden")).toBe(true);
|
|
83
|
+
|
|
84
|
+
undo();
|
|
85
|
+
|
|
86
|
+
expect(hidden2.hasAttribute("aria-hidden")).toBe(false);
|
|
87
|
+
expect(preHidden.getAttribute("aria-hidden")).toBe("true");
|
|
88
|
+
expect(hidden2.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
89
|
+
expect(preHidden.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("treats shadow-hosted avoid elements as connected to host", () => {
|
|
93
|
+
document.body.innerHTML = `
|
|
94
|
+
<div id="root"></div>
|
|
95
|
+
`;
|
|
96
|
+
const root = document.getElementById("root") as HTMLElement;
|
|
97
|
+
|
|
98
|
+
const host = document.createElement("div");
|
|
99
|
+
host.id = "shadow-host";
|
|
100
|
+
const shadow = host.attachShadow({ mode: "open" });
|
|
101
|
+
const shadowTarget = document.createElement("button");
|
|
102
|
+
shadowTarget.id = "shadow-target";
|
|
103
|
+
shadow.appendChild(shadowTarget);
|
|
104
|
+
|
|
105
|
+
const sibling = document.createElement("div");
|
|
106
|
+
sibling.id = "outside";
|
|
107
|
+
|
|
108
|
+
root.append(host, sibling);
|
|
109
|
+
|
|
110
|
+
const undo = hideNonTargetElements([shadowTarget]);
|
|
111
|
+
|
|
112
|
+
expect(sibling.hasAttribute("data-aksel-hidden")).toBe(true);
|
|
113
|
+
expect(host.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
114
|
+
|
|
115
|
+
undo();
|
|
116
|
+
|
|
117
|
+
expect(sibling.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
118
|
+
expect(host.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("maintains counters across nested calls until final undo", () => {
|
|
122
|
+
document.body.innerHTML = `
|
|
123
|
+
<div id="root">
|
|
124
|
+
<div id="target-a"></div>
|
|
125
|
+
<div id="target-b"></div>
|
|
126
|
+
<div id="shared"></div>
|
|
127
|
+
</div>
|
|
128
|
+
`;
|
|
129
|
+
const targetA = document.getElementById("target-a") as Element;
|
|
130
|
+
const targetB = document.getElementById("target-b") as Element;
|
|
131
|
+
const shared = document.getElementById("shared") as Element;
|
|
132
|
+
|
|
133
|
+
const undoA = hideNonTargetElements([targetA]);
|
|
134
|
+
expect(shared.hasAttribute("data-aksel-hidden")).toBe(true);
|
|
135
|
+
|
|
136
|
+
const undoB = hideNonTargetElements([targetB]);
|
|
137
|
+
expect(shared.hasAttribute("data-aksel-hidden")).toBe(true);
|
|
138
|
+
|
|
139
|
+
undoB();
|
|
140
|
+
expect(shared.hasAttribute("data-aksel-hidden")).toBe(true);
|
|
141
|
+
expect(shared.getAttribute("aria-hidden")).toBe("true");
|
|
142
|
+
|
|
143
|
+
undoA();
|
|
144
|
+
expect(shared.hasAttribute("data-aksel-hidden")).toBe(false);
|
|
145
|
+
expect(shared.hasAttribute("aria-hidden")).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
const hasNavigator = typeof navigator !== "undefined";
|
|
2
2
|
|
|
3
|
+
interface NavigatorUAData {
|
|
4
|
+
brands: { brand: string; version: string }[];
|
|
5
|
+
mobile: boolean;
|
|
6
|
+
platform: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getNavigatorData(): { platform: string; maxTouchPoints: number } {
|
|
10
|
+
if (!hasNavigator) {
|
|
11
|
+
return { platform: "", maxTouchPoints: -1 };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const uaData = (navigator as any).userAgentData as
|
|
15
|
+
| NavigatorUAData
|
|
16
|
+
| undefined;
|
|
17
|
+
|
|
18
|
+
if (uaData?.platform) {
|
|
19
|
+
return {
|
|
20
|
+
platform: uaData.platform,
|
|
21
|
+
maxTouchPoints: navigator.maxTouchPoints,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
platform: navigator.platform ?? "",
|
|
27
|
+
maxTouchPoints: navigator.maxTouchPoints ?? -1,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const nav = getNavigatorData();
|
|
32
|
+
|
|
3
33
|
const isSafari = hasNavigator && /apple/i.test(navigator.vendor);
|
|
4
34
|
|
|
5
|
-
|
|
35
|
+
const isWebKit =
|
|
36
|
+
typeof CSS === "undefined" || !CSS.supports
|
|
37
|
+
? false
|
|
38
|
+
: CSS.supports("-webkit-backdrop-filter:none");
|
|
39
|
+
|
|
40
|
+
const isIOS =
|
|
41
|
+
nav.platform === "MacIntel" && nav.maxTouchPoints > 1
|
|
42
|
+
? true
|
|
43
|
+
: /iP(hone|ad|od)|iOS/.test(nav.platform);
|
|
44
|
+
|
|
45
|
+
export { isSafari, isWebKit, isIOS };
|