@navikt/ds-react 7.32.2 → 7.32.4
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/FilteredOptions/FilteredOptionsItem.js +4 -0
- package/cjs/form/combobox/FilteredOptions/FilteredOptionsItem.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/form/switch/Switch.js +3 -3
- package/cjs/form/switch/Switch.js.map +1 -1
- package/cjs/popover/Popover.js +1 -1
- package/cjs/popover/Popover.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/descendants/useDescendant.js +3 -0
- package/cjs/util/hooks/descendants/useDescendant.js.map +1 -1
- package/cjs/util/hooks/useLatestRef.js +1 -0
- package/cjs/util/hooks/useLatestRef.js.map +1 -1
- package/esm/accordion/AccordionHeader.js +1 -1
- package/esm/accordion/AccordionHeader.js.map +1 -1
- package/esm/form/combobox/FilteredOptions/FilteredOptionsItem.js +4 -0
- package/esm/form/combobox/FilteredOptions/FilteredOptionsItem.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/form/switch/Switch.js +3 -3
- package/esm/form/switch/Switch.js.map +1 -1
- package/esm/popover/Popover.js +1 -1
- package/esm/popover/Popover.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/descendants/useDescendant.js +3 -0
- package/esm/util/hooks/descendants/useDescendant.js.map +1 -1
- package/esm/util/hooks/useLatestRef.js +1 -0
- package/esm/util/hooks/useLatestRef.js.map +1 -1
- package/package.json +4 -4
- package/src/accordion/AccordionHeader.tsx +1 -1
- package/src/form/combobox/FilteredOptions/FilteredOptionsItem.tsx +4 -0
- package/src/form/combobox/Input/ToggleListButton.tsx +2 -1
- package/src/form/combobox/__tests__/combobox.test.tsx +45 -106
- package/src/form/switch/Switch.tsx +4 -4
- package/src/popover/Popover.tsx +1 -1
- package/src/util/__tests__/hideNonTargetElements.test.ts +147 -0
- package/src/util/hideNonTargetElements.ts +179 -0
- package/src/util/hooks/descendants/useDescendant.tsx +3 -0
- package/src/util/hooks/useLatestRef.ts +1 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modified version of `aria-hidden`-package.
|
|
3
|
+
* - Removed "inert"-functionality.
|
|
4
|
+
* - Removed flexibility for different data-attributes.
|
|
5
|
+
* https://github.com/theKashey/aria-hidden/blob/720e8a8e1cfa047bd299a929d95d47ac860a5c1a/src/index.ts
|
|
6
|
+
*/
|
|
7
|
+
import { ownerDocument } from "./owner";
|
|
8
|
+
|
|
9
|
+
type UndoFn = () => void;
|
|
10
|
+
|
|
11
|
+
let ariaHiddenCounter = new WeakMap<Element, number>();
|
|
12
|
+
let markerCounter = new WeakMap<Element, number>();
|
|
13
|
+
|
|
14
|
+
let uncontrolledElementsSet = new WeakSet<Element>();
|
|
15
|
+
let lockCount = 0;
|
|
16
|
+
|
|
17
|
+
const controlAttribute = "aria-hidden";
|
|
18
|
+
const markerName = "data-aksel-hidden";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Unwraps a Shadow DOM host to find the actual Element in the light DOM.
|
|
22
|
+
*/
|
|
23
|
+
function unwrapHost(node: Element | ShadowRoot): Element | null {
|
|
24
|
+
return (
|
|
25
|
+
node &&
|
|
26
|
+
((node as ShadowRoot).host || unwrapHost(node.parentNode as Element))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Corrects the target elements by unwrapping Shadow DOM hosts if necessary.
|
|
32
|
+
*
|
|
33
|
+
* @param parent - The parent HTMLElement to check containment against.
|
|
34
|
+
* @param targets - An array of target Elements to correct.
|
|
35
|
+
* @returns An array of corrected Elements that are contained within the parent.
|
|
36
|
+
*/
|
|
37
|
+
function correctElements(parent: HTMLElement, targets: Element[]): Element[] {
|
|
38
|
+
return targets
|
|
39
|
+
.map((target) => {
|
|
40
|
+
if (parent.contains(target)) {
|
|
41
|
+
return target;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const correctedTarget = unwrapHost(target);
|
|
45
|
+
|
|
46
|
+
if (parent.contains(correctedTarget)) {
|
|
47
|
+
return correctedTarget;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
})
|
|
52
|
+
.filter((x): x is Element => x !== null);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Applies the aria-hidden attribute to all elements in the body except the specified avoid elements.
|
|
57
|
+
*/
|
|
58
|
+
function applyAttributeToOthers(
|
|
59
|
+
uncorrectedAvoidElements: Element[],
|
|
60
|
+
body: HTMLElement,
|
|
61
|
+
): UndoFn {
|
|
62
|
+
const avoidElements = correctElements(body, uncorrectedAvoidElements);
|
|
63
|
+
const elementsToAvoidWithParents = new Set<Node>();
|
|
64
|
+
const elementsToAvoidUpdating = new Set<Node>(avoidElements);
|
|
65
|
+
const hiddenElements: Element[] = [];
|
|
66
|
+
|
|
67
|
+
avoidElements.forEach(addToAvoidList);
|
|
68
|
+
applyAttributes(body);
|
|
69
|
+
elementsToAvoidWithParents.clear();
|
|
70
|
+
|
|
71
|
+
function addToAvoidList(el: Node | undefined) {
|
|
72
|
+
if (!el || elementsToAvoidWithParents.has(el)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
elementsToAvoidWithParents.add(el);
|
|
77
|
+
if (el.parentNode) {
|
|
78
|
+
addToAvoidList(el.parentNode);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function applyAttributes(parent: Element | null) {
|
|
83
|
+
if (!parent || elementsToAvoidUpdating.has(parent)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const parentChildren = parent.children;
|
|
88
|
+
|
|
89
|
+
for (let index = 0; index < parentChildren.length; index += 1) {
|
|
90
|
+
const node = parentChildren[index] as Element;
|
|
91
|
+
|
|
92
|
+
if (elementsToAvoidWithParents.has(node)) {
|
|
93
|
+
applyAttributes(node);
|
|
94
|
+
} else {
|
|
95
|
+
const attr = node.getAttribute(controlAttribute);
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* We only check for falsy values here since since arbitrary values
|
|
99
|
+
* (e.g. "true", "foo", "") are all valid for indicating that the element is already hidden.
|
|
100
|
+
*/
|
|
101
|
+
const alreadyHidden = attr !== null && attr !== "false";
|
|
102
|
+
const counterValue = (ariaHiddenCounter.get(node) || 0) + 1;
|
|
103
|
+
const markerValue = (markerCounter.get(node) || 0) + 1;
|
|
104
|
+
|
|
105
|
+
ariaHiddenCounter.set(node, counterValue);
|
|
106
|
+
markerCounter.set(node, markerValue);
|
|
107
|
+
hiddenElements.push(node);
|
|
108
|
+
|
|
109
|
+
if (counterValue === 1 && alreadyHidden) {
|
|
110
|
+
uncontrolledElementsSet.add(node);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (markerValue === 1) {
|
|
114
|
+
node.setAttribute(markerName, "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!alreadyHidden) {
|
|
118
|
+
node.setAttribute(controlAttribute, "true");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
lockCount += 1;
|
|
125
|
+
|
|
126
|
+
/* Cleanup */
|
|
127
|
+
return () => {
|
|
128
|
+
for (const element of hiddenElements) {
|
|
129
|
+
const currentCounterValue = ariaHiddenCounter.get(element) || 0;
|
|
130
|
+
const counterValue = currentCounterValue - 1;
|
|
131
|
+
const markerValue = (markerCounter.get(element) || 0) - 1;
|
|
132
|
+
|
|
133
|
+
ariaHiddenCounter.set(element, counterValue);
|
|
134
|
+
markerCounter.set(element, markerValue);
|
|
135
|
+
|
|
136
|
+
if (!counterValue) {
|
|
137
|
+
if (!uncontrolledElementsSet.has(element)) {
|
|
138
|
+
element.removeAttribute(controlAttribute);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
uncontrolledElementsSet.delete(element);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!markerValue) {
|
|
145
|
+
element.removeAttribute(markerName);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
lockCount -= 1;
|
|
150
|
+
|
|
151
|
+
/* Reset */
|
|
152
|
+
if (!lockCount) {
|
|
153
|
+
ariaHiddenCounter = new WeakMap();
|
|
154
|
+
uncontrolledElementsSet = new WeakSet();
|
|
155
|
+
markerCounter = new WeakMap();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Hides all elements in the document body for assertive technologies except the specified elements with `aria-hidden`.
|
|
162
|
+
* @param avoidElements - An array of elements to avoid hiding.
|
|
163
|
+
* @returns A function that, when called, will undo the hiding of elements.
|
|
164
|
+
*/
|
|
165
|
+
function hideNonTargetElements(avoidElements: Element[]): UndoFn {
|
|
166
|
+
const body = ownerDocument(avoidElements[0]).body;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Assume that elements with `aria-live` or `script` tags should not be hidden.
|
|
170
|
+
* This ensures that live regions and scripts continue to function properly.
|
|
171
|
+
*/
|
|
172
|
+
const ingoredElements = Array.from(
|
|
173
|
+
body.querySelectorAll("[aria-live], script"),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return applyAttributeToOthers(avoidElements.concat(ingoredElements), body);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export { hideNonTargetElements };
|
|
@@ -46,10 +46,13 @@ export function createDescendantContext<
|
|
|
46
46
|
useClientLayoutEffect(() => {
|
|
47
47
|
return () => {
|
|
48
48
|
if (!ref.current) return;
|
|
49
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
49
50
|
descendants.unregister(ref.current);
|
|
50
51
|
};
|
|
52
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
53
|
}, []);
|
|
52
54
|
|
|
55
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
53
56
|
useClientLayoutEffect(() => {
|
|
54
57
|
if (!ref.current) return;
|
|
55
58
|
const dataIndex = Number(ref.current.dataset.index);
|