@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.
@@ -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 !== null && /^(ltr|rtl)$/i.test(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
@@ -2,5 +2,7 @@
2
2
  export * from "./attribute";
3
3
  export * from "./data";
4
4
  export * from "./find";
5
+ export * from "./focusable";
5
6
  export * from "./models";
7
+ export * from "./sanitise";
6
8
  export * from "./style";
@@ -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 !== null && /^(ltr|rtl)$/i.test(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 !== null && /^(ltr|rtl)$/i.test(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.7.0"
100
+ "version": "0.9.0"
89
101
  }
@@ -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
@@ -1,5 +1,7 @@
1
1
  export * from './attribute';
2
2
  export * from './data';
3
3
  export * from './find';
4
+ export * from './focusable';
4
5
  export * from './models';
6
+ export * from './sanitise';
5
7
  export * from './style';
@@ -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 !== null && /^(ltr|rtl)$/i.test(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
@@ -1,5 +1,7 @@
1
1
  export * from './attribute';
2
2
  export * from './data';
3
3
  export * from './find';
4
+ export * from './focusable';
4
5
  export * from './models';
6
+ export * from './sanitise';
5
7
  export * from './style';
@@ -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[];