@reshaped/utilities 3.9.1-canary.3 → 3.10.0-canary.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 (93) hide show
  1. package/README.md +232 -0
  2. package/dist/a11y/Chain.d.ts +20 -0
  3. package/dist/a11y/Chain.js +60 -0
  4. package/dist/a11y/TrapFocus.d.ts +28 -0
  5. package/dist/a11y/TrapFocus.js +162 -0
  6. package/dist/a11y/TrapScreenReader.d.ts +15 -0
  7. package/dist/a11y/TrapScreenReader.js +42 -0
  8. package/dist/a11y/focus.d.ts +38 -0
  9. package/dist/a11y/focus.js +101 -0
  10. package/dist/a11y/index.d.ts +4 -0
  11. package/dist/a11y/index.js +3 -0
  12. package/dist/a11y/keyboardMode.d.ts +4 -0
  13. package/dist/a11y/keyboardMode.js +10 -0
  14. package/dist/a11y/tests/Chain.test.js +88 -0
  15. package/dist/a11y/tests/TrapFocus.test.d.ts +1 -0
  16. package/dist/a11y/tests/TrapFocus.test.js +313 -0
  17. package/dist/a11y/tests/TrapScreenReader.test.d.ts +1 -0
  18. package/dist/a11y/tests/TrapScreenReader.test.js +126 -0
  19. package/dist/a11y/tests/focus.test.d.ts +1 -0
  20. package/dist/a11y/tests/focus.test.js +278 -0
  21. package/dist/a11y/tests/keyboardMode.test.d.ts +1 -0
  22. package/dist/a11y/tests/keyboardMode.test.js +27 -0
  23. package/dist/a11y/types.d.ts +24 -0
  24. package/dist/a11y/types.js +1 -0
  25. package/dist/constants/keys.d.ts +11 -0
  26. package/dist/constants/keys.js +11 -0
  27. package/dist/css/StyleCache.d.ts +7 -0
  28. package/dist/css/StyleCache.js +19 -0
  29. package/dist/css/classNames.d.ts +7 -0
  30. package/dist/css/classNames.js +19 -0
  31. package/dist/css/index.d.ts +2 -0
  32. package/dist/css/index.js +4 -0
  33. package/dist/css/tests/StyleCache.test.d.ts +1 -0
  34. package/dist/css/tests/StyleCache.test.js +45 -0
  35. package/dist/css/tests/classNames.test.d.ts +1 -0
  36. package/dist/css/tests/classNames.test.js +63 -0
  37. package/dist/dom/findClosestScrollableContainer.d.ts +5 -0
  38. package/dist/dom/findClosestScrollableContainer.js +12 -0
  39. package/dist/dom/findParent.d.ts +2 -0
  40. package/dist/dom/findParent.js +10 -0
  41. package/dist/dom/index.d.ts +3 -0
  42. package/dist/dom/index.js +4 -0
  43. package/dist/dom/tests/findClosestScrollableContainer.test.d.ts +1 -0
  44. package/dist/dom/tests/findClosestScrollableContainer.test.js +61 -0
  45. package/dist/dom/tests/findParent.test.d.ts +1 -0
  46. package/dist/dom/tests/findParent.test.js +45 -0
  47. package/dist/flyout/Flyout.d.ts +2 -2
  48. package/dist/flyout/Flyout.js +6 -6
  49. package/dist/flyout/index.d.ts +1 -1
  50. package/dist/flyout/index.js +1 -1
  51. package/dist/flyout/tests/Flyout.test.js +9 -9
  52. package/dist/flyout/types.d.ts +2 -2
  53. package/dist/flyout/utilities/applyPosition.js +1 -1
  54. package/dist/flyout/utilities/tests/applyPosition.test.js +10 -10
  55. package/dist/flyout/utilities/tests/calculateLayoutAdjustment.test.js +2 -2
  56. package/dist/flyout/utilities/tests/calculatePosition.test.js +1 -1
  57. package/dist/flyout/utilities/tests/centerBySize.test.js +1 -1
  58. package/dist/flyout/utilities/tests/getPositionFallbacks.test.js +1 -1
  59. package/dist/flyout/utilities/tests/getRTLPosition.test.js +1 -1
  60. package/dist/flyout/utilities/tests/isFullyVisible.test.js +1 -1
  61. package/dist/helpers/classNames.d.ts +7 -0
  62. package/dist/helpers/classNames.js +19 -0
  63. package/dist/helpers/index.d.ts +1 -0
  64. package/dist/helpers/index.js +2 -0
  65. package/dist/helpers/tests/classNames.test.d.ts +1 -0
  66. package/dist/helpers/tests/classNames.test.js +63 -0
  67. package/dist/i18n/index.d.ts +1 -0
  68. package/dist/i18n/index.js +2 -0
  69. package/dist/index.d.ts +5 -1
  70. package/dist/index.js +5 -1
  71. package/dist/internal.d.ts +11 -0
  72. package/dist/internal.js +10 -0
  73. package/dist/platform/index.d.ts +1 -0
  74. package/dist/platform/index.js +16 -0
  75. package/dist/scroll/disable.d.ts +7 -0
  76. package/dist/scroll/disable.js +15 -0
  77. package/dist/scroll/helpers.d.ts +1 -0
  78. package/dist/scroll/helpers.js +17 -0
  79. package/dist/scroll/index.d.ts +2 -0
  80. package/dist/scroll/index.js +4 -0
  81. package/dist/scroll/lock.d.ts +7 -0
  82. package/dist/scroll/lock.js +26 -0
  83. package/dist/scroll/lockSafari.d.ts +2 -0
  84. package/dist/scroll/lockSafari.js +20 -0
  85. package/dist/scroll/lockStandard.d.ts +4 -0
  86. package/dist/scroll/lockStandard.js +15 -0
  87. package/dist/scroll/tests/lock.test.d.ts +1 -0
  88. package/dist/scroll/tests/lock.test.js +81 -0
  89. package/package.json +6 -1
  90. package/dist/flyout/utilities/findClosestFixedContainer.d.ts +0 -5
  91. package/dist/flyout/utilities/findClosestFixedContainer.js +0 -18
  92. package/dist/flyout/utilities/tests/findClosestFixedContainer.test.js +0 -46
  93. /package/dist/{flyout/utilities/tests/findClosestFixedContainer.test.d.ts → a11y/tests/Chain.test.d.ts} +0 -0
@@ -0,0 +1,101 @@
1
+ import { getShadowRoot } from "../dom/index.js";
2
+ const pseudoFocusAttribute = "data-rs-focus";
3
+ export const focusableSelector = 'a,button,input:not([type="hidden"]),textarea,select,details,[tabindex],[contenteditable]';
4
+ export const getActiveElement = (originEl) => {
5
+ const shadowRoot = originEl ? getShadowRoot(originEl) : null;
6
+ const rootEl = shadowRoot ?? document;
7
+ const pseudoFocusedEl = rootEl.querySelector(`[${pseudoFocusAttribute}]`);
8
+ return (pseudoFocusedEl || rootEl.activeElement);
9
+ };
10
+ export const focusElement = (el, options) => {
11
+ const shadowRoot = getShadowRoot(el);
12
+ const rootEl = shadowRoot ?? document;
13
+ rootEl.querySelector(`[${pseudoFocusAttribute}]`)?.removeAttribute(pseudoFocusAttribute);
14
+ if (options?.pseudoFocus) {
15
+ el.setAttribute(pseudoFocusAttribute, "true");
16
+ }
17
+ else {
18
+ el.focus();
19
+ }
20
+ };
21
+ export const getFocusableElements = (rootEl, options) => {
22
+ const focusableElements = Array.from(rootEl.querySelectorAll(focusableSelector));
23
+ const filteredElements = focusableElements.filter((el) => {
24
+ if (el.hasAttribute("disabled"))
25
+ return false;
26
+ if (el.clientHeight === 0)
27
+ return false;
28
+ // Using getAttribute here since browser sets el.tabIndex to -1 by default
29
+ if (!options?.includeNegativeTabIndex && el.getAttribute("tabindex") === "-1")
30
+ return false;
31
+ if (el.type === "radio") {
32
+ let sameNameRadioEls;
33
+ if (el.form) {
34
+ const formInputs = el.form.elements.namedItem(el.name);
35
+ // Synthetic error handling for narrowing down types
36
+ // Radio element can't find itself in the form, so we don't need to include it in the array
37
+ if (!formInputs)
38
+ return false;
39
+ const multipleElementsFound = "length" in formInputs;
40
+ if (!multipleElementsFound) {
41
+ // Single element found is always an input radio since we're inside the condition
42
+ sameNameRadioEls = [formInputs];
43
+ }
44
+ else {
45
+ sameNameRadioEls = Array.from(formInputs).filter((el) => "type" in el && el.type === "radio");
46
+ }
47
+ }
48
+ else {
49
+ sameNameRadioEls = Array.from(rootEl.querySelectorAll(`[type="radio"][name="${el.name}"]`));
50
+ }
51
+ if (sameNameRadioEls?.length) {
52
+ const checkedEl = Array.from(sameNameRadioEls).find((el) => el.checked);
53
+ if (checkedEl && el !== checkedEl)
54
+ return false;
55
+ if (!checkedEl && el !== sameNameRadioEls[0])
56
+ return false;
57
+ }
58
+ }
59
+ return true;
60
+ });
61
+ if (options?.additionalElement && filteredElements.length) {
62
+ filteredElements.unshift(options.additionalElement);
63
+ }
64
+ return filteredElements;
65
+ };
66
+ export const getFocusData = (args) => {
67
+ const { root, target, options } = args;
68
+ const focusable = getFocusableElements(root, {
69
+ additionalElement: options?.additionalElement,
70
+ includeNegativeTabIndex: options?.includeNegativeTabIndex,
71
+ });
72
+ const focusableLimit = focusable.length - 1;
73
+ const currentElement = getActiveElement(root);
74
+ const currentIndex = focusable.indexOf(currentElement);
75
+ const positions = {
76
+ next: currentIndex + 1,
77
+ prev: currentIndex - 1,
78
+ first: 0,
79
+ last: focusableLimit,
80
+ };
81
+ let nextIndex = positions[target];
82
+ const isOverflow = nextIndex > focusableLimit || nextIndex < 0;
83
+ if (isOverflow) {
84
+ if (options?.circular) {
85
+ nextIndex = target === "prev" ? positions.last : positions.first;
86
+ }
87
+ else {
88
+ nextIndex = target === "prev" ? positions.first : positions.last;
89
+ }
90
+ }
91
+ return { overflow: isOverflow, el: focusable[nextIndex], focusableElements: focusable };
92
+ };
93
+ const focusTargetElement = (root, target, options) => {
94
+ const data = getFocusData({ root, target, options });
95
+ focusElement(data.el);
96
+ return { el: data.el, focusableElements: data.focusableElements };
97
+ };
98
+ export const focusNextElement = (root, options) => focusTargetElement(root, "next", { ...options, includeNegativeTabIndex: true });
99
+ export const focusPreviousElement = (root, options) => focusTargetElement(root, "prev", { ...options, includeNegativeTabIndex: true });
100
+ export const focusFirstElement = (root) => focusTargetElement(root, "first", { includeNegativeTabIndex: true });
101
+ export const focusLastElement = (root) => focusTargetElement(root, "last", { includeNegativeTabIndex: true });
@@ -0,0 +1,4 @@
1
+ export { focusableSelector, getActiveElement, getFocusableElements, focusNextElement, focusPreviousElement, focusFirstElement, focusLastElement, } from "./focus";
2
+ export { activateKeyboardMode, deactivateKeyboardMode, checkKeyboardMode } from "./keyboardMode";
3
+ export { default as TrapFocus } from "./TrapFocus";
4
+ export type { TrapMode, FocusableElement } from "./types";
@@ -0,0 +1,3 @@
1
+ export { focusableSelector, getActiveElement, getFocusableElements, focusNextElement, focusPreviousElement, focusFirstElement, focusLastElement, } from "./focus.js";
2
+ export { activateKeyboardMode, deactivateKeyboardMode, checkKeyboardMode } from "./keyboardMode.js";
3
+ export { default as TrapFocus } from "./TrapFocus.js";
@@ -0,0 +1,4 @@
1
+ export declare const keyboardModeAttribute = "data-rs-keyboard";
2
+ export declare const activateKeyboardMode: () => void;
3
+ export declare const deactivateKeyboardMode: () => void;
4
+ export declare const checkKeyboardMode: () => boolean;
@@ -0,0 +1,10 @@
1
+ export const keyboardModeAttribute = "data-rs-keyboard";
2
+ export const activateKeyboardMode = () => {
3
+ document.documentElement.setAttribute(keyboardModeAttribute, "true");
4
+ };
5
+ export const deactivateKeyboardMode = () => {
6
+ document.documentElement.removeAttribute(keyboardModeAttribute);
7
+ };
8
+ export const checkKeyboardMode = () => {
9
+ return document.documentElement.hasAttribute(keyboardModeAttribute);
10
+ };
@@ -0,0 +1,88 @@
1
+ import { expect, test, describe, beforeEach } from "vitest";
2
+ import Chain from "../Chain.js";
3
+ describe("a11y/Chain", () => {
4
+ let chain;
5
+ beforeEach(() => {
6
+ chain = new Chain();
7
+ });
8
+ test("initializes as empty", () => {
9
+ expect(chain.isEmpty()).toBe(true);
10
+ expect(chain.getAll()).toEqual({});
11
+ expect(chain.tailId).toBe(null);
12
+ });
13
+ test("adds items and maintains chain order", () => {
14
+ const id1 = chain.add("first");
15
+ const id2 = chain.add("second");
16
+ const id3 = chain.add("third");
17
+ expect(chain.isEmpty()).toBe(false);
18
+ expect(chain.get(id1)).toEqual({ previousId: null, data: "first", nextId: id2 });
19
+ expect(chain.get(id2)).toEqual({ previousId: id1, data: "second", nextId: id3 });
20
+ expect(chain.get(id3)).toEqual({ previousId: id2, data: "third" });
21
+ expect(chain.tailId).toBe(id3);
22
+ });
23
+ test("identifies last item correctly", () => {
24
+ const id1 = chain.add("first");
25
+ const id2 = chain.add("second");
26
+ expect(chain.isLast(id1)).toBe(false);
27
+ expect(chain.isLast(id2)).toBe(true);
28
+ });
29
+ test("removes middle item and reconnects chain", () => {
30
+ const id1 = chain.add("first");
31
+ const id2 = chain.add("second");
32
+ const id3 = chain.add("third");
33
+ const removed = chain.remove(id2);
34
+ expect(removed).toBe("second");
35
+ expect(chain.get(id1).nextId).toBe(id3);
36
+ expect(chain.get(id3).previousId).toBe(id1);
37
+ expect(chain.get(id2)).toBeUndefined();
38
+ });
39
+ test("removes last item and updates tail", () => {
40
+ const id1 = chain.add("first");
41
+ const id2 = chain.add("second");
42
+ chain.remove(id2);
43
+ expect(chain.tailId).toBe(id1);
44
+ expect(chain.get(id1).nextId).toBe(null);
45
+ });
46
+ test("removes first item", () => {
47
+ const id1 = chain.add("first");
48
+ const id2 = chain.add("second");
49
+ chain.remove(id1);
50
+ expect(chain.get(id2).previousId).toBe(null);
51
+ expect(chain.get(id1)).toBeUndefined();
52
+ });
53
+ test("removes all items leaving chain empty", () => {
54
+ const id1 = chain.add("only");
55
+ chain.remove(id1);
56
+ expect(chain.isEmpty()).toBe(true);
57
+ expect(chain.tailId).toBe(null);
58
+ });
59
+ test("handles removing non-existent item", () => {
60
+ chain.add("first");
61
+ const result = chain.remove(999);
62
+ expect(result).toBeUndefined();
63
+ });
64
+ test("removes previous items until condition is met", () => {
65
+ const id1 = chain.add("first");
66
+ const id2 = chain.add("second");
67
+ const id3 = chain.add("third");
68
+ const id4 = chain.add("fourth");
69
+ const result = chain.removePreviousTill(id4, (item) => item.data === "second");
70
+ // Last deleted id
71
+ expect(result).toBe("second");
72
+ // Keeps only the first item
73
+ expect(chain.get(id1)).toEqual({ previousId: null, nextId: null, data: "first" });
74
+ expect(chain.get(id2)).toBeUndefined();
75
+ expect(chain.get(id3)).toBeUndefined();
76
+ expect(chain.get(id4)).toBeUndefined();
77
+ expect(chain.tailId).toBe(id1);
78
+ });
79
+ test("removePreviousTill stops at first item when condition never met", () => {
80
+ const id1 = chain.add("first");
81
+ const id2 = chain.add("second");
82
+ const result = chain.removePreviousTill(id2, () => false);
83
+ expect(result).toBe("first");
84
+ expect(chain.get(id1)).toBeUndefined();
85
+ expect(chain.get(id2)).toBeUndefined();
86
+ expect(chain.isEmpty()).toBe(true);
87
+ });
88
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,313 @@
1
+ import { expect, test, describe, beforeEach, afterEach, vi } from "vitest";
2
+ import TrapFocus from "../TrapFocus.js";
3
+ describe("a11y/TrapFocus", () => {
4
+ let container;
5
+ let trigger;
6
+ beforeEach(() => {
7
+ container = document.createElement("div");
8
+ document.body.appendChild(container);
9
+ trigger = document.createElement("button");
10
+ trigger.textContent = "Trigger";
11
+ document.body.appendChild(trigger);
12
+ trigger.focus();
13
+ });
14
+ afterEach(() => {
15
+ document.body.removeChild(container);
16
+ document.body.removeChild(trigger);
17
+ });
18
+ describe("basic functionality", () => {
19
+ test("traps focus within root element", () => {
20
+ container.innerHTML = `
21
+ <button id="btn1">Button 1</button>
22
+ <button id="btn2">Button 2</button>
23
+ `;
24
+ const trap = new TrapFocus();
25
+ trap.trap(container);
26
+ expect(trap.trapped).toBe(true);
27
+ // Should focus first element
28
+ expect(document.activeElement?.id).toBe("btn1");
29
+ });
30
+ test("releases trap and returns focus to trigger", () => {
31
+ container.innerHTML = `
32
+ <button id="btn1">Button 1</button>
33
+ <button id="btn2">Button 2</button>
34
+ `;
35
+ const trap = new TrapFocus();
36
+ trap.trap(container);
37
+ trap.release();
38
+ expect(trap.trapped).toBe(false);
39
+ expect(document.activeElement).toBe(trigger);
40
+ });
41
+ test("does not trap when no focusable elements", () => {
42
+ container.innerHTML = `<div>No focusable content</div>`;
43
+ const trap = new TrapFocus();
44
+ trap.trap(container);
45
+ expect(trap.trapped).toBeUndefined();
46
+ });
47
+ test("focuses initialFocusEl when provided", () => {
48
+ container.innerHTML = `
49
+ <button id="btn1">Button 1</button>
50
+ <button id="btn2">Button 2</button>
51
+ `;
52
+ const btn2 = container.querySelector("#btn2");
53
+ const trap = new TrapFocus();
54
+ trap.trap(container, { initialFocusEl: btn2 });
55
+ expect(document.activeElement).toBe(btn2);
56
+ });
57
+ test("calls onRelease callback when trap is released by Tab navigation", () => {
58
+ container.innerHTML = `<button id="btn1">Button 1</button>`;
59
+ const onRelease = vi.fn();
60
+ const trap = new TrapFocus();
61
+ trap.trap(container, { mode: "action-menu", onRelease });
62
+ const btn1 = container.querySelector("#btn1");
63
+ btn1.focus();
64
+ // Tab should release action-menu mode
65
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true });
66
+ document.dispatchEvent(event);
67
+ expect(onRelease).toHaveBeenCalled();
68
+ });
69
+ });
70
+ describe("dialog mode", () => {
71
+ test("navigates forward with Tab key", () => {
72
+ container.innerHTML = `
73
+ <button id="btn1">Button 1</button>
74
+ <button id="btn2">Button 2</button>
75
+ <button id="btn3">Button 3</button>
76
+ `;
77
+ const trap = new TrapFocus();
78
+ trap.trap(container, { mode: "dialog" });
79
+ const btn1 = container.querySelector("#btn1");
80
+ const btn2 = container.querySelector("#btn2");
81
+ btn1.focus();
82
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
83
+ document.dispatchEvent(event);
84
+ expect(document.activeElement).toBe(btn2);
85
+ });
86
+ test("navigates backward with Shift+Tab", () => {
87
+ container.innerHTML = `
88
+ <button id="btn1">Button 1</button>
89
+ <button id="btn2">Button 2</button>
90
+ <button id="btn3">Button 3</button>
91
+ `;
92
+ const trap = new TrapFocus();
93
+ trap.trap(container, { mode: "dialog" });
94
+ const btn1 = container.querySelector("#btn1");
95
+ const btn2 = container.querySelector("#btn2");
96
+ btn2.focus();
97
+ const event = new KeyboardEvent("keydown", {
98
+ key: "Tab",
99
+ shiftKey: true,
100
+ bubbles: true,
101
+ cancelable: true,
102
+ });
103
+ document.dispatchEvent(event);
104
+ expect(document.activeElement).toBe(btn1);
105
+ });
106
+ test("wraps focus to first element when tabbing from last", () => {
107
+ container.innerHTML = `
108
+ <button id="btn1">Button 1</button>
109
+ <button id="btn2">Button 2</button>
110
+ `;
111
+ const trap = new TrapFocus();
112
+ trap.trap(container, { mode: "dialog" });
113
+ const btn1 = container.querySelector("#btn1");
114
+ const btn2 = container.querySelector("#btn2");
115
+ btn2.focus();
116
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
117
+ document.dispatchEvent(event);
118
+ expect(document.activeElement).toBe(btn1);
119
+ });
120
+ });
121
+ describe("action-menu mode", () => {
122
+ test("navigates with ArrowDown key", () => {
123
+ container.innerHTML = `
124
+ <button id="btn1">Button 1</button>
125
+ <button id="btn2">Button 2</button>
126
+ `;
127
+ const trap = new TrapFocus();
128
+ trap.trap(container, { mode: "action-menu" });
129
+ const btn1 = container.querySelector("#btn1");
130
+ const btn2 = container.querySelector("#btn2");
131
+ btn1.focus();
132
+ const event = new KeyboardEvent("keydown", {
133
+ key: "ArrowDown",
134
+ bubbles: true,
135
+ cancelable: true,
136
+ });
137
+ document.dispatchEvent(event);
138
+ expect(document.activeElement).toBe(btn2);
139
+ });
140
+ test("navigates with ArrowUp key", () => {
141
+ container.innerHTML = `
142
+ <button id="btn1">Button 1</button>
143
+ <button id="btn2">Button 2</button>
144
+ `;
145
+ const trap = new TrapFocus();
146
+ trap.trap(container, { mode: "action-menu" });
147
+ const btn1 = container.querySelector("#btn1");
148
+ const btn2 = container.querySelector("#btn2");
149
+ btn2.focus();
150
+ const event = new KeyboardEvent("keydown", {
151
+ key: "ArrowUp",
152
+ bubbles: true,
153
+ cancelable: true,
154
+ });
155
+ document.dispatchEvent(event);
156
+ expect(document.activeElement).toBe(btn1);
157
+ });
158
+ test("releases trap when Tab is pressed", () => {
159
+ container.innerHTML = `<button id="btn1">Button 1</button>`;
160
+ const trap = new TrapFocus();
161
+ trap.trap(container, { mode: "action-menu" });
162
+ const btn1 = container.querySelector("#btn1");
163
+ btn1.focus();
164
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
165
+ document.dispatchEvent(event);
166
+ expect(trap.trapped).toBe(false);
167
+ });
168
+ });
169
+ describe("action-bar mode", () => {
170
+ test("navigates with ArrowRight key", () => {
171
+ container.innerHTML = `
172
+ <button id="btn1">Button 1</button>
173
+ <button id="btn2">Button 2</button>
174
+ `;
175
+ const trap = new TrapFocus();
176
+ trap.trap(container, { mode: "action-bar" });
177
+ const btn1 = container.querySelector("#btn1");
178
+ const btn2 = container.querySelector("#btn2");
179
+ btn1.focus();
180
+ const event = new KeyboardEvent("keydown", {
181
+ key: "ArrowRight",
182
+ bubbles: true,
183
+ cancelable: true,
184
+ });
185
+ document.dispatchEvent(event);
186
+ expect(document.activeElement).toBe(btn2);
187
+ });
188
+ test("navigates with ArrowLeft key", () => {
189
+ container.innerHTML = `
190
+ <button id="btn1">Button 1</button>
191
+ <button id="btn2">Button 2</button>
192
+ `;
193
+ const trap = new TrapFocus();
194
+ trap.trap(container, { mode: "action-bar" });
195
+ const btn1 = container.querySelector("#btn1");
196
+ const btn2 = container.querySelector("#btn2");
197
+ btn2.focus();
198
+ const event = new KeyboardEvent("keydown", {
199
+ key: "ArrowLeft",
200
+ bubbles: true,
201
+ cancelable: true,
202
+ });
203
+ document.dispatchEvent(event);
204
+ expect(document.activeElement).toBe(btn1);
205
+ });
206
+ });
207
+ describe("content-menu mode", () => {
208
+ test("releases trap when tabbing past last element", () => {
209
+ container.innerHTML = `
210
+ <button id="btn1">Button 1</button>
211
+ <button id="btn2">Button 2</button>
212
+ `;
213
+ const trap = new TrapFocus();
214
+ trap.trap(container, { mode: "content-menu" });
215
+ const btn2 = container.querySelector("#btn2");
216
+ btn2.focus();
217
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
218
+ document.dispatchEvent(event);
219
+ expect(trap.trapped).toBe(false);
220
+ });
221
+ test("continues navigation when not at boundaries", () => {
222
+ container.innerHTML = `
223
+ <button id="btn1">Button 1</button>
224
+ <button id="btn2">Button 2</button>
225
+ <button id="btn3">Button 3</button>
226
+ `;
227
+ const trap = new TrapFocus();
228
+ trap.trap(container, { mode: "content-menu" });
229
+ const btn1 = container.querySelector("#btn1");
230
+ const btn2 = container.querySelector("#btn2");
231
+ btn1.focus();
232
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
233
+ document.dispatchEvent(event);
234
+ expect(document.activeElement).toBe(btn2);
235
+ expect(trap.trapped).toBe(true);
236
+ });
237
+ });
238
+ describe("selection-menu mode", () => {
239
+ test("uses pseudo focus with data-rs-focus attribute", () => {
240
+ container.innerHTML = `
241
+ <button id="btn1">Button 1</button>
242
+ <button id="btn2">Button 2</button>
243
+ `;
244
+ const trap = new TrapFocus();
245
+ trap.trap(container, { mode: "selection-menu" });
246
+ const btn1 = container.querySelector("#btn1");
247
+ const btn2 = container.querySelector("#btn2");
248
+ // First element should have pseudo focus
249
+ expect(btn1.getAttribute("data-rs-focus")).toBe("true");
250
+ // Navigate with arrow down
251
+ const event = new KeyboardEvent("keydown", {
252
+ key: "ArrowDown",
253
+ bubbles: true,
254
+ cancelable: true,
255
+ });
256
+ document.dispatchEvent(event);
257
+ // Pseudo focus should move
258
+ expect(btn1.hasAttribute("data-rs-focus")).toBe(false);
259
+ expect(btn2.getAttribute("data-rs-focus")).toBe("true");
260
+ });
261
+ });
262
+ describe("includeTrigger option", () => {
263
+ test("includes trigger in focus navigation when enabled", () => {
264
+ container.innerHTML = `<button id="btn1">Button 1</button>`;
265
+ const trap = new TrapFocus();
266
+ trap.trap(container, { mode: "dialog", includeTrigger: true });
267
+ const btn1 = container.querySelector("#btn1");
268
+ // Trigger should be first in navigation order
269
+ expect(document.activeElement).toBe(trigger);
270
+ // Tab should move to btn1
271
+ const event = new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true });
272
+ document.dispatchEvent(event);
273
+ expect(document.activeElement).toBe(btn1);
274
+ });
275
+ });
276
+ describe("chain management", () => {
277
+ test("manages multiple traps in a chain", () => {
278
+ const container1 = document.createElement("div");
279
+ container1.innerHTML = `<button id="btn1">Container 1</button>`;
280
+ document.body.appendChild(container1);
281
+ const container2 = document.createElement("div");
282
+ container2.innerHTML = `<button id="btn2">Container 2</button>`;
283
+ document.body.appendChild(container2);
284
+ const trap1 = new TrapFocus();
285
+ const trap2 = new TrapFocus();
286
+ trap1.trap(container1);
287
+ const btn1 = container1.querySelector("#btn1");
288
+ btn1.focus();
289
+ trap2.trap(container2);
290
+ // Only the last trap should respond to events
291
+ const btn2 = container2.querySelector("#btn2");
292
+ expect(document.activeElement).toBe(btn2);
293
+ // Release second trap
294
+ trap2.release();
295
+ // First trap should be restored
296
+ expect(document.activeElement).toBe(btn1);
297
+ trap1.release();
298
+ document.body.removeChild(container1);
299
+ document.body.removeChild(container2);
300
+ });
301
+ });
302
+ describe("release options", () => {
303
+ test("does not return focus to trigger with withoutFocusReturn option", () => {
304
+ container.innerHTML = `<button id="btn1">Button 1</button>`;
305
+ const btn1 = container.querySelector("#btn1");
306
+ const trap = new TrapFocus();
307
+ trap.trap(container);
308
+ btn1.focus();
309
+ trap.release({ withoutFocusReturn: true });
310
+ expect(document.activeElement).not.toBe(trigger);
311
+ });
312
+ });
313
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,126 @@
1
+ import { expect, test, describe, beforeEach, afterEach } from "vitest";
2
+ import TrapScreenReader from "../TrapScreenReader.js";
3
+ describe("a11y/TrapScreenReader", () => {
4
+ let container;
5
+ beforeEach(() => {
6
+ container = document.createElement("div");
7
+ document.body.appendChild(container);
8
+ });
9
+ afterEach(() => {
10
+ document.body.removeChild(container);
11
+ });
12
+ test("hides siblings from screen reader when trap is activated", () => {
13
+ container.innerHTML = `
14
+ <div id="sibling1">Sibling 1</div>
15
+ <div id="target">Target</div>
16
+ <div id="sibling2">Sibling 2</div>
17
+ `;
18
+ const target = container.querySelector("#target");
19
+ const sibling1 = container.querySelector("#sibling1");
20
+ const sibling2 = container.querySelector("#sibling2");
21
+ const trap = new TrapScreenReader(target);
22
+ trap.trap();
23
+ expect(sibling1.getAttribute("aria-hidden")).toBe("true");
24
+ expect(sibling2.getAttribute("aria-hidden")).toBe("true");
25
+ expect(target.hasAttribute("aria-hidden")).toBe(false);
26
+ });
27
+ test("releases trapped elements and removes aria-hidden", () => {
28
+ container.innerHTML = `
29
+ <div id="sibling1">Sibling 1</div>
30
+ <div id="target">Target</div>
31
+ <div id="sibling2">Sibling 2</div>
32
+ `;
33
+ const target = container.querySelector("#target");
34
+ const sibling1 = container.querySelector("#sibling1");
35
+ const sibling2 = container.querySelector("#sibling2");
36
+ const trap = new TrapScreenReader(target);
37
+ trap.trap();
38
+ trap.release();
39
+ expect(sibling1.hasAttribute("aria-hidden")).toBe(false);
40
+ expect(sibling2.hasAttribute("aria-hidden")).toBe(false);
41
+ });
42
+ test("traverses up the DOM tree and hides siblings at each level", () => {
43
+ container.innerHTML = `
44
+ <div id="outer-sibling">Outer Sibling</div>
45
+ <div id="parent">
46
+ <div id="inner-sibling">Inner Sibling</div>
47
+ <div id="target">Target</div>
48
+ </div>
49
+ `;
50
+ const target = container.querySelector("#target");
51
+ const innerSibling = container.querySelector("#inner-sibling");
52
+ const outerSibling = container.querySelector("#outer-sibling");
53
+ const parent = container.querySelector("#parent");
54
+ const trap = new TrapScreenReader(target);
55
+ trap.trap();
56
+ // Inner sibling should be hidden
57
+ expect(innerSibling.getAttribute("aria-hidden")).toBe("true");
58
+ // Outer sibling should be hidden
59
+ expect(outerSibling.getAttribute("aria-hidden")).toBe("true");
60
+ // Parent and target should not be hidden
61
+ expect(parent.hasAttribute("aria-hidden")).toBe(false);
62
+ expect(target.hasAttribute("aria-hidden")).toBe(false);
63
+ });
64
+ test("does not hide elements that already have aria-hidden", () => {
65
+ container.innerHTML = `
66
+ <div id="sibling1" aria-hidden="true">Already Hidden</div>
67
+ <div id="target">Target</div>
68
+ <div id="sibling2">Sibling 2</div>
69
+ `;
70
+ const target = container.querySelector("#target");
71
+ const sibling1 = container.querySelector("#sibling1");
72
+ const sibling2 = container.querySelector("#sibling2");
73
+ const trap = new TrapScreenReader(target);
74
+ trap.trap();
75
+ // sibling1 should still be aria-hidden but not tracked
76
+ expect(sibling1.getAttribute("aria-hidden")).toBe("true");
77
+ expect(sibling2.getAttribute("aria-hidden")).toBe("true");
78
+ trap.release();
79
+ // sibling1 should still have aria-hidden (wasn't added by trap)
80
+ expect(sibling1.getAttribute("aria-hidden")).toBe("true");
81
+ // sibling2 should have aria-hidden removed (was added by trap)
82
+ expect(sibling2.hasAttribute("aria-hidden")).toBe(false);
83
+ });
84
+ test("stops at body level and does not hide body siblings", () => {
85
+ const outsideContainer = document.createElement("div");
86
+ outsideContainer.id = "outside";
87
+ document.body.appendChild(outsideContainer);
88
+ container.innerHTML = `<div id="target">Target</div>`;
89
+ const target = container.querySelector("#target");
90
+ const trap = new TrapScreenReader(target);
91
+ trap.trap();
92
+ // Container's sibling (outsideContainer) should be hidden
93
+ expect(outsideContainer.getAttribute("aria-hidden")).toBe("true");
94
+ trap.release();
95
+ document.body.removeChild(outsideContainer);
96
+ });
97
+ test("releases previous trap when trap is called again", () => {
98
+ container.innerHTML = `
99
+ <div id="sibling1">Sibling 1</div>
100
+ <div id="target">Target</div>
101
+ <div id="sibling2">Sibling 2</div>
102
+ `;
103
+ const target = container.querySelector("#target");
104
+ const sibling1 = container.querySelector("#sibling1");
105
+ const sibling2 = container.querySelector("#sibling2");
106
+ const trap = new TrapScreenReader(target);
107
+ // First trap
108
+ trap.trap();
109
+ expect(sibling1.getAttribute("aria-hidden")).toBe("true");
110
+ // Second trap should release first
111
+ trap.trap();
112
+ expect(sibling1.getAttribute("aria-hidden")).toBe("true");
113
+ expect(sibling2.getAttribute("aria-hidden")).toBe("true");
114
+ });
115
+ test("ignores non-element nodes", () => {
116
+ const parent = document.createElement("div");
117
+ parent.appendChild(document.createTextNode("Text node"));
118
+ const target = document.createElement("div");
119
+ target.textContent = "Target";
120
+ parent.appendChild(target);
121
+ container.appendChild(parent);
122
+ const trap = new TrapScreenReader(target);
123
+ // Should not throw error when encountering text nodes
124
+ expect(() => trap.trap()).not.toThrow();
125
+ });
126
+ });
@@ -0,0 +1 @@
1
+ export {};