@aurodesignsystem/auro-library 5.0.2 → 5.1.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/CHANGELOG.md +7 -0
- package/package.json +12 -5
- package/scripts/runtime/FocusTrap/FocusTrap.mjs +130 -0
- package/scripts/runtime/FocusTrap/index.mjs +1 -0
- package/scripts/runtime/FocusTrap/test/FocusTrap.test.js +168 -0
- package/scripts/runtime/Focusables/Focusables.mjs +129 -0
- package/scripts/runtime/Focusables/index.mjs +1 -0
- package/scripts/runtime/Focusables/test/Focusables.test.js +165 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Semantic Release Automated Changelog
|
|
2
2
|
|
|
3
|
+
# [5.1.0](https://github.com/AlaskaAirlines/auro-library/compare/v5.0.2...v5.1.0) (2025-06-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add focus trap ([1ec8290](https://github.com/AlaskaAirlines/auro-library/commit/1ec82905e5fc27aa0e4769df683462ebbce08bcd))
|
|
9
|
+
|
|
3
10
|
## [5.0.2](https://github.com/AlaskaAirlines/auro-library/compare/v5.0.1...v5.0.2) (2025-06-02)
|
|
4
11
|
|
|
5
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aurodesignsystem/auro-library",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "This repository holds shared scripts, utilities, and workflows utilized across repositories along the Auro Design System.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -70,16 +70,23 @@
|
|
|
70
70
|
"esLint": "eslint ./scripts/**/*.js",
|
|
71
71
|
"linters": "npm-run-all esLint",
|
|
72
72
|
"build:docs": "node scripts/build/generateReadme.mjs",
|
|
73
|
-
"test": "vitest
|
|
74
|
-
"test:watch": "vitest"
|
|
73
|
+
"test:vitest": "vitest --run",
|
|
74
|
+
"test:vitest:watch": "vitest",
|
|
75
|
+
"test:auro": "auro test --files 'scripts/**/*.test.js'",
|
|
76
|
+
"test:auro:watch": "auro test --watch --files 'scripts/**/*.test.js'",
|
|
77
|
+
"test": "npm-run-all test:vitest test:auro",
|
|
78
|
+
"test:watch": "npm-run-all test:vitest:watch test:auro:watch"
|
|
75
79
|
},
|
|
76
80
|
"bugs": {
|
|
77
81
|
"url": "https://github.com/AlaskaAirlines/auro-library/issues"
|
|
78
82
|
},
|
|
79
83
|
"dependencies": {
|
|
84
|
+
"@aurodesignsystem/auro-cli": "^2.5.0",
|
|
85
|
+
"@floating-ui/dom": "^1.6.11",
|
|
86
|
+
"@open-wc/testing": "^4.0.0",
|
|
80
87
|
"handlebars": "^4.7.8",
|
|
81
88
|
"markdown-magic": "^2.6.1",
|
|
82
|
-
"
|
|
83
|
-
"
|
|
89
|
+
"npm-run-all": "^4.1.5",
|
|
90
|
+
"sinon": "^20.0.0"
|
|
84
91
|
}
|
|
85
92
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { getFocusableElements } from '../Focusables/Focusables.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FocusTrap manages keyboard focus within a specified container element, ensuring that focus does not leave the container when tabbing.
|
|
5
|
+
* It is commonly used for modal dialogs or overlays to improve accessibility by trapping focus within interactive UI components.
|
|
6
|
+
*/
|
|
7
|
+
export class FocusTrap {
|
|
8
|
+
/**
|
|
9
|
+
* Creates a new FocusTrap instance for the given container element.
|
|
10
|
+
* Initializes event listeners and prepares the container for focus management.
|
|
11
|
+
*
|
|
12
|
+
* @param {HTMLElement} container The DOM element to trap focus within.
|
|
13
|
+
* @throws {Error} If the provided container is not a valid HTMLElement.
|
|
14
|
+
*/
|
|
15
|
+
constructor(container) {
|
|
16
|
+
if (!container || !(container instanceof HTMLElement)) {
|
|
17
|
+
throw new Error("FocusTrap requires a valid HTMLElement.");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
this.container = container;
|
|
21
|
+
this.tabDirection = 'forward'; // or 'backward'
|
|
22
|
+
|
|
23
|
+
this._init();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Initializes the focus trap by setting up event listeners and attributes on the container.
|
|
28
|
+
* Prepares the container for focus management, including support for shadow DOM and inert attributes.
|
|
29
|
+
*
|
|
30
|
+
* @private
|
|
31
|
+
*/
|
|
32
|
+
_init() {
|
|
33
|
+
|
|
34
|
+
// Add inert attribute to prevent focusing programmatically as well (if supported)
|
|
35
|
+
if ('inert' in HTMLElement.prototype) {
|
|
36
|
+
this.container.inert = false; // Ensure the container isn't inert
|
|
37
|
+
this.container.setAttribute('data-focus-trap-container', true); // Mark for identification
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Track tab direction
|
|
41
|
+
this.container.addEventListener('keydown', this._onKeydown);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Handles keydown events to manage tab navigation within the container.
|
|
46
|
+
* Ensures that focus wraps around when reaching the first or last focusable element.
|
|
47
|
+
*
|
|
48
|
+
* @param {KeyboardEvent} e The keyboard event triggered by user interaction.
|
|
49
|
+
* @private
|
|
50
|
+
*/
|
|
51
|
+
_onKeydown = (e) => {
|
|
52
|
+
|
|
53
|
+
if (e.key === 'Tab') {
|
|
54
|
+
|
|
55
|
+
// Set the tab direction based on the key pressed
|
|
56
|
+
this.tabDirection = e.shiftKey ? 'backward' : 'forward';
|
|
57
|
+
|
|
58
|
+
// Get the active element(s) in the document and shadow root
|
|
59
|
+
// This will include the active element in the shadow DOM if it exists
|
|
60
|
+
// Active element may be inside the shadow DOM depending on delegatesFocus, so we need to check both
|
|
61
|
+
const actives = [
|
|
62
|
+
document.activeElement,
|
|
63
|
+
...document.activeElement.shadowRoot && [document.activeElement.shadowRoot.activeElement] || []
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
// Update the focusable elements
|
|
67
|
+
const focusables = this._getFocusableElements();
|
|
68
|
+
|
|
69
|
+
// If we're at either end of the focusable elements, wrap around to the other end
|
|
70
|
+
const focusIndex =
|
|
71
|
+
(actives.includes(focusables[0]) || actives.includes(this.container)) && this.tabDirection === 'backward'
|
|
72
|
+
? focusables.length - 1
|
|
73
|
+
: actives.includes(focusables[focusables.length - 1]) && this.tabDirection === 'forward'
|
|
74
|
+
? 0
|
|
75
|
+
: null;
|
|
76
|
+
|
|
77
|
+
if (focusIndex !== null) {
|
|
78
|
+
focusables[focusIndex].focus();
|
|
79
|
+
e.preventDefault(); // Prevent default tab behavior
|
|
80
|
+
e.stopPropagation(); // Stop the event from bubbling up
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Retrieves all focusable elements within the container in DOM order, including those in shadow DOM and slots.
|
|
87
|
+
* Returns a unique, ordered array of elements that can receive focus.
|
|
88
|
+
*
|
|
89
|
+
* @returns {Array<HTMLElement>} An array of focusable elements within the container.
|
|
90
|
+
* @private
|
|
91
|
+
*/
|
|
92
|
+
_getFocusableElements() {
|
|
93
|
+
// Use the imported utility function to get focusable elements
|
|
94
|
+
const elements = getFocusableElements(this.container);
|
|
95
|
+
|
|
96
|
+
// Filter out any elements with the 'focus-bookend' class
|
|
97
|
+
return elements;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Moves focus to the first focusable element within the container.
|
|
102
|
+
* Useful for setting initial focus when activating the focus trap.
|
|
103
|
+
*/
|
|
104
|
+
focusFirstElement() {
|
|
105
|
+
const focusables = this._getFocusableElements();
|
|
106
|
+
if (focusables.length) focusables[0].focus();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Moves focus to the last focusable element within the container.
|
|
111
|
+
* Useful for setting focus when deactivating or cycling focus in reverse.
|
|
112
|
+
*/
|
|
113
|
+
focusLastElement() {
|
|
114
|
+
const focusables = this._getFocusableElements();
|
|
115
|
+
if (focusables.length) focusables[focusables.length - 1].focus();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Removes event listeners and attributes added by the focus trap.
|
|
120
|
+
* Call this method to clean up when the focus trap is no longer needed.
|
|
121
|
+
*/
|
|
122
|
+
disconnect() {
|
|
123
|
+
|
|
124
|
+
if (this.container.hasAttribute('data-focus-trap-container')) {
|
|
125
|
+
this.container.removeAttribute('data-focus-trap-container');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.container.removeEventListener('keydown', this._onKeydown);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FocusTrap } from './FocusTrap.mjs';
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Try this alternative approach
|
|
2
|
+
import { expect } from '@open-wc/testing';
|
|
3
|
+
import { html, render } from 'lit';
|
|
4
|
+
import sinon from 'sinon';
|
|
5
|
+
import { FocusTrap } from '../FocusTrap.mjs';
|
|
6
|
+
|
|
7
|
+
async function fixture(template) {
|
|
8
|
+
const wrapper = document.createElement('div');
|
|
9
|
+
render(template, wrapper);
|
|
10
|
+
document.body.appendChild(wrapper);
|
|
11
|
+
await new Promise(resolve => setTimeout(resolve, 0)); // Give browser time to render
|
|
12
|
+
return wrapper.firstElementChild;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('FocusTrap', () => {
|
|
16
|
+
let container;
|
|
17
|
+
let focusTrap;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
// Create a container with focusable elements
|
|
21
|
+
container = await fixture(html`
|
|
22
|
+
<div>
|
|
23
|
+
<button id="first">First</button>
|
|
24
|
+
<input id="middle" type="text" />
|
|
25
|
+
<a href="#" id="last">Last</a>
|
|
26
|
+
</div>
|
|
27
|
+
`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
if (focusTrap) {
|
|
32
|
+
focusTrap.disconnect();
|
|
33
|
+
focusTrap = null;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('constructor', () => {
|
|
38
|
+
it('should create a new instance with a valid container', () => {
|
|
39
|
+
expect(() => new FocusTrap(container)).to.not.throw();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should throw an error if container is not a valid HTMLElement', () => {
|
|
43
|
+
expect(() => new FocusTrap(null)).to.throw('FocusTrap requires a valid HTMLElement');
|
|
44
|
+
expect(() => new FocusTrap({})).to.throw('FocusTrap requires a valid HTMLElement');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('initialization', () => {
|
|
49
|
+
it('should set attributes on the container', () => {
|
|
50
|
+
focusTrap = new FocusTrap(container);
|
|
51
|
+
|
|
52
|
+
if ('inert' in HTMLElement.prototype) {
|
|
53
|
+
expect(container.inert).to.be.false;
|
|
54
|
+
expect(container.hasAttribute('data-focus-trap-container')).to.be.true;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('focus management', () => {
|
|
60
|
+
it('should focus the first element when focusFirstElement is called', () => {
|
|
61
|
+
focusTrap = new FocusTrap(container);
|
|
62
|
+
const firstButton = container.querySelector('#first');
|
|
63
|
+
|
|
64
|
+
focusTrap.focusFirstElement();
|
|
65
|
+
expect(document.activeElement).to.equal(firstButton);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should focus the last element when focusLastElement is called', () => {
|
|
69
|
+
focusTrap = new FocusTrap(container);
|
|
70
|
+
const lastLink = container.querySelector('#last');
|
|
71
|
+
|
|
72
|
+
focusTrap.focusLastElement();
|
|
73
|
+
expect(document.activeElement).to.equal(lastLink);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('keyboard navigation', () => {
|
|
78
|
+
it('should trap focus and cycle to the first element when tabbing from the last element', async () => {
|
|
79
|
+
focusTrap = new FocusTrap(container);
|
|
80
|
+
const lastLink = container.querySelector('#last');
|
|
81
|
+
const firstButton = container.querySelector('#first');
|
|
82
|
+
|
|
83
|
+
// Create a spy on the focus method of the first button
|
|
84
|
+
const firstButtonFocusSpy = sinon.spy(firstButton, 'focus');
|
|
85
|
+
|
|
86
|
+
lastLink.focus();
|
|
87
|
+
expect(document.activeElement).to.equal(lastLink);
|
|
88
|
+
|
|
89
|
+
// Simulate Tab key press
|
|
90
|
+
const tabEvent = new KeyboardEvent('keydown', {
|
|
91
|
+
key: 'Tab',
|
|
92
|
+
bubbles: true,
|
|
93
|
+
composed: true
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
lastLink.dispatchEvent(tabEvent);
|
|
97
|
+
|
|
98
|
+
// Check if focus method was called on the first element
|
|
99
|
+
expect(firstButtonFocusSpy.calledOnce).to.be.true;
|
|
100
|
+
firstButtonFocusSpy.restore(); // Clean up the spy
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should trap focus and cycle to the last element when shift-tabbing from the first element', async () => {
|
|
104
|
+
focusTrap = new FocusTrap(container);
|
|
105
|
+
const firstButton = container.querySelector('#first');
|
|
106
|
+
const lastLink = container.querySelector('#last');
|
|
107
|
+
|
|
108
|
+
// Create a spy on the focus method of the last link
|
|
109
|
+
const lastLinkFocusSpy = sinon.spy(lastLink, 'focus');
|
|
110
|
+
|
|
111
|
+
firstButton.focus();
|
|
112
|
+
expect(document.activeElement).to.equal(firstButton);
|
|
113
|
+
|
|
114
|
+
// Simulate Shift+Tab key press
|
|
115
|
+
const shiftTabEvent = new KeyboardEvent('keydown', {
|
|
116
|
+
key: 'Tab',
|
|
117
|
+
shiftKey: true,
|
|
118
|
+
bubbles: true,
|
|
119
|
+
cancelable: true
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
firstButton.dispatchEvent(shiftTabEvent);
|
|
123
|
+
|
|
124
|
+
// Check if focus method was called on the last element
|
|
125
|
+
expect(lastLinkFocusSpy.calledOnce).to.be.true;
|
|
126
|
+
lastLinkFocusSpy.restore(); // Clean up the spy
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('cleanup', () => {
|
|
131
|
+
it('should remove attributes when disconnected', () => {
|
|
132
|
+
focusTrap = new FocusTrap(container);
|
|
133
|
+
|
|
134
|
+
focusTrap.disconnect();
|
|
135
|
+
|
|
136
|
+
expect(container.hasAttribute('data-focus-trap-container')).to.be.false;
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('shadow DOM support', () => {
|
|
141
|
+
it('should work with shadow DOM elements', async () => {
|
|
142
|
+
// Create a custom element with shadow DOM
|
|
143
|
+
const shadowHost = await fixture(html`
|
|
144
|
+
<div id="shadow-host"></div>
|
|
145
|
+
`);
|
|
146
|
+
|
|
147
|
+
// Create shadow root
|
|
148
|
+
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
|
|
149
|
+
|
|
150
|
+
// Add focusable elements to shadow DOM
|
|
151
|
+
shadowRoot.innerHTML = `
|
|
152
|
+
<button id="shadow-first">First</button>
|
|
153
|
+
<input id="shadow-middle" type="text" />
|
|
154
|
+
<a href="#" id="shadow-last">Last</a>
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
focusTrap = new FocusTrap(shadowHost);
|
|
158
|
+
const firstButton = shadowRoot.querySelector('#shadow-first');
|
|
159
|
+
const lastLink = shadowRoot.querySelector('#shadow-last');
|
|
160
|
+
|
|
161
|
+
focusTrap.focusFirstElement();
|
|
162
|
+
expect(shadowRoot.activeElement).to.equal(firstButton);
|
|
163
|
+
|
|
164
|
+
focusTrap.focusLastElement();
|
|
165
|
+
expect(shadowRoot.activeElement).to.equal(lastLink);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Selectors for focusable elements
|
|
2
|
+
export const FOCUSABLE_SELECTORS = [
|
|
3
|
+
'a[href]',
|
|
4
|
+
'button:not([disabled])',
|
|
5
|
+
'textarea:not([disabled])',
|
|
6
|
+
'input:not([disabled])',
|
|
7
|
+
'select:not([disabled])',
|
|
8
|
+
'[role="tab"]:not([disabled])',
|
|
9
|
+
'[role="link"]:not([disabled])',
|
|
10
|
+
'[role="button"]:not([disabled])',
|
|
11
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
12
|
+
'[contenteditable]:not([contenteditable="false"])'
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// List of custom components that are known to be focusable
|
|
16
|
+
export const FOCUSABLE_COMPONENTS = [
|
|
17
|
+
'auro-checkbox',
|
|
18
|
+
'auro-radio',
|
|
19
|
+
'auro-dropdown',
|
|
20
|
+
'auro-button',
|
|
21
|
+
'auro-combobox',
|
|
22
|
+
'auro-input',
|
|
23
|
+
'auro-counter',
|
|
24
|
+
'auro-menu',
|
|
25
|
+
'auro-select',
|
|
26
|
+
'auro-datepicker',
|
|
27
|
+
'auro-hyperlink',
|
|
28
|
+
'auro-accordion',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Determines if a given element is a custom focusable component.
|
|
33
|
+
* Returns true if the element matches a known focusable component and is not disabled.
|
|
34
|
+
*
|
|
35
|
+
* @param {HTMLElement} element The element to check for focusability.
|
|
36
|
+
* @returns {boolean} True if the element is a focusable custom component, false otherwise.
|
|
37
|
+
*/
|
|
38
|
+
export function isFocusableComponent(element) {
|
|
39
|
+
const componentName = element.tagName.toLowerCase();
|
|
40
|
+
|
|
41
|
+
// Guard Clause: Element is a focusable component
|
|
42
|
+
if (!FOCUSABLE_COMPONENTS.includes(componentName)) return false;
|
|
43
|
+
|
|
44
|
+
// Guard Clause: Element is not disabled
|
|
45
|
+
if (element.hasAttribute('disabled')) return false;
|
|
46
|
+
|
|
47
|
+
// Guard Clause: The element is a hyperlink and has no href attribute
|
|
48
|
+
if (componentName.match("hyperlink") && !element.hasAttribute('href')) return false;
|
|
49
|
+
|
|
50
|
+
// If all guard clauses pass, the element is a focusable component
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Retrieves all focusable elements within the container in DOM order, including those in shadow DOM and slots.
|
|
56
|
+
* Returns a unique, ordered array of elements that can receive focus.
|
|
57
|
+
*
|
|
58
|
+
* @param {HTMLElement} container The container to search within
|
|
59
|
+
* @returns {Array<HTMLElement>} An array of focusable elements within the container.
|
|
60
|
+
*/
|
|
61
|
+
export function getFocusableElements(container) {
|
|
62
|
+
// Get elements in DOM order by walking the tree
|
|
63
|
+
const orderedFocusableElements = [];
|
|
64
|
+
|
|
65
|
+
// Define a recursive function to collect focusable elements in DOM order
|
|
66
|
+
const collectFocusableElements = (root) => {
|
|
67
|
+
// Check if current element is focusable
|
|
68
|
+
if (root.nodeType === Node.ELEMENT_NODE) {
|
|
69
|
+
// Check if this is a custom component that is focusable
|
|
70
|
+
const isComponentFocusable = isFocusableComponent(root);
|
|
71
|
+
|
|
72
|
+
if (isComponentFocusable) {
|
|
73
|
+
// Add the component itself as a focusable element and don't traverse its shadow DOM
|
|
74
|
+
orderedFocusableElements.push(root);
|
|
75
|
+
return; // Skip traversing inside this component
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Check if the element itself matches any selector
|
|
79
|
+
for (const selector of FOCUSABLE_SELECTORS) {
|
|
80
|
+
if (root.matches?.(selector)) {
|
|
81
|
+
orderedFocusableElements.push(root);
|
|
82
|
+
break; // Once we know it's focusable, no need to check other selectors
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Process shadow DOM only for non-Auro components
|
|
87
|
+
if (root.shadowRoot) {
|
|
88
|
+
// Process shadow DOM children in order
|
|
89
|
+
if (root.shadowRoot.children) {
|
|
90
|
+
Array.from(root.shadowRoot.children).forEach(child => {
|
|
91
|
+
collectFocusableElements(child);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Process slots and their assigned nodes in order
|
|
97
|
+
if (root.tagName === 'SLOT') {
|
|
98
|
+
const assignedNodes = root.assignedNodes({ flatten: true });
|
|
99
|
+
for (const node of assignedNodes) {
|
|
100
|
+
collectFocusableElements(node);
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
// Process light DOM children in order
|
|
104
|
+
if (root.children) {
|
|
105
|
+
Array.from(root.children).forEach(child => {
|
|
106
|
+
collectFocusableElements(child);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Start the traversal from the container
|
|
114
|
+
collectFocusableElements(container);
|
|
115
|
+
|
|
116
|
+
// Remove duplicates that might have been collected through different paths
|
|
117
|
+
// while preserving order
|
|
118
|
+
const uniqueElements = [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
|
|
121
|
+
for (const element of orderedFocusableElements) {
|
|
122
|
+
if (!seen.has(element)) {
|
|
123
|
+
seen.add(element);
|
|
124
|
+
uniqueElements.push(element);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return uniqueElements;
|
|
129
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { isFocusableComponent, getFocusableElements, FOCUSABLE_SELECTORS, FOCUSABLE_COMPONENTS } from './Focusables.mjs';
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/* eslint-disable max-classes-per-file */
|
|
2
|
+
import { fixture, html, expect, elementUpdated } from '@open-wc/testing';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
isFocusableComponent,
|
|
6
|
+
getFocusableElements
|
|
7
|
+
} from '../Focusables.mjs';
|
|
8
|
+
|
|
9
|
+
describe('isFocusableComponent', () => {
|
|
10
|
+
it('returns true for enabled custom focusable components', async () => {
|
|
11
|
+
for (const tag of [
|
|
12
|
+
'auro-checkbox', 'auro-radio', 'auro-dropdown', 'auro-button', 'auro-combobox',
|
|
13
|
+
'auro-input', 'auro-counter', 'auro-menu', 'auro-select', 'auro-datepicker',
|
|
14
|
+
'auro-hyperlink', 'auro-accordion'
|
|
15
|
+
]) {
|
|
16
|
+
const el = document.createElement(tag);
|
|
17
|
+
if (tag === 'auro-hyperlink') {
|
|
18
|
+
el.setAttribute('href', '#');
|
|
19
|
+
}
|
|
20
|
+
document.body.appendChild(el);
|
|
21
|
+
expect(isFocusableComponent(el)).to.be.true;
|
|
22
|
+
el.remove();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns false for custom components with disabled attribute', async () => {
|
|
27
|
+
for (const tag of [
|
|
28
|
+
'auro-checkbox', 'auro-radio', 'auro-dropdown', 'auro-button', 'auro-combobox',
|
|
29
|
+
'auro-input', 'auro-counter', 'auro-menu', 'auro-select', 'auro-datepicker',
|
|
30
|
+
'auro-hyperlink', 'auro-accordion'
|
|
31
|
+
]) {
|
|
32
|
+
const el = document.createElement(tag);
|
|
33
|
+
el.setAttribute('disabled', '');
|
|
34
|
+
if (tag === 'auro-hyperlink') {
|
|
35
|
+
el.setAttribute('href', '#');
|
|
36
|
+
}
|
|
37
|
+
document.body.appendChild(el);
|
|
38
|
+
expect(isFocusableComponent(el)).to.be.false;
|
|
39
|
+
el.remove();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns false for auro-hyperlink without href', async () => {
|
|
44
|
+
const el = document.createElement('auro-hyperlink');
|
|
45
|
+
document.body.appendChild(el);
|
|
46
|
+
expect(isFocusableComponent(el)).to.be.false;
|
|
47
|
+
el.remove();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns false for non-custom elements', async () => {
|
|
51
|
+
const el = document.createElement('div');
|
|
52
|
+
document.body.appendChild(el);
|
|
53
|
+
expect(isFocusableComponent(el)).to.be.false;
|
|
54
|
+
el.remove();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('getFocusableElements', () => {
|
|
59
|
+
it('finds standard focusable elements', async () => {
|
|
60
|
+
const el = await fixture(html`
|
|
61
|
+
<div>
|
|
62
|
+
<button id="btn"></button>
|
|
63
|
+
<input id="input">
|
|
64
|
+
<a id="link" href="#"></a>
|
|
65
|
+
<textarea id="ta"></textarea>
|
|
66
|
+
<select id="sel"></select>
|
|
67
|
+
</div>
|
|
68
|
+
`);
|
|
69
|
+
const focusables = getFocusableElements(el);
|
|
70
|
+
expect(focusables.map(e => e.id)).to.include.members(['btn', 'input', 'link', 'ta', 'sel']);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('skips disabled elements', async () => {
|
|
74
|
+
const el = await fixture(html`
|
|
75
|
+
<div>
|
|
76
|
+
<button id="btn" disabled></button>
|
|
77
|
+
<input id="input" disabled>
|
|
78
|
+
<textarea id="ta" disabled></textarea>
|
|
79
|
+
<select id="sel" disabled></select>
|
|
80
|
+
</div>
|
|
81
|
+
`);
|
|
82
|
+
const focusables = getFocusableElements(el);
|
|
83
|
+
expect(focusables.length).to.equal(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('finds custom focusable components', async () => {
|
|
87
|
+
const el = await fixture(html`
|
|
88
|
+
<div>
|
|
89
|
+
<auro-checkbox id="cb"></auro-checkbox>
|
|
90
|
+
<auro-hyperlink id="hl" href="#"></auro-hyperlink>
|
|
91
|
+
<auro-button id="ab"></auro-button>
|
|
92
|
+
</div>
|
|
93
|
+
`);
|
|
94
|
+
const focusables = getFocusableElements(el);
|
|
95
|
+
expect(focusables.map(e => e.id)).to.include.members(['cb', 'hl', 'ab']);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('skips disabled custom components', async () => {
|
|
99
|
+
const el = await fixture(html`
|
|
100
|
+
<div>
|
|
101
|
+
<auro-checkbox id="cb" disabled></auro-checkbox>
|
|
102
|
+
<auro-hyperlink id="hl" disabled href="#"></auro-hyperlink>
|
|
103
|
+
</div>
|
|
104
|
+
`);
|
|
105
|
+
const focusables = getFocusableElements(el);
|
|
106
|
+
expect(focusables.length).to.equal(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('finds elements in shadow DOM', async () => {
|
|
110
|
+
class ShadowEl extends HTMLElement {
|
|
111
|
+
constructor() {
|
|
112
|
+
super();
|
|
113
|
+
this.attachShadow({ mode: 'open' });
|
|
114
|
+
}
|
|
115
|
+
connectedCallback() {
|
|
116
|
+
this.shadowRoot.innerHTML = `<button id="shadowBtn"></button>`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
customElements.define('shadow-el', ShadowEl);
|
|
120
|
+
const el = await fixture(html`
|
|
121
|
+
<div>
|
|
122
|
+
<shadow-el id="host"></shadow-el>
|
|
123
|
+
</div>
|
|
124
|
+
`);
|
|
125
|
+
await elementUpdated(el);
|
|
126
|
+
const focusables = getFocusableElements(el);
|
|
127
|
+
// Should find the button inside shadow DOM
|
|
128
|
+
const shadowBtn = el.querySelector('shadow-el').shadowRoot.getElementById('shadowBtn');
|
|
129
|
+
expect(focusables).to.include(shadowBtn);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('finds elements assigned to slots', async () => {
|
|
133
|
+
class SlotEl extends HTMLElement {
|
|
134
|
+
constructor() {
|
|
135
|
+
super();
|
|
136
|
+
this.attachShadow({ mode: 'open' });
|
|
137
|
+
}
|
|
138
|
+
connectedCallback() {
|
|
139
|
+
this.shadowRoot.innerHTML = `<slot></slot>`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
customElements.define('slot-el', SlotEl);
|
|
143
|
+
const el = await fixture(html`
|
|
144
|
+
<slot-el>
|
|
145
|
+
<button id="slottedBtn"></button>
|
|
146
|
+
</slot-el>
|
|
147
|
+
`);
|
|
148
|
+
await elementUpdated(el);
|
|
149
|
+
const focusables = getFocusableElements(el);
|
|
150
|
+
const slottedBtn = el.querySelector('#slottedBtn');
|
|
151
|
+
expect(focusables).to.include(slottedBtn);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not return duplicates', async () => {
|
|
155
|
+
// This is a contrived case, but let's check that duplicates are not returned
|
|
156
|
+
const el = await fixture(html`
|
|
157
|
+
<div>
|
|
158
|
+
<button id="btn"></button>
|
|
159
|
+
</div>
|
|
160
|
+
`);
|
|
161
|
+
const focusables = getFocusableElements(el);
|
|
162
|
+
// Should only have one instance of the button
|
|
163
|
+
expect(focusables.filter(e => e.id === 'btn').length).to.equal(1);
|
|
164
|
+
});
|
|
165
|
+
});
|