accessible-kit 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/package.json +1 -1
- package/src/css/a11y-offcanvas.theme.css +7 -6
- package/src/js/a11y-modal.js +51 -10
- package/src/js/a11y-offcanvas.js +51 -10
- package/src/js/index.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,51 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.0.5] - 2025-12-21
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Focus Trap - Manual Tab Navigation**: Complete rewrite of Tab key handling in Offcanvas and Modal components
|
|
12
|
+
- **BREAKING CHANGE in behavior**: Focus trap now completely overrides native browser Tab behavior
|
|
13
|
+
- Always prevents default Tab action and manually controls focus movement
|
|
14
|
+
- Eliminates `aria-hidden` violations caused by browser trying to focus hidden elements
|
|
15
|
+
- `updateFocusableElements()` is called on every Tab keypress to catch dynamic DOM changes
|
|
16
|
+
- Focus only moves through elements in the filtered `focusableElements` list
|
|
17
|
+
- Properly handles nested collapse components - focus skips closed submenus and includes opened ones
|
|
18
|
+
|
|
19
|
+
### Details
|
|
20
|
+
**Problem solved:**
|
|
21
|
+
When using Tab key in offcanvas/modal with dynamic content (e.g., collapse menus), the browser's native Tab behavior would attempt to focus elements with `aria-hidden="true"`, causing console warnings and accessibility violations.
|
|
22
|
+
|
|
23
|
+
**Solution:**
|
|
24
|
+
The focus trap now takes complete control of Tab navigation:
|
|
25
|
+
1. Every Tab keypress prevents default browser behavior
|
|
26
|
+
2. Updates the list of focusable elements to reflect current DOM state
|
|
27
|
+
3. Manually calculates and focuses the next/previous element from the filtered list
|
|
28
|
+
4. Elements inside `aria-hidden="true"` containers are never focused
|
|
29
|
+
|
|
30
|
+
This ensures perfect compatibility with dynamic components like animated collapse panels in navigation menus.
|
|
31
|
+
|
|
32
|
+
## [1.0.4] - 2025-12-21
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
- **Focus Trap - Initial Implementation**: Improved focus trap in Offcanvas and Modal components
|
|
36
|
+
- Focus trap now correctly excludes elements with `aria-hidden="true"` and their children
|
|
37
|
+
- Fixed timing issue where `updateFocusableElements()` was called before CSS visibility changes applied
|
|
38
|
+
- Focus trap now properly skips hidden elements in collapsed/nested components
|
|
39
|
+
- Added comprehensive filtering for hidden, invisible, and aria-hidden elements
|
|
40
|
+
- Removed `visibility: hidden` check from filter to prevent false positives during panel opening
|
|
41
|
+
- Works correctly with both animated (CSS Grid) and non-animated collapse panels
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- Added `:focus-visible` styles to Offcanvas theme for better keyboard navigation visibility
|
|
45
|
+
- Added navigation demo with nested collapse submenus to Offcanvas demo page
|
|
46
|
+
|
|
47
|
+
### Details
|
|
48
|
+
The focus trap improvements ensure that keyboard navigation works correctly in complex scenarios:
|
|
49
|
+
- When offcanvas/modal contains collapse components, Tab key properly skips hidden submenu items
|
|
50
|
+
- Focus is set after CSS transitions complete, preventing "no focusable elements" issue
|
|
51
|
+
- Users can now navigate nested menus with full accessibility support
|
|
52
|
+
|
|
8
53
|
## [1.0.3] - 2025-12-20
|
|
9
54
|
|
|
10
55
|
### Fixed
|
|
@@ -89,6 +134,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
89
134
|
- Zero dependencies
|
|
90
135
|
- Full TypeScript-ready exports
|
|
91
136
|
|
|
137
|
+
[1.0.5]: https://github.com/5ulo/accessible-kit/compare/v1.0.4...v1.0.5
|
|
138
|
+
[1.0.4]: https://github.com/5ulo/accessible-kit/compare/v1.0.3...v1.0.4
|
|
92
139
|
[1.0.3]: https://github.com/5ulo/accessible-kit/compare/v1.0.2...v1.0.3
|
|
93
140
|
[1.0.2]: https://github.com/5ulo/accessible-kit/compare/v1.0.1...v1.0.2
|
|
94
141
|
[1.0.1]: https://github.com/5ulo/accessible-kit/compare/v1.0.0...v1.0.1
|
package/package.json
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
/* Obsahuje vizualne nastavenia: farby, velkosti, bordery, radiusy, spacing */
|
|
3
3
|
/* Pre zmenu vzhladu upravte tento subor */
|
|
4
4
|
|
|
5
|
+
/* Focus visible for all focusable elements inside offcanvas */
|
|
6
|
+
[data-offcanvas-panel] :focus-visible {
|
|
7
|
+
outline: 2px solid #3b82f6;
|
|
8
|
+
outline-offset: 2px;
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
/* Offcanvas panel */
|
|
6
12
|
[data-offcanvas-panel] {
|
|
7
13
|
width: 320px;
|
|
@@ -70,11 +76,6 @@
|
|
|
70
76
|
color: #111827;
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
[data-offcanvas-close]:focus {
|
|
74
|
-
outline: 2px solid #3b82f6;
|
|
75
|
-
outline-offset: 2px;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
79
|
/* Backdrop */
|
|
79
80
|
[data-offcanvas-backdrop] {
|
|
80
81
|
background: rgba(0, 0, 0, 0.5);
|
|
@@ -151,7 +152,7 @@
|
|
|
151
152
|
border: 2px solid currentColor;
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
[data-offcanvas-
|
|
155
|
+
[data-offcanvas-panel] :focus-visible {
|
|
155
156
|
outline-width: 3px;
|
|
156
157
|
}
|
|
157
158
|
}
|
package/src/js/a11y-modal.js
CHANGED
|
@@ -126,17 +126,16 @@ class AccessibleModal {
|
|
|
126
126
|
document.body.classList.add("modal-open");
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
// Update focusable elements
|
|
130
|
-
this.updateFocusableElements();
|
|
131
|
-
|
|
132
|
-
// Focus first element
|
|
129
|
+
// Update focusable elements and focus - needs to wait for CSS to apply
|
|
133
130
|
setTimeout(() => {
|
|
131
|
+
this.updateFocusableElements();
|
|
132
|
+
|
|
134
133
|
if (this.firstFocusable) {
|
|
135
134
|
this.firstFocusable.focus();
|
|
136
135
|
} else {
|
|
137
136
|
this.modal.focus();
|
|
138
137
|
}
|
|
139
|
-
},
|
|
138
|
+
}, 50);
|
|
140
139
|
|
|
141
140
|
// Callback
|
|
142
141
|
if (this.options.onOpen) {
|
|
@@ -188,11 +187,38 @@ class AccessibleModal {
|
|
|
188
187
|
this.focusableElements = Array.from(
|
|
189
188
|
this.dialog.querySelectorAll(focusableSelectors.join(","))
|
|
190
189
|
).filter((el) => {
|
|
191
|
-
|
|
190
|
+
// Check if element is visible
|
|
191
|
+
const isVisible = (
|
|
192
192
|
el.offsetWidth > 0 ||
|
|
193
193
|
el.offsetHeight > 0 ||
|
|
194
194
|
el.getClientRects().length > 0
|
|
195
195
|
);
|
|
196
|
+
|
|
197
|
+
if (!isVisible) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Check if element or any parent has aria-hidden="true"
|
|
202
|
+
let currentElement = el;
|
|
203
|
+
while (currentElement && currentElement !== this.dialog) {
|
|
204
|
+
if (currentElement.getAttribute('aria-hidden') === 'true') {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
currentElement = currentElement.parentElement;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Check if element has hidden attribute
|
|
211
|
+
if (el.hasAttribute('hidden')) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Check computed style for display (not visibility, as modal is opening)
|
|
216
|
+
const style = window.getComputedStyle(el);
|
|
217
|
+
if (style.display === 'none') {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return true;
|
|
196
222
|
});
|
|
197
223
|
|
|
198
224
|
this.firstFocusable = this.focusableElements[0] || null;
|
|
@@ -203,24 +229,39 @@ class AccessibleModal {
|
|
|
203
229
|
handleFocusTrap(e) {
|
|
204
230
|
if (!this.isOpen || e.key !== "Tab") return;
|
|
205
231
|
|
|
232
|
+
// Update focusable elements before each Tab to catch dynamic changes (e.g., collapse panels)
|
|
233
|
+
this.updateFocusableElements();
|
|
234
|
+
|
|
206
235
|
// If no focusable elements, prevent default
|
|
207
236
|
if (this.focusableElements.length === 0) {
|
|
208
237
|
e.preventDefault();
|
|
209
238
|
return;
|
|
210
239
|
}
|
|
211
240
|
|
|
241
|
+
// Always prevent default Tab behavior - we'll handle focus manually
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
|
|
244
|
+
// Find current element index in focusable list
|
|
245
|
+
const currentIndex = this.focusableElements.indexOf(document.activeElement);
|
|
246
|
+
|
|
212
247
|
// Shift + Tab (backward)
|
|
213
248
|
if (e.shiftKey) {
|
|
214
|
-
if (
|
|
215
|
-
|
|
249
|
+
if (currentIndex <= 0) {
|
|
250
|
+
// At first element or not in list - go to last
|
|
216
251
|
this.lastFocusable.focus();
|
|
252
|
+
} else {
|
|
253
|
+
// Go to previous element
|
|
254
|
+
this.focusableElements[currentIndex - 1].focus();
|
|
217
255
|
}
|
|
218
256
|
}
|
|
219
257
|
// Tab (forward)
|
|
220
258
|
else {
|
|
221
|
-
if (
|
|
222
|
-
|
|
259
|
+
if (currentIndex === -1 || currentIndex >= this.focusableElements.length - 1) {
|
|
260
|
+
// Not in list or at last element - go to first
|
|
223
261
|
this.firstFocusable.focus();
|
|
262
|
+
} else {
|
|
263
|
+
// Go to next element
|
|
264
|
+
this.focusableElements[currentIndex + 1].focus();
|
|
224
265
|
}
|
|
225
266
|
}
|
|
226
267
|
}
|
package/src/js/a11y-offcanvas.js
CHANGED
|
@@ -131,17 +131,16 @@ class AccessibleOffcanvas {
|
|
|
131
131
|
document.body.classList.add("offcanvas-open");
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
// Update focusable elements
|
|
135
|
-
this.updateFocusableElements();
|
|
136
|
-
|
|
137
|
-
// Focus first element
|
|
134
|
+
// Update focusable elements and focus - needs to wait for CSS to apply
|
|
138
135
|
setTimeout(() => {
|
|
136
|
+
this.updateFocusableElements();
|
|
137
|
+
|
|
139
138
|
if (this.firstFocusable) {
|
|
140
139
|
this.firstFocusable.focus();
|
|
141
140
|
} else {
|
|
142
141
|
this.panel.focus();
|
|
143
142
|
}
|
|
144
|
-
},
|
|
143
|
+
}, 50);
|
|
145
144
|
|
|
146
145
|
// Callback
|
|
147
146
|
if (this.options.onOpen) {
|
|
@@ -196,11 +195,38 @@ class AccessibleOffcanvas {
|
|
|
196
195
|
this.focusableElements = Array.from(
|
|
197
196
|
this.panel.querySelectorAll(focusableSelectors.join(","))
|
|
198
197
|
).filter((el) => {
|
|
199
|
-
|
|
198
|
+
// Check if element is visible
|
|
199
|
+
const isVisible = (
|
|
200
200
|
el.offsetWidth > 0 ||
|
|
201
201
|
el.offsetHeight > 0 ||
|
|
202
202
|
el.getClientRects().length > 0
|
|
203
203
|
);
|
|
204
|
+
|
|
205
|
+
if (!isVisible) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check if element or any parent has aria-hidden="true"
|
|
210
|
+
let currentElement = el;
|
|
211
|
+
while (currentElement && currentElement !== this.panel) {
|
|
212
|
+
if (currentElement.getAttribute('aria-hidden') === 'true') {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
currentElement = currentElement.parentElement;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Check if element has hidden attribute
|
|
219
|
+
if (el.hasAttribute('hidden')) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check computed style for display (not visibility, as panel is opening)
|
|
224
|
+
const style = window.getComputedStyle(el);
|
|
225
|
+
if (style.display === 'none') {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return true;
|
|
204
230
|
});
|
|
205
231
|
|
|
206
232
|
this.firstFocusable = this.focusableElements[0] || null;
|
|
@@ -211,24 +237,39 @@ class AccessibleOffcanvas {
|
|
|
211
237
|
handleFocusTrap(e) {
|
|
212
238
|
if (!this.isOpen || e.key !== "Tab") return;
|
|
213
239
|
|
|
240
|
+
// Update focusable elements before each Tab to catch dynamic changes (e.g., collapse panels)
|
|
241
|
+
this.updateFocusableElements();
|
|
242
|
+
|
|
214
243
|
// If no focusable elements, prevent default
|
|
215
244
|
if (this.focusableElements.length === 0) {
|
|
216
245
|
e.preventDefault();
|
|
217
246
|
return;
|
|
218
247
|
}
|
|
219
248
|
|
|
249
|
+
// Always prevent default Tab behavior - we'll handle focus manually
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
|
|
252
|
+
// Find current element index in focusable list
|
|
253
|
+
const currentIndex = this.focusableElements.indexOf(document.activeElement);
|
|
254
|
+
|
|
220
255
|
// Shift + Tab (backward)
|
|
221
256
|
if (e.shiftKey) {
|
|
222
|
-
if (
|
|
223
|
-
|
|
257
|
+
if (currentIndex <= 0) {
|
|
258
|
+
// At first element or not in list - go to last
|
|
224
259
|
this.lastFocusable.focus();
|
|
260
|
+
} else {
|
|
261
|
+
// Go to previous element
|
|
262
|
+
this.focusableElements[currentIndex - 1].focus();
|
|
225
263
|
}
|
|
226
264
|
}
|
|
227
265
|
// Tab (forward)
|
|
228
266
|
else {
|
|
229
|
-
if (
|
|
230
|
-
|
|
267
|
+
if (currentIndex === -1 || currentIndex >= this.focusableElements.length - 1) {
|
|
268
|
+
// Not in list or at last element - go to first
|
|
231
269
|
this.firstFocusable.focus();
|
|
270
|
+
} else {
|
|
271
|
+
// Go to next element
|
|
272
|
+
this.focusableElements[currentIndex + 1].focus();
|
|
232
273
|
}
|
|
233
274
|
}
|
|
234
275
|
}
|