@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.
Files changed (47) hide show
  1. package/cjs/accordion/AccordionHeader.js +1 -1
  2. package/cjs/accordion/AccordionHeader.js.map +1 -1
  3. package/cjs/form/combobox/Input/ToggleListButton.js +2 -1
  4. package/cjs/form/combobox/Input/ToggleListButton.js.map +1 -1
  5. package/cjs/modal/Modal.js +12 -0
  6. package/cjs/modal/Modal.js.map +1 -1
  7. package/cjs/modal/ModalUtils.d.ts +3 -2
  8. package/cjs/modal/ModalUtils.js +60 -10
  9. package/cjs/modal/ModalUtils.js.map +1 -1
  10. package/cjs/util/detectBrowser.d.ts +3 -1
  11. package/cjs/util/detectBrowser.js +27 -1
  12. package/cjs/util/detectBrowser.js.map +1 -1
  13. package/cjs/util/hideNonTargetElements.d.ts +8 -0
  14. package/cjs/util/hideNonTargetElements.js +141 -0
  15. package/cjs/util/hideNonTargetElements.js.map +1 -0
  16. package/cjs/util/hooks/useScrollLock.d.ts +11 -0
  17. package/cjs/util/hooks/useScrollLock.js +270 -0
  18. package/cjs/util/hooks/useScrollLock.js.map +1 -0
  19. package/esm/accordion/AccordionHeader.js +1 -1
  20. package/esm/accordion/AccordionHeader.js.map +1 -1
  21. package/esm/form/combobox/Input/ToggleListButton.js +2 -1
  22. package/esm/form/combobox/Input/ToggleListButton.js.map +1 -1
  23. package/esm/modal/Modal.js +13 -1
  24. package/esm/modal/Modal.js.map +1 -1
  25. package/esm/modal/ModalUtils.d.ts +3 -2
  26. package/esm/modal/ModalUtils.js +27 -7
  27. package/esm/modal/ModalUtils.js.map +1 -1
  28. package/esm/util/detectBrowser.d.ts +3 -1
  29. package/esm/util/detectBrowser.js +25 -1
  30. package/esm/util/detectBrowser.js.map +1 -1
  31. package/esm/util/hideNonTargetElements.d.ts +8 -0
  32. package/esm/util/hideNonTargetElements.js +139 -0
  33. package/esm/util/hideNonTargetElements.js.map +1 -0
  34. package/esm/util/hooks/useScrollLock.d.ts +11 -0
  35. package/esm/util/hooks/useScrollLock.js +268 -0
  36. package/esm/util/hooks/useScrollLock.js.map +1 -0
  37. package/package.json +5 -5
  38. package/src/accordion/AccordionHeader.tsx +1 -1
  39. package/src/form/combobox/Input/ToggleListButton.tsx +2 -1
  40. package/src/form/combobox/__tests__/combobox.test.tsx +45 -106
  41. package/src/modal/Modal.test.tsx +13 -24
  42. package/src/modal/Modal.tsx +16 -0
  43. package/src/modal/ModalUtils.ts +35 -7
  44. package/src/util/__tests__/hideNonTargetElements.test.ts +147 -0
  45. package/src/util/detectBrowser.ts +41 -1
  46. package/src/util/hideNonTargetElements.ts +179 -0
  47. package/src/util/hooks/useScrollLock.ts +317 -0
@@ -1,5 +1,4 @@
1
- /* eslint-disable testing-library/no-unnecessary-act -- https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning */
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
- await act(async () => {
44
- await userEvent.click(
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 act(async () => {
67
- await userEvent.click(
68
- await screen.findByRole("button", { name: "apple slett" }),
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
- await act(async () => {
85
- await userEvent.click(
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
- await act(async () => {
110
- await userEvent.click(
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 bananaOption = screen.getByRole("option", {
113
+ const option = screen.getByRole("option", {
149
114
  name: "Hjelpemidler [HJE]",
150
115
  selected: false,
151
116
  });
152
- await act(async () => {
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 act(async () => {
179
- await userEvent.click(combobox);
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 act(async () => {
209
- await userEvent.click(combobox);
210
- await userEvent.type(combobox, "Syke");
211
- await userEvent.keyboard("{ArrowRight}");
212
- await userEvent.keyboard("{ArrowDown}");
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 matched anywhere in the label", async () => {
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 act(async () => {
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 act(async () => {
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 act(async () => {
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 act(async () => {
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 act(async () => {
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 act(async () => {
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 act(async () => {
356
- await userEvent.click(combobox);
357
- });
296
+ await userEvent.click(combobox);
358
297
 
359
298
  await pressKey("ArrowDown");
360
299
  hasVirtualFocus("apple");
@@ -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 body class", async () => {
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.body.classList).not.toContain(BODY_CLASS);
35
+ expect(document.documentElement.style.overflowX).toBe("hidden");
40
36
  });
41
37
  await waitFor(() => {
42
- expect(document.body.classList).not.toContain(BODY_CLASS_LEGACY);
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.getByRole("button"));
56
- await waitFor(() =>
57
- expect(document.body.classList).not.toContain(BODY_CLASS),
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
  });
@@ -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 =
@@ -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
- export function useBodyScrollLock(
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(BODY_CLASS, BODY_CLASS_LEGACY);
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(BODY_CLASS, BODY_CLASS_LEGACY);
84
+ ownerDoc.body.classList.add(BODY_CLASS_LEGACY);
59
85
  } else {
60
- ownerDoc.body.classList.remove(BODY_CLASS, BODY_CLASS_LEGACY);
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(BODY_CLASS, BODY_CLASS_LEGACY);
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
- export { isSafari };
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 };