@oscarpalmer/toretto 0.7.0 → 0.9.0
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/dist/focusable.js +152 -0
- package/dist/focusable.mjs +152 -0
- package/dist/index.js +180 -1
- package/dist/index.mjs +2 -0
- package/dist/sanitise.js +75 -0
- package/dist/sanitise.mjs +36 -0
- package/dist/style.js +1 -1
- package/dist/style.mjs +1 -1
- package/package.json +13 -1
- package/src/focusable.ts +270 -0
- package/src/index.ts +2 -0
- package/src/sanitise.ts +63 -0
- package/src/style.ts +1 -1
- package/types/focusable.d.ts +16 -0
- package/types/index.d.cts +28 -0
- package/types/index.d.ts +2 -0
- package/types/sanitise.d.ts +12 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/focusable.ts
|
|
2
|
+
function getFocusable(parent) {
|
|
3
|
+
return getValidElements(parent, focusableFilters, false);
|
|
4
|
+
}
|
|
5
|
+
function getItem(element, tabbable) {
|
|
6
|
+
return {
|
|
7
|
+
element,
|
|
8
|
+
tabIndex: tabbable ? getTabIndex(element) : -1
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function getTabbable(parent) {
|
|
12
|
+
return getValidElements(parent, tabbableFilters, true);
|
|
13
|
+
}
|
|
14
|
+
function getTabIndex(element) {
|
|
15
|
+
const tabIndex = element?.tabIndex ?? -1;
|
|
16
|
+
if (tabIndex < 0 && (/^(audio|details|video)$/i.test(element.tagName) || isEditable(element)) && !hasTabIndex(element)) {
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
return tabIndex;
|
|
20
|
+
}
|
|
21
|
+
function getValidElements(parent, filters, tabbable) {
|
|
22
|
+
const elements = [...parent.querySelectorAll(selector)];
|
|
23
|
+
const items = [];
|
|
24
|
+
let { length } = elements;
|
|
25
|
+
for (let index = 0;index < length; index += 1) {
|
|
26
|
+
const item = getItem(elements[index], tabbable);
|
|
27
|
+
if (!filters.some((filter) => filter(item))) {
|
|
28
|
+
items.push(item);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!tabbable) {
|
|
32
|
+
return items.map((item) => item.element);
|
|
33
|
+
}
|
|
34
|
+
const indiced = [];
|
|
35
|
+
const zeroed = [];
|
|
36
|
+
length = items.length;
|
|
37
|
+
for (let index = 0;index < length; index += 1) {
|
|
38
|
+
const item = items[index];
|
|
39
|
+
if (item.tabIndex === 0) {
|
|
40
|
+
zeroed.push(item.element);
|
|
41
|
+
} else {
|
|
42
|
+
indiced[item.tabIndex] = [
|
|
43
|
+
...indiced[item.tabIndex] ?? [],
|
|
44
|
+
item.element
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [...indiced.flat(), ...zeroed];
|
|
49
|
+
}
|
|
50
|
+
function hasTabIndex(element) {
|
|
51
|
+
return !Number.isNaN(Number.parseInt(element.getAttribute("tabindex"), 10));
|
|
52
|
+
}
|
|
53
|
+
function isDisabled(item) {
|
|
54
|
+
if (/^(button|input|select|textarea)$/i.test(item.element.tagName) && isDisabledFromFieldset(item.element)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return (item.element.disabled ?? false) || item.element.getAttribute("aria-disabled") === "true";
|
|
58
|
+
}
|
|
59
|
+
function isDisabledFromFieldset(element) {
|
|
60
|
+
let parent = element.parentElement;
|
|
61
|
+
while (parent != null) {
|
|
62
|
+
if (parent instanceof HTMLFieldSetElement && parent.disabled) {
|
|
63
|
+
const children = Array.from(parent.children);
|
|
64
|
+
const { length } = children;
|
|
65
|
+
for (let index = 0;index < length; index += 1) {
|
|
66
|
+
const child = children[index];
|
|
67
|
+
if (child instanceof HTMLLegendElement) {
|
|
68
|
+
return parent.matches("fieldset[disabled] *") ? true : !child.contains(element);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
parent = parent.parentElement;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
function isEditable(element) {
|
|
78
|
+
return /^(|true)$/i.test(element.getAttribute("contenteditable"));
|
|
79
|
+
}
|
|
80
|
+
function isFocusable(element) {
|
|
81
|
+
return isValidElement(element, focusableFilters, false);
|
|
82
|
+
}
|
|
83
|
+
function isHidden(item) {
|
|
84
|
+
if ((item.element.hidden ?? false) || item.element instanceof HTMLInputElement && item.element.type === "hidden") {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
const isDirectSummary = item.element.matches("details > summary:first-of-type");
|
|
88
|
+
const nodeUnderDetails = isDirectSummary ? item.element.parentElement : item.element;
|
|
89
|
+
if (nodeUnderDetails?.matches("details:not([open]) *") ?? false) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const style = getComputedStyle(item.element);
|
|
93
|
+
if (style.display === "none" || style.visibility === "hidden") {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
const { height, width } = item.element.getBoundingClientRect();
|
|
97
|
+
return height === 0 && width === 0;
|
|
98
|
+
}
|
|
99
|
+
function isInert(item) {
|
|
100
|
+
return (item.element.inert ?? false) || /^(|true)$/i.test(item.element.getAttribute("inert")) || item.element.parentElement != null && isInert({
|
|
101
|
+
element: item.element.parentElement,
|
|
102
|
+
tabIndex: -1
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function isNotTabbable(item) {
|
|
106
|
+
return (item.tabIndex ?? -1) < 0;
|
|
107
|
+
}
|
|
108
|
+
function isNotTabbableRadio(item) {
|
|
109
|
+
if (!(item.element instanceof HTMLInputElement) || item.element.type !== "radio" || !item.element.name || item.element.checked) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const parent = item.element.form ?? item.element.getRootNode?.() ?? item.element.ownerDocument;
|
|
113
|
+
const realName = CSS?.escape?.(item.element.name) ?? item.element.name;
|
|
114
|
+
const radios = Array.from(parent.querySelectorAll(`input[type="radio"][name="${realName}"]`));
|
|
115
|
+
const checked = radios.find((radio) => radio.checked);
|
|
116
|
+
return checked != null && checked !== item.element;
|
|
117
|
+
}
|
|
118
|
+
function isSummarised(item) {
|
|
119
|
+
return item.element instanceof HTMLDetailsElement && Array.from(item.element.children).some((child) => /^summary$/i.test(child.tagName));
|
|
120
|
+
}
|
|
121
|
+
function isTabbable(element) {
|
|
122
|
+
return isValidElement(element, tabbableFilters, true);
|
|
123
|
+
}
|
|
124
|
+
function isValidElement(element, filters, tabbable) {
|
|
125
|
+
const item = getItem(element, tabbable);
|
|
126
|
+
return !filters.some((filter) => filter(item));
|
|
127
|
+
}
|
|
128
|
+
var focusableFilters = [isDisabled, isInert, isHidden, isSummarised];
|
|
129
|
+
var selector = [
|
|
130
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
131
|
+
"[tabindex]:not(slot)",
|
|
132
|
+
"a[href]",
|
|
133
|
+
"audio[controls]",
|
|
134
|
+
"button",
|
|
135
|
+
"details",
|
|
136
|
+
"details > summary:first-of-type",
|
|
137
|
+
"input",
|
|
138
|
+
"select",
|
|
139
|
+
"textarea",
|
|
140
|
+
"video[controls]"
|
|
141
|
+
].map((selector2) => `${selector2}:not([inert])`).join(",");
|
|
142
|
+
var tabbableFilters = [
|
|
143
|
+
isNotTabbable,
|
|
144
|
+
isNotTabbableRadio,
|
|
145
|
+
...focusableFilters
|
|
146
|
+
];
|
|
147
|
+
export {
|
|
148
|
+
isTabbable,
|
|
149
|
+
isFocusable,
|
|
150
|
+
getTabbable,
|
|
151
|
+
getFocusable
|
|
152
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/focusable.ts
|
|
2
|
+
function getFocusable(parent) {
|
|
3
|
+
return getValidElements(parent, focusableFilters, false);
|
|
4
|
+
}
|
|
5
|
+
function getItem(element, tabbable) {
|
|
6
|
+
return {
|
|
7
|
+
element,
|
|
8
|
+
tabIndex: tabbable ? getTabIndex(element) : -1
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function getTabbable(parent) {
|
|
12
|
+
return getValidElements(parent, tabbableFilters, true);
|
|
13
|
+
}
|
|
14
|
+
function getTabIndex(element) {
|
|
15
|
+
const tabIndex = element?.tabIndex ?? -1;
|
|
16
|
+
if (tabIndex < 0 && (/^(audio|details|video)$/i.test(element.tagName) || isEditable(element)) && !hasTabIndex(element)) {
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
return tabIndex;
|
|
20
|
+
}
|
|
21
|
+
function getValidElements(parent, filters, tabbable) {
|
|
22
|
+
const elements = [...parent.querySelectorAll(selector)];
|
|
23
|
+
const items = [];
|
|
24
|
+
let { length } = elements;
|
|
25
|
+
for (let index = 0;index < length; index += 1) {
|
|
26
|
+
const item = getItem(elements[index], tabbable);
|
|
27
|
+
if (!filters.some((filter) => filter(item))) {
|
|
28
|
+
items.push(item);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!tabbable) {
|
|
32
|
+
return items.map((item) => item.element);
|
|
33
|
+
}
|
|
34
|
+
const indiced = [];
|
|
35
|
+
const zeroed = [];
|
|
36
|
+
length = items.length;
|
|
37
|
+
for (let index = 0;index < length; index += 1) {
|
|
38
|
+
const item = items[index];
|
|
39
|
+
if (item.tabIndex === 0) {
|
|
40
|
+
zeroed.push(item.element);
|
|
41
|
+
} else {
|
|
42
|
+
indiced[item.tabIndex] = [
|
|
43
|
+
...indiced[item.tabIndex] ?? [],
|
|
44
|
+
item.element
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return [...indiced.flat(), ...zeroed];
|
|
49
|
+
}
|
|
50
|
+
function hasTabIndex(element) {
|
|
51
|
+
return !Number.isNaN(Number.parseInt(element.getAttribute("tabindex"), 10));
|
|
52
|
+
}
|
|
53
|
+
function isDisabled(item) {
|
|
54
|
+
if (/^(button|input|select|textarea)$/i.test(item.element.tagName) && isDisabledFromFieldset(item.element)) {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
return (item.element.disabled ?? false) || item.element.getAttribute("aria-disabled") === "true";
|
|
58
|
+
}
|
|
59
|
+
function isDisabledFromFieldset(element) {
|
|
60
|
+
let parent = element.parentElement;
|
|
61
|
+
while (parent != null) {
|
|
62
|
+
if (parent instanceof HTMLFieldSetElement && parent.disabled) {
|
|
63
|
+
const children = Array.from(parent.children);
|
|
64
|
+
const { length } = children;
|
|
65
|
+
for (let index = 0;index < length; index += 1) {
|
|
66
|
+
const child = children[index];
|
|
67
|
+
if (child instanceof HTMLLegendElement) {
|
|
68
|
+
return parent.matches("fieldset[disabled] *") ? true : !child.contains(element);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
parent = parent.parentElement;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
function isEditable(element) {
|
|
78
|
+
return /^(|true)$/i.test(element.getAttribute("contenteditable"));
|
|
79
|
+
}
|
|
80
|
+
function isFocusable(element) {
|
|
81
|
+
return isValidElement(element, focusableFilters, false);
|
|
82
|
+
}
|
|
83
|
+
function isHidden(item) {
|
|
84
|
+
if ((item.element.hidden ?? false) || item.element instanceof HTMLInputElement && item.element.type === "hidden") {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
const isDirectSummary = item.element.matches("details > summary:first-of-type");
|
|
88
|
+
const nodeUnderDetails = isDirectSummary ? item.element.parentElement : item.element;
|
|
89
|
+
if (nodeUnderDetails?.matches("details:not([open]) *") ?? false) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const style = getComputedStyle(item.element);
|
|
93
|
+
if (style.display === "none" || style.visibility === "hidden") {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
const { height, width } = item.element.getBoundingClientRect();
|
|
97
|
+
return height === 0 && width === 0;
|
|
98
|
+
}
|
|
99
|
+
function isInert(item) {
|
|
100
|
+
return (item.element.inert ?? false) || /^(|true)$/i.test(item.element.getAttribute("inert")) || item.element.parentElement != null && isInert({
|
|
101
|
+
element: item.element.parentElement,
|
|
102
|
+
tabIndex: -1
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function isNotTabbable(item) {
|
|
106
|
+
return (item.tabIndex ?? -1) < 0;
|
|
107
|
+
}
|
|
108
|
+
function isNotTabbableRadio(item) {
|
|
109
|
+
if (!(item.element instanceof HTMLInputElement) || item.element.type !== "radio" || !item.element.name || item.element.checked) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const parent = item.element.form ?? item.element.getRootNode?.() ?? item.element.ownerDocument;
|
|
113
|
+
const realName = CSS?.escape?.(item.element.name) ?? item.element.name;
|
|
114
|
+
const radios = Array.from(parent.querySelectorAll(`input[type="radio"][name="${realName}"]`));
|
|
115
|
+
const checked = radios.find((radio) => radio.checked);
|
|
116
|
+
return checked != null && checked !== item.element;
|
|
117
|
+
}
|
|
118
|
+
function isSummarised(item) {
|
|
119
|
+
return item.element instanceof HTMLDetailsElement && Array.from(item.element.children).some((child) => /^summary$/i.test(child.tagName));
|
|
120
|
+
}
|
|
121
|
+
function isTabbable(element) {
|
|
122
|
+
return isValidElement(element, tabbableFilters, true);
|
|
123
|
+
}
|
|
124
|
+
function isValidElement(element, filters, tabbable) {
|
|
125
|
+
const item = getItem(element, tabbable);
|
|
126
|
+
return !filters.some((filter) => filter(item));
|
|
127
|
+
}
|
|
128
|
+
var focusableFilters = [isDisabled, isInert, isHidden, isSummarised];
|
|
129
|
+
var selector = [
|
|
130
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
131
|
+
"[tabindex]:not(slot)",
|
|
132
|
+
"a[href]",
|
|
133
|
+
"audio[controls]",
|
|
134
|
+
"button",
|
|
135
|
+
"details",
|
|
136
|
+
"details > summary:first-of-type",
|
|
137
|
+
"input",
|
|
138
|
+
"select",
|
|
139
|
+
"textarea",
|
|
140
|
+
"video[controls]"
|
|
141
|
+
].map((selector2) => `${selector2}:not([inert])`).join(",");
|
|
142
|
+
var tabbableFilters = [
|
|
143
|
+
isNotTabbable,
|
|
144
|
+
isNotTabbableRadio,
|
|
145
|
+
...focusableFilters
|
|
146
|
+
];
|
|
147
|
+
export {
|
|
148
|
+
isTabbable,
|
|
149
|
+
isFocusable,
|
|
150
|
+
getTabbable,
|
|
151
|
+
getFocusable
|
|
152
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -291,6 +291,180 @@ function traverse(from, to) {
|
|
|
291
291
|
}
|
|
292
292
|
return -1e6;
|
|
293
293
|
}
|
|
294
|
+
// src/focusable.ts
|
|
295
|
+
function getFocusable(parent) {
|
|
296
|
+
return getValidElements(parent, focusableFilters, false);
|
|
297
|
+
}
|
|
298
|
+
function getItem(element, tabbable) {
|
|
299
|
+
return {
|
|
300
|
+
element,
|
|
301
|
+
tabIndex: tabbable ? getTabIndex(element) : -1
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function getTabbable(parent) {
|
|
305
|
+
return getValidElements(parent, tabbableFilters, true);
|
|
306
|
+
}
|
|
307
|
+
function getTabIndex(element) {
|
|
308
|
+
const tabIndex = element?.tabIndex ?? -1;
|
|
309
|
+
if (tabIndex < 0 && (/^(audio|details|video)$/i.test(element.tagName) || isEditable(element)) && !hasTabIndex(element)) {
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
return tabIndex;
|
|
313
|
+
}
|
|
314
|
+
function getValidElements(parent, filters, tabbable) {
|
|
315
|
+
const elements = [...parent.querySelectorAll(selector)];
|
|
316
|
+
const items = [];
|
|
317
|
+
let { length } = elements;
|
|
318
|
+
for (let index = 0;index < length; index += 1) {
|
|
319
|
+
const item = getItem(elements[index], tabbable);
|
|
320
|
+
if (!filters.some((filter2) => filter2(item))) {
|
|
321
|
+
items.push(item);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (!tabbable) {
|
|
325
|
+
return items.map((item) => item.element);
|
|
326
|
+
}
|
|
327
|
+
const indiced = [];
|
|
328
|
+
const zeroed = [];
|
|
329
|
+
length = items.length;
|
|
330
|
+
for (let index = 0;index < length; index += 1) {
|
|
331
|
+
const item = items[index];
|
|
332
|
+
if (item.tabIndex === 0) {
|
|
333
|
+
zeroed.push(item.element);
|
|
334
|
+
} else {
|
|
335
|
+
indiced[item.tabIndex] = [
|
|
336
|
+
...indiced[item.tabIndex] ?? [],
|
|
337
|
+
item.element
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return [...indiced.flat(), ...zeroed];
|
|
342
|
+
}
|
|
343
|
+
function hasTabIndex(element) {
|
|
344
|
+
return !Number.isNaN(Number.parseInt(element.getAttribute("tabindex"), 10));
|
|
345
|
+
}
|
|
346
|
+
function isDisabled(item) {
|
|
347
|
+
if (/^(button|input|select|textarea)$/i.test(item.element.tagName) && isDisabledFromFieldset(item.element)) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
return (item.element.disabled ?? false) || item.element.getAttribute("aria-disabled") === "true";
|
|
351
|
+
}
|
|
352
|
+
function isDisabledFromFieldset(element) {
|
|
353
|
+
let parent = element.parentElement;
|
|
354
|
+
while (parent != null) {
|
|
355
|
+
if (parent instanceof HTMLFieldSetElement && parent.disabled) {
|
|
356
|
+
const children = Array.from(parent.children);
|
|
357
|
+
const { length } = children;
|
|
358
|
+
for (let index = 0;index < length; index += 1) {
|
|
359
|
+
const child = children[index];
|
|
360
|
+
if (child instanceof HTMLLegendElement) {
|
|
361
|
+
return parent.matches("fieldset[disabled] *") ? true : !child.contains(element);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
parent = parent.parentElement;
|
|
367
|
+
}
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
function isEditable(element) {
|
|
371
|
+
return /^(|true)$/i.test(element.getAttribute("contenteditable"));
|
|
372
|
+
}
|
|
373
|
+
function isFocusable(element) {
|
|
374
|
+
return isValidElement(element, focusableFilters, false);
|
|
375
|
+
}
|
|
376
|
+
function isHidden(item) {
|
|
377
|
+
if ((item.element.hidden ?? false) || item.element instanceof HTMLInputElement && item.element.type === "hidden") {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
const isDirectSummary = item.element.matches("details > summary:first-of-type");
|
|
381
|
+
const nodeUnderDetails = isDirectSummary ? item.element.parentElement : item.element;
|
|
382
|
+
if (nodeUnderDetails?.matches("details:not([open]) *") ?? false) {
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
const style = getComputedStyle(item.element);
|
|
386
|
+
if (style.display === "none" || style.visibility === "hidden") {
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
const { height, width } = item.element.getBoundingClientRect();
|
|
390
|
+
return height === 0 && width === 0;
|
|
391
|
+
}
|
|
392
|
+
function isInert(item) {
|
|
393
|
+
return (item.element.inert ?? false) || /^(|true)$/i.test(item.element.getAttribute("inert")) || item.element.parentElement != null && isInert({
|
|
394
|
+
element: item.element.parentElement,
|
|
395
|
+
tabIndex: -1
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
function isNotTabbable(item) {
|
|
399
|
+
return (item.tabIndex ?? -1) < 0;
|
|
400
|
+
}
|
|
401
|
+
function isNotTabbableRadio(item) {
|
|
402
|
+
if (!(item.element instanceof HTMLInputElement) || item.element.type !== "radio" || !item.element.name || item.element.checked) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
const parent = item.element.form ?? item.element.getRootNode?.() ?? item.element.ownerDocument;
|
|
406
|
+
const realName = CSS?.escape?.(item.element.name) ?? item.element.name;
|
|
407
|
+
const radios = Array.from(parent.querySelectorAll(`input[type="radio"][name="${realName}"]`));
|
|
408
|
+
const checked = radios.find((radio) => radio.checked);
|
|
409
|
+
return checked != null && checked !== item.element;
|
|
410
|
+
}
|
|
411
|
+
function isSummarised(item) {
|
|
412
|
+
return item.element instanceof HTMLDetailsElement && Array.from(item.element.children).some((child) => /^summary$/i.test(child.tagName));
|
|
413
|
+
}
|
|
414
|
+
function isTabbable(element) {
|
|
415
|
+
return isValidElement(element, tabbableFilters, true);
|
|
416
|
+
}
|
|
417
|
+
function isValidElement(element, filters, tabbable) {
|
|
418
|
+
const item = getItem(element, tabbable);
|
|
419
|
+
return !filters.some((filter2) => filter2(item));
|
|
420
|
+
}
|
|
421
|
+
var focusableFilters = [isDisabled, isInert, isHidden, isSummarised];
|
|
422
|
+
var selector = [
|
|
423
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
424
|
+
"[tabindex]:not(slot)",
|
|
425
|
+
"a[href]",
|
|
426
|
+
"audio[controls]",
|
|
427
|
+
"button",
|
|
428
|
+
"details",
|
|
429
|
+
"details > summary:first-of-type",
|
|
430
|
+
"input",
|
|
431
|
+
"select",
|
|
432
|
+
"textarea",
|
|
433
|
+
"video[controls]"
|
|
434
|
+
].map((selector2) => `${selector2}:not([inert])`).join(",");
|
|
435
|
+
var tabbableFilters = [
|
|
436
|
+
isNotTabbable,
|
|
437
|
+
isNotTabbableRadio,
|
|
438
|
+
...focusableFilters
|
|
439
|
+
];
|
|
440
|
+
// src/sanitise.ts
|
|
441
|
+
function sanitise(value2, options) {
|
|
442
|
+
return sanitiseNodes(Array.isArray(value2) ? value2 : [value2], {
|
|
443
|
+
sanitiseBooleanAttributes: options?.sanitiseBooleanAttributes ?? true
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
function sanitiseAttributes(element, attributes, options) {
|
|
447
|
+
const { length } = attributes;
|
|
448
|
+
for (let index = 0;index < length; index += 1) {
|
|
449
|
+
const attribute2 = attributes[index];
|
|
450
|
+
if (isBadAttribute(attribute2) || isEmptyNonBooleanAttribute(attribute2)) {
|
|
451
|
+
element.removeAttribute(attribute2.name);
|
|
452
|
+
} else if (options.sanitiseBooleanAttributes && isInvalidBooleanAttribute(attribute2)) {
|
|
453
|
+
element.setAttribute(attribute2.name, "");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function sanitiseNodes(nodes, options) {
|
|
458
|
+
const { length } = nodes;
|
|
459
|
+
for (let index = 0;index < length; index += 1) {
|
|
460
|
+
const node = nodes[index];
|
|
461
|
+
if (node instanceof Element) {
|
|
462
|
+
sanitiseAttributes(node, [...node.attributes], options);
|
|
463
|
+
}
|
|
464
|
+
sanitiseNodes([...node.childNodes], options);
|
|
465
|
+
}
|
|
466
|
+
return nodes;
|
|
467
|
+
}
|
|
294
468
|
// src/style.ts
|
|
295
469
|
function getStyle(element, property) {
|
|
296
470
|
return element.style[property];
|
|
@@ -306,7 +480,7 @@ function getStyles(element, properties) {
|
|
|
306
480
|
}
|
|
307
481
|
function getTextDirection(element) {
|
|
308
482
|
const direction = element.getAttribute("dir");
|
|
309
|
-
if (direction
|
|
483
|
+
if (direction != null && /^(ltr|rtl)$/i.test(direction)) {
|
|
310
484
|
return direction.toLowerCase();
|
|
311
485
|
}
|
|
312
486
|
return getComputedStyle?.(element)?.direction === "rtl" ? "rtl" : "ltr";
|
|
@@ -330,13 +504,18 @@ export {
|
|
|
330
504
|
setData,
|
|
331
505
|
setAttributes,
|
|
332
506
|
setAttribute,
|
|
507
|
+
sanitise,
|
|
508
|
+
isTabbable,
|
|
333
509
|
isInvalidBooleanAttribute,
|
|
510
|
+
isFocusable,
|
|
334
511
|
isEmptyNonBooleanAttribute,
|
|
335
512
|
isBooleanAttribute,
|
|
336
513
|
isBadAttribute,
|
|
337
514
|
getTextDirection,
|
|
515
|
+
getTabbable,
|
|
338
516
|
getStyles,
|
|
339
517
|
getStyle,
|
|
518
|
+
getFocusable,
|
|
340
519
|
getElementUnderPointer,
|
|
341
520
|
getData,
|
|
342
521
|
findRelatives,
|
package/dist/index.mjs
CHANGED
package/dist/sanitise.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// src/attribute.ts
|
|
2
|
+
function isBadAttribute(attribute) {
|
|
3
|
+
return onPrefix.test(attribute.name) || sourcePrefix.test(attribute.name) && valuePrefix.test(attribute.value);
|
|
4
|
+
}
|
|
5
|
+
function isEmptyNonBooleanAttribute(attribute) {
|
|
6
|
+
return !booleanAttributes.includes(attribute.name) && attribute.value.trim().length === 0;
|
|
7
|
+
}
|
|
8
|
+
function isInvalidBooleanAttribute(attribute) {
|
|
9
|
+
if (!booleanAttributes.includes(attribute.name)) {
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
const normalised = attribute.value.toLowerCase().trim();
|
|
13
|
+
return !(normalised.length === 0 || normalised === attribute.name || attribute.name === "hidden" && normalised === "until-found");
|
|
14
|
+
}
|
|
15
|
+
var booleanAttributes = Object.freeze([
|
|
16
|
+
"async",
|
|
17
|
+
"autofocus",
|
|
18
|
+
"autoplay",
|
|
19
|
+
"checked",
|
|
20
|
+
"controls",
|
|
21
|
+
"default",
|
|
22
|
+
"defer",
|
|
23
|
+
"disabled",
|
|
24
|
+
"formnovalidate",
|
|
25
|
+
"hidden",
|
|
26
|
+
"inert",
|
|
27
|
+
"ismap",
|
|
28
|
+
"itemscope",
|
|
29
|
+
"loop",
|
|
30
|
+
"multiple",
|
|
31
|
+
"muted",
|
|
32
|
+
"nomodule",
|
|
33
|
+
"novalidate",
|
|
34
|
+
"open",
|
|
35
|
+
"playsinline",
|
|
36
|
+
"readonly",
|
|
37
|
+
"required",
|
|
38
|
+
"reversed",
|
|
39
|
+
"selected"
|
|
40
|
+
]);
|
|
41
|
+
var onPrefix = /^on/i;
|
|
42
|
+
var sourcePrefix = /^(href|src|xlink:href)$/i;
|
|
43
|
+
var valuePrefix = /(data:text\/html|javascript:)/i;
|
|
44
|
+
|
|
45
|
+
// src/sanitise.ts
|
|
46
|
+
function sanitise(value2, options) {
|
|
47
|
+
return sanitiseNodes(Array.isArray(value2) ? value2 : [value2], {
|
|
48
|
+
sanitiseBooleanAttributes: options?.sanitiseBooleanAttributes ?? true
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function sanitiseAttributes(element, attributes, options) {
|
|
52
|
+
const { length } = attributes;
|
|
53
|
+
for (let index = 0;index < length; index += 1) {
|
|
54
|
+
const attribute2 = attributes[index];
|
|
55
|
+
if (isBadAttribute(attribute2) || isEmptyNonBooleanAttribute(attribute2)) {
|
|
56
|
+
element.removeAttribute(attribute2.name);
|
|
57
|
+
} else if (options.sanitiseBooleanAttributes && isInvalidBooleanAttribute(attribute2)) {
|
|
58
|
+
element.setAttribute(attribute2.name, "");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function sanitiseNodes(nodes, options) {
|
|
63
|
+
const { length } = nodes;
|
|
64
|
+
for (let index = 0;index < length; index += 1) {
|
|
65
|
+
const node = nodes[index];
|
|
66
|
+
if (node instanceof Element) {
|
|
67
|
+
sanitiseAttributes(node, [...node.attributes], options);
|
|
68
|
+
}
|
|
69
|
+
sanitiseNodes([...node.childNodes], options);
|
|
70
|
+
}
|
|
71
|
+
return nodes;
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
sanitise
|
|
75
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// src/sanitise.ts
|
|
2
|
+
import {
|
|
3
|
+
isBadAttribute,
|
|
4
|
+
isEmptyNonBooleanAttribute,
|
|
5
|
+
isInvalidBooleanAttribute
|
|
6
|
+
} from "./attribute";
|
|
7
|
+
function sanitise(value, options) {
|
|
8
|
+
return sanitiseNodes(Array.isArray(value) ? value : [value], {
|
|
9
|
+
sanitiseBooleanAttributes: options?.sanitiseBooleanAttributes ?? true
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
function sanitiseAttributes(element, attributes, options) {
|
|
13
|
+
const { length } = attributes;
|
|
14
|
+
for (let index = 0;index < length; index += 1) {
|
|
15
|
+
const attribute2 = attributes[index];
|
|
16
|
+
if (isBadAttribute(attribute2) || isEmptyNonBooleanAttribute(attribute2)) {
|
|
17
|
+
element.removeAttribute(attribute2.name);
|
|
18
|
+
} else if (options.sanitiseBooleanAttributes && isInvalidBooleanAttribute(attribute2)) {
|
|
19
|
+
element.setAttribute(attribute2.name, "");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function sanitiseNodes(nodes, options) {
|
|
24
|
+
const { length } = nodes;
|
|
25
|
+
for (let index = 0;index < length; index += 1) {
|
|
26
|
+
const node = nodes[index];
|
|
27
|
+
if (node instanceof Element) {
|
|
28
|
+
sanitiseAttributes(node, [...node.attributes], options);
|
|
29
|
+
}
|
|
30
|
+
sanitiseNodes([...node.childNodes], options);
|
|
31
|
+
}
|
|
32
|
+
return nodes;
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
sanitise
|
|
36
|
+
};
|
package/dist/style.js
CHANGED
|
@@ -58,7 +58,7 @@ function getStyles(element, properties) {
|
|
|
58
58
|
}
|
|
59
59
|
function getTextDirection(element) {
|
|
60
60
|
const direction = element.getAttribute("dir");
|
|
61
|
-
if (direction
|
|
61
|
+
if (direction != null && /^(ltr|rtl)$/i.test(direction)) {
|
|
62
62
|
return direction.toLowerCase();
|
|
63
63
|
}
|
|
64
64
|
return getComputedStyle?.(element)?.direction === "rtl" ? "rtl" : "ltr";
|
package/dist/style.mjs
CHANGED
|
@@ -14,7 +14,7 @@ function getStyles(element, properties) {
|
|
|
14
14
|
}
|
|
15
15
|
function getTextDirection(element) {
|
|
16
16
|
const direction = element.getAttribute("dir");
|
|
17
|
-
if (direction
|
|
17
|
+
if (direction != null && /^(ltr|rtl)$/i.test(direction)) {
|
|
18
18
|
return direction.toLowerCase();
|
|
19
19
|
}
|
|
20
20
|
return getComputedStyle?.(element)?.direction === "rtl" ? "rtl" : "ltr";
|
package/package.json
CHANGED
|
@@ -45,10 +45,22 @@
|
|
|
45
45
|
"import": "./dist/find.mjs",
|
|
46
46
|
"require": "./dist/find.js"
|
|
47
47
|
},
|
|
48
|
+
"./focusable": {
|
|
49
|
+
"types": "./types/focusable.d.ts",
|
|
50
|
+
"bun": "./src/focusable.ts",
|
|
51
|
+
"import": "./dist/focusable.mjs",
|
|
52
|
+
"require": "./dist/focusable.js"
|
|
53
|
+
},
|
|
48
54
|
"./models": {
|
|
49
55
|
"types": "./types/models.d.ts",
|
|
50
56
|
"bun": "./src/models.ts"
|
|
51
57
|
},
|
|
58
|
+
"./sanitise": {
|
|
59
|
+
"types": "./types/sanitise.d.ts",
|
|
60
|
+
"bun": "./src/sanitise.ts",
|
|
61
|
+
"import": "./dist/sanitise.mjs",
|
|
62
|
+
"require": "./dist/sanitise.js"
|
|
63
|
+
},
|
|
52
64
|
"./style": {
|
|
53
65
|
"types": "./types/style.d.ts",
|
|
54
66
|
"bun": "./src/style.ts",
|
|
@@ -85,5 +97,5 @@
|
|
|
85
97
|
},
|
|
86
98
|
"type": "module",
|
|
87
99
|
"types": "types/index.d.cts",
|
|
88
|
-
"version": "0.
|
|
100
|
+
"version": "0.9.0"
|
|
89
101
|
}
|
package/src/focusable.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Based on https://github.com/focus-trap/tabbable :-)
|
|
2
|
+
|
|
3
|
+
type ElementWithTabIndex = {
|
|
4
|
+
element: Element;
|
|
5
|
+
tabIndex: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type Filter = (item: ElementWithTabIndex) => boolean;
|
|
9
|
+
type InertElement = Element & {inert: boolean};
|
|
10
|
+
|
|
11
|
+
const focusableFilters = [isDisabled, isInert, isHidden, isSummarised];
|
|
12
|
+
|
|
13
|
+
const selector = [
|
|
14
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
15
|
+
'[tabindex]:not(slot)',
|
|
16
|
+
'a[href]',
|
|
17
|
+
'audio[controls]',
|
|
18
|
+
'button',
|
|
19
|
+
'details',
|
|
20
|
+
'details > summary:first-of-type',
|
|
21
|
+
'input',
|
|
22
|
+
'select',
|
|
23
|
+
'textarea',
|
|
24
|
+
'video[controls]',
|
|
25
|
+
]
|
|
26
|
+
.map(selector => `${selector}:not([inert])`)
|
|
27
|
+
.join(',');
|
|
28
|
+
|
|
29
|
+
const tabbableFilters = [
|
|
30
|
+
isNotTabbable,
|
|
31
|
+
isNotTabbableRadio,
|
|
32
|
+
...focusableFilters,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get a list of focusable elements within a parent element
|
|
37
|
+
*/
|
|
38
|
+
export function getFocusable(parent: Element): Element[] {
|
|
39
|
+
return getValidElements(parent, focusableFilters, false);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getItem(element: Element, tabbable: boolean): ElementWithTabIndex {
|
|
43
|
+
return {
|
|
44
|
+
element,
|
|
45
|
+
tabIndex: tabbable ? getTabIndex(element) : -1,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get a list of tabbable elements within a parent element
|
|
51
|
+
*/
|
|
52
|
+
export function getTabbable(parent: Element): Element[] {
|
|
53
|
+
return getValidElements(parent, tabbableFilters, true);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getTabIndex(element: Element): number {
|
|
57
|
+
const tabIndex = (element as HTMLElement)?.tabIndex ?? -1;
|
|
58
|
+
|
|
59
|
+
if (
|
|
60
|
+
tabIndex < 0 &&
|
|
61
|
+
(/^(audio|details|video)$/i.test(element.tagName) || isEditable(element)) &&
|
|
62
|
+
!hasTabIndex(element)
|
|
63
|
+
) {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return tabIndex;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getValidElements(
|
|
71
|
+
parent: Element,
|
|
72
|
+
filters: Filter[],
|
|
73
|
+
tabbable: boolean,
|
|
74
|
+
): Array<Element> {
|
|
75
|
+
const elements = [...parent.querySelectorAll(selector)];
|
|
76
|
+
const items: ElementWithTabIndex[] = [];
|
|
77
|
+
|
|
78
|
+
let {length} = elements;
|
|
79
|
+
|
|
80
|
+
for (let index = 0; index < length; index += 1) {
|
|
81
|
+
const item = getItem(elements[index], tabbable);
|
|
82
|
+
|
|
83
|
+
if (!filters.some(filter => filter(item))) {
|
|
84
|
+
items.push(item);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!tabbable) {
|
|
89
|
+
return items.map(item => item.element);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const indiced: Array<Array<Element>> = [];
|
|
93
|
+
const zeroed: Array<Element> = [];
|
|
94
|
+
|
|
95
|
+
length = items.length;
|
|
96
|
+
|
|
97
|
+
for (let index = 0; index < length; index += 1) {
|
|
98
|
+
const item = items[index];
|
|
99
|
+
|
|
100
|
+
if (item.tabIndex === 0) {
|
|
101
|
+
zeroed.push(item.element);
|
|
102
|
+
} else {
|
|
103
|
+
indiced[item.tabIndex] = [
|
|
104
|
+
...(indiced[item.tabIndex] ?? []),
|
|
105
|
+
item.element,
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return [...indiced.flat(), ...zeroed];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasTabIndex(element: Element): boolean {
|
|
114
|
+
return !Number.isNaN(
|
|
115
|
+
Number.parseInt(element.getAttribute('tabindex') as string, 10),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isDisabled(item: ElementWithTabIndex): boolean {
|
|
120
|
+
if (
|
|
121
|
+
/^(button|input|select|textarea)$/i.test(item.element.tagName) &&
|
|
122
|
+
isDisabledFromFieldset(item.element)
|
|
123
|
+
) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
((item.element as HTMLInputElement).disabled ?? false) ||
|
|
129
|
+
item.element.getAttribute('aria-disabled') === 'true'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isDisabledFromFieldset(element: Element): boolean {
|
|
134
|
+
let parent = element.parentElement;
|
|
135
|
+
|
|
136
|
+
while (parent != null) {
|
|
137
|
+
if (parent instanceof HTMLFieldSetElement && parent.disabled) {
|
|
138
|
+
const children = Array.from(parent.children);
|
|
139
|
+
const {length} = children;
|
|
140
|
+
|
|
141
|
+
for (let index = 0; index < length; index += 1) {
|
|
142
|
+
const child = children[index];
|
|
143
|
+
|
|
144
|
+
if (child instanceof HTMLLegendElement) {
|
|
145
|
+
return parent.matches('fieldset[disabled] *')
|
|
146
|
+
? true
|
|
147
|
+
: !child.contains(element);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
parent = parent.parentElement;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isEditable(element: Element): boolean {
|
|
161
|
+
return /^(|true)$/i.test(element.getAttribute('contenteditable') as string);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Is the element focusable?
|
|
166
|
+
*/
|
|
167
|
+
export function isFocusable(element: Element): boolean {
|
|
168
|
+
return isValidElement(element, focusableFilters, false);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isHidden(item: ElementWithTabIndex) {
|
|
172
|
+
if (
|
|
173
|
+
((item.element as HTMLElement).hidden ?? false) ||
|
|
174
|
+
(item.element instanceof HTMLInputElement && item.element.type === 'hidden')
|
|
175
|
+
) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const isDirectSummary = item.element.matches(
|
|
180
|
+
'details > summary:first-of-type',
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const nodeUnderDetails = isDirectSummary
|
|
184
|
+
? item.element.parentElement
|
|
185
|
+
: item.element;
|
|
186
|
+
|
|
187
|
+
if (nodeUnderDetails?.matches('details:not([open]) *') ?? false) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const style = getComputedStyle(item.element);
|
|
192
|
+
|
|
193
|
+
if (style.display === 'none' || style.visibility === 'hidden') {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const {height, width} = item.element.getBoundingClientRect();
|
|
198
|
+
|
|
199
|
+
return height === 0 && width === 0;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isInert(item: ElementWithTabIndex): boolean {
|
|
203
|
+
return (
|
|
204
|
+
((item.element as InertElement).inert ?? false) ||
|
|
205
|
+
/^(|true)$/i.test(item.element.getAttribute('inert') as string) ||
|
|
206
|
+
(item.element.parentElement != null &&
|
|
207
|
+
isInert({
|
|
208
|
+
element: item.element.parentElement,
|
|
209
|
+
tabIndex: -1,
|
|
210
|
+
}))
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isNotTabbable(item: ElementWithTabIndex) {
|
|
215
|
+
return (item.tabIndex ?? -1) < 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function isNotTabbableRadio(item: ElementWithTabIndex): boolean {
|
|
219
|
+
if (
|
|
220
|
+
!(item.element instanceof HTMLInputElement) ||
|
|
221
|
+
item.element.type !== 'radio' ||
|
|
222
|
+
!item.element.name ||
|
|
223
|
+
item.element.checked
|
|
224
|
+
) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const parent =
|
|
229
|
+
item.element.form ??
|
|
230
|
+
item.element.getRootNode?.() ??
|
|
231
|
+
item.element.ownerDocument;
|
|
232
|
+
|
|
233
|
+
const realName = CSS?.escape?.(item.element.name) ?? item.element.name;
|
|
234
|
+
|
|
235
|
+
const radios = Array.from(
|
|
236
|
+
(parent as Element).querySelectorAll(
|
|
237
|
+
`input[type="radio"][name="${realName}"]`,
|
|
238
|
+
),
|
|
239
|
+
) as HTMLInputElement[];
|
|
240
|
+
|
|
241
|
+
const checked = radios.find(radio => radio.checked);
|
|
242
|
+
|
|
243
|
+
return checked != null && checked !== item.element;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isSummarised(item: ElementWithTabIndex) {
|
|
247
|
+
return (
|
|
248
|
+
item.element instanceof HTMLDetailsElement &&
|
|
249
|
+
Array.from(item.element.children).some(child =>
|
|
250
|
+
/^summary$/i.test(child.tagName),
|
|
251
|
+
)
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Is the element tabbable?
|
|
257
|
+
*/
|
|
258
|
+
export function isTabbable(element: Element): boolean {
|
|
259
|
+
return isValidElement(element, tabbableFilters, true);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isValidElement(
|
|
263
|
+
element: Element,
|
|
264
|
+
filters: Filter[],
|
|
265
|
+
tabbable: boolean,
|
|
266
|
+
): boolean {
|
|
267
|
+
const item = getItem(element, tabbable);
|
|
268
|
+
|
|
269
|
+
return !filters.some(filter => filter(item));
|
|
270
|
+
}
|
package/src/index.ts
CHANGED
package/src/sanitise.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isBadAttribute,
|
|
3
|
+
isEmptyNonBooleanAttribute,
|
|
4
|
+
isInvalidBooleanAttribute,
|
|
5
|
+
} from './attribute';
|
|
6
|
+
|
|
7
|
+
export type SanitiseOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* - Sanitise boolean attributes? _(Defaults to `true`)_
|
|
10
|
+
* - E.g. `checked="abc"` => `checked=""`
|
|
11
|
+
*/
|
|
12
|
+
sanitiseBooleanAttributes?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* - Sanitise one or more nodes _(as well as all their children)_:
|
|
17
|
+
* - Removes or sanitises bad attributes
|
|
18
|
+
*/
|
|
19
|
+
export function sanitise(
|
|
20
|
+
value: Node | Node[],
|
|
21
|
+
options?: Partial<SanitiseOptions>,
|
|
22
|
+
): Node[] {
|
|
23
|
+
return sanitiseNodes(Array.isArray(value) ? value : [value], {
|
|
24
|
+
sanitiseBooleanAttributes: options?.sanitiseBooleanAttributes ?? true,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sanitiseAttributes(
|
|
29
|
+
element: Element,
|
|
30
|
+
attributes: Attr[],
|
|
31
|
+
options: SanitiseOptions,
|
|
32
|
+
): void {
|
|
33
|
+
const {length} = attributes;
|
|
34
|
+
|
|
35
|
+
for (let index = 0; index < length; index += 1) {
|
|
36
|
+
const attribute = attributes[index];
|
|
37
|
+
|
|
38
|
+
if (isBadAttribute(attribute) || isEmptyNonBooleanAttribute(attribute)) {
|
|
39
|
+
element.removeAttribute(attribute.name);
|
|
40
|
+
} else if (
|
|
41
|
+
options.sanitiseBooleanAttributes &&
|
|
42
|
+
isInvalidBooleanAttribute(attribute)
|
|
43
|
+
) {
|
|
44
|
+
element.setAttribute(attribute.name, '');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sanitiseNodes(nodes: Node[], options: SanitiseOptions): Node[] {
|
|
50
|
+
const {length} = nodes;
|
|
51
|
+
|
|
52
|
+
for (let index = 0; index < length; index += 1) {
|
|
53
|
+
const node = nodes[index];
|
|
54
|
+
|
|
55
|
+
if (node instanceof Element) {
|
|
56
|
+
sanitiseAttributes(node, [...node.attributes], options);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
sanitiseNodes([...node.childNodes], options);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return nodes;
|
|
63
|
+
}
|
package/src/style.ts
CHANGED
|
@@ -36,7 +36,7 @@ export function getStyles<Property extends keyof CSSStyleDeclaration>(
|
|
|
36
36
|
export function getTextDirection(element: Element): TextDirection {
|
|
37
37
|
const direction = element.getAttribute('dir');
|
|
38
38
|
|
|
39
|
-
if (direction
|
|
39
|
+
if (direction != null && /^(ltr|rtl)$/i.test(direction)) {
|
|
40
40
|
return direction.toLowerCase() as TextDirection;
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get a list of focusable elements within a parent element
|
|
3
|
+
*/
|
|
4
|
+
export declare function getFocusable(parent: Element): Element[];
|
|
5
|
+
/**
|
|
6
|
+
* Get a list of tabbable elements within a parent element
|
|
7
|
+
*/
|
|
8
|
+
export declare function getTabbable(parent: Element): Element[];
|
|
9
|
+
/**
|
|
10
|
+
* Is the element focusable?
|
|
11
|
+
*/
|
|
12
|
+
export declare function isFocusable(element: Element): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Is the element tabbable?
|
|
15
|
+
*/
|
|
16
|
+
export declare function isTabbable(element: Element): boolean;
|
package/types/index.d.cts
CHANGED
|
@@ -121,6 +121,34 @@ export declare function findRelatives(origin: Element, selector: string, context
|
|
|
121
121
|
* - If `skipIgnore` is `true`, no elements are ignored
|
|
122
122
|
*/
|
|
123
123
|
export declare function getElementUnderPointer(skipIgnore?: boolean): Element | undefined;
|
|
124
|
+
/**
|
|
125
|
+
* Get a list of focusable elements within a parent element
|
|
126
|
+
*/
|
|
127
|
+
export declare function getFocusable(parent: Element): Element[];
|
|
128
|
+
/**
|
|
129
|
+
* Get a list of tabbable elements within a parent element
|
|
130
|
+
*/
|
|
131
|
+
export declare function getTabbable(parent: Element): Element[];
|
|
132
|
+
/**
|
|
133
|
+
* Is the element focusable?
|
|
134
|
+
*/
|
|
135
|
+
export declare function isFocusable(element: Element): boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Is the element tabbable?
|
|
138
|
+
*/
|
|
139
|
+
export declare function isTabbable(element: Element): boolean;
|
|
140
|
+
export type SanitiseOptions = {
|
|
141
|
+
/**
|
|
142
|
+
* - Sanitise boolean attributes? _(Defaults to `true`)_
|
|
143
|
+
* - E.g. `checked="abc"` => `checked=""`
|
|
144
|
+
*/
|
|
145
|
+
sanitiseBooleanAttributes?: boolean;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* - Sanitise one or more nodes _(as well as all their children)_:
|
|
149
|
+
* - Removes or sanitises bad attributes
|
|
150
|
+
*/
|
|
151
|
+
export declare function sanitise(value: Node | Node[], options?: Partial<SanitiseOptions>): Node[];
|
|
124
152
|
/**
|
|
125
153
|
* Get a style from an element
|
|
126
154
|
*/
|
package/types/index.d.ts
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type SanitiseOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* - Sanitise boolean attributes? _(Defaults to `true`)_
|
|
4
|
+
* - E.g. `checked="abc"` => `checked=""`
|
|
5
|
+
*/
|
|
6
|
+
sanitiseBooleanAttributes?: boolean;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* - Sanitise one or more nodes _(as well as all their children)_:
|
|
10
|
+
* - Removes or sanitises bad attributes
|
|
11
|
+
*/
|
|
12
|
+
export declare function sanitise(value: Node | Node[], options?: Partial<SanitiseOptions>): Node[];
|