accessible-kit 1.0.4 → 1.0.6

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 CHANGED
@@ -5,15 +5,58 @@ 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.6] - 2026-01-15
9
+
10
+ ### Added
11
+ - **Dropdown - Multiple ARIA Pattern Support**: Added `data-dropdown-role` attribute to support dialog and listbox patterns in addition to the default menu pattern
12
+ - **New attribute**: `data-dropdown-role` accepts `"menu"` (default), `"dialog"`, or `"listbox"`
13
+ - Automatically sets appropriate `aria-haspopup` value based on role type:
14
+ - `role="menu"` → `aria-haspopup="true"`
15
+ - `role="dialog"` → `aria-haspopup="dialog"`
16
+ - `role="listbox"` → `aria-haspopup="listbox"`
17
+ - Conditionally applies correct item roles (`menuitem`, none for dialog, `option` for listbox)
18
+ - Pattern-specific keyboard navigation:
19
+ - Menu/listbox: Arrow keys, Home/End, Tab closes dropdown
20
+ - Dialog: Arrow keys, Tab navigates within (doesn't close)
21
+ - **Use case example**: Radio playlists, media controls, or complex interactive widgets that aren't semantic "menus"
22
+ - Added comprehensive documentation and live example (radio playlist) to demo page
23
+ - Updated README with ARIA pattern guidance and when to use each pattern
24
+ - **Backwards compatible**: Defaults to `"menu"` pattern, existing implementations unchanged
25
+
26
+ ## [1.0.5] - 2025-12-21
27
+
28
+ ### Fixed
29
+ - **Focus Trap - Manual Tab Navigation**: Complete rewrite of Tab key handling in Offcanvas and Modal components
30
+ - **BREAKING CHANGE in behavior**: Focus trap now completely overrides native browser Tab behavior
31
+ - Always prevents default Tab action and manually controls focus movement
32
+ - Eliminates `aria-hidden` violations caused by browser trying to focus hidden elements
33
+ - `updateFocusableElements()` is called on every Tab keypress to catch dynamic DOM changes
34
+ - Focus only moves through elements in the filtered `focusableElements` list
35
+ - Properly handles nested collapse components - focus skips closed submenus and includes opened ones
36
+
37
+ ### Details
38
+ **Problem solved:**
39
+ 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.
40
+
41
+ **Solution:**
42
+ The focus trap now takes complete control of Tab navigation:
43
+ 1. Every Tab keypress prevents default browser behavior
44
+ 2. Updates the list of focusable elements to reflect current DOM state
45
+ 3. Manually calculates and focuses the next/previous element from the filtered list
46
+ 4. Elements inside `aria-hidden="true"` containers are never focused
47
+
48
+ This ensures perfect compatibility with dynamic components like animated collapse panels in navigation menus.
49
+
8
50
  ## [1.0.4] - 2025-12-21
9
51
 
10
52
  ### Fixed
11
- - **Focus Trap**: Fixed focus trap in Offcanvas and Modal components
53
+ - **Focus Trap - Initial Implementation**: Improved focus trap in Offcanvas and Modal components
12
54
  - Focus trap now correctly excludes elements with `aria-hidden="true"` and their children
13
55
  - Fixed timing issue where `updateFocusableElements()` was called before CSS visibility changes applied
14
- - Focus trap now properly skips hidden elements in collapsed/nested components (e.g., collapse submenus in navigation)
56
+ - Focus trap now properly skips hidden elements in collapsed/nested components
15
57
  - Added comprehensive filtering for hidden, invisible, and aria-hidden elements
16
58
  - Removed `visibility: hidden` check from filter to prevent false positives during panel opening
59
+ - Works correctly with both animated (CSS Grid) and non-animated collapse panels
17
60
 
18
61
  ### Added
19
62
  - Added `:focus-visible` styles to Offcanvas theme for better keyboard navigation visibility
@@ -109,6 +152,8 @@ document.addEventListener('DOMContentLoaded', () => {
109
152
  - Zero dependencies
110
153
  - Full TypeScript-ready exports
111
154
 
155
+ [1.0.6]: https://github.com/5ulo/accessible-kit/compare/v1.0.5...v1.0.6
156
+ [1.0.5]: https://github.com/5ulo/accessible-kit/compare/v1.0.4...v1.0.5
112
157
  [1.0.4]: https://github.com/5ulo/accessible-kit/compare/v1.0.3...v1.0.4
113
158
  [1.0.3]: https://github.com/5ulo/accessible-kit/compare/v1.0.2...v1.0.3
114
159
  [1.0.2]: https://github.com/5ulo/accessible-kit/compare/v1.0.1...v1.0.2
package/README.md CHANGED
@@ -168,11 +168,12 @@ const dropdown = new AccessibleDropdown(element, {
168
168
 
169
169
  ## Dropdown
170
170
 
171
- Fully accessible dropdown component with keyboard navigation and ARIA support.
171
+ Fully accessible dropdown component with keyboard navigation and ARIA support. Supports multiple ARIA patterns (menu, dialog, listbox) for different use cases.
172
172
 
173
173
  ### Features
174
174
 
175
- - ✅ Full ARIA attributes support
175
+ - ✅ Full ARIA attributes support (menu, dialog, and listbox patterns)
176
+ - ✅ Flexible role patterns via `data-dropdown-role` attribute
176
177
  - ✅ Keyboard navigation (arrows, Enter, Space, Esc, Home, End)
177
178
  - ✅ Focus management
178
179
  - ✅ Auto-close on outside click
@@ -213,6 +214,57 @@ Fully accessible dropdown component with keyboard navigation and ARIA support.
213
214
  </div>
214
215
  ```
215
216
 
217
+ ### ARIA Patterns
218
+
219
+ The dropdown component supports multiple ARIA patterns to match different use cases. By default, it uses the **menu pattern**, but you can change this using the `data-dropdown-role` attribute.
220
+
221
+ #### When to Use Each Pattern
222
+
223
+ **Menu Pattern (default)** - Use `role="menu"` for:
224
+ - Navigation menus
225
+ - Action menus (Edit, Delete, etc.)
226
+ - Language/region selectors
227
+ - User profile menus
228
+ - Context menus
229
+
230
+ **Dialog Pattern** - Use `data-dropdown-role="dialog"` for:
231
+ - Media player playlists/controls
232
+ - Complex interactive widgets
233
+ - Custom form controls
234
+ - Rich content with multiple focusable elements
235
+ - Non-menu disclosure patterns
236
+
237
+ **Example: Dialog Pattern (Radio Playlist)**
238
+
239
+ ```html
240
+ <!-- Use data-dropdown-role="dialog" for playlists, controls, or interactive widgets -->
241
+ <div data-dropdown data-dropdown-role="dialog">
242
+ <button data-dropdown-button>
243
+ 🎵 Playlist
244
+ <span data-dropdown-arrow></span>
245
+ </button>
246
+ <div data-dropdown-menu>
247
+ <div>
248
+ <div>
249
+ <button data-dropdown-item>Station 1</button>
250
+ <button data-dropdown-item>Station 2</button>
251
+ <button data-dropdown-item>Station 3</button>
252
+ </div>
253
+ </div>
254
+ </div>
255
+ </div>
256
+
257
+ <!-- This automatically sets:
258
+ - Button: aria-haspopup="dialog" (instead of "true")
259
+ - Menu: role="dialog" (instead of "menu")
260
+ - Items: No role attribute (instead of "menuitem")
261
+ - Keyboard: Tab allowed within dropdown (menu pattern closes on Tab)
262
+ -->
263
+ ```
264
+
265
+ **Why use dialog pattern?**
266
+ When your dropdown contains complex interactive content (like a media player playlist), it's not semantically a "menu" of commands. The dialog pattern better represents this content structure and provides more appropriate keyboard behavior. Tab/Shift+Tab cycles through items within the dropdown (instead of closing it like menu pattern does).
267
+
216
268
  ### JavaScript Initialization
217
269
 
218
270
  ```javascript
@@ -225,6 +277,7 @@ document.addEventListener('DOMContentLoaded', () => {
225
277
 
226
278
  // Or manual initialization with options
227
279
  const dropdown = new Dropdown(element, {
280
+ dropdownRole: 'menu', // 'menu' (default), 'dialog', or 'listbox'
228
281
  closeOnSelect: true,
229
282
  closeOnOutsideClick: true,
230
283
  closeOnEscape: true,
@@ -269,6 +322,9 @@ Dropdowns have **CSS Grid animations enabled by default**. The animation automat
269
322
  **Data attributes:**
270
323
 
271
324
  ```html
325
+ <!-- Use dialog pattern for non-menu content -->
326
+ <div data-dropdown data-dropdown-role="dialog">
327
+
272
328
  <!-- Keep dropdown open after selection -->
273
329
  <div data-dropdown data-close-on-select="false">
274
330
 
@@ -283,6 +339,7 @@ Dropdowns have **CSS Grid animations enabled by default**. The animation automat
283
339
 
284
340
  ```javascript
285
341
  {
342
+ dropdownRole: 'menu', // ARIA pattern: 'menu', 'dialog', or 'listbox'
286
343
  closeOnSelect: true, // Close after selecting item
287
344
  closeOnOutsideClick: true, // Close on outside click
288
345
  closeOnEscape: true, // Close on Escape key
@@ -295,12 +352,21 @@ Dropdowns have **CSS Grid animations enabled by default**. The animation automat
295
352
 
296
353
  ### Keyboard Navigation
297
354
 
355
+ **Menu Pattern (default):**
356
+
298
357
  - **Enter** / **Space** - Open/close menu or select item
299
358
  - **↓** / **↑** - Navigate between items
300
359
  - **Home** / **End** - Jump to first/last item
301
360
  - **Esc** - Close menu and return focus to button
302
361
  - **Tab** - Close menu and move focus
303
362
 
363
+ **Dialog Pattern:**
364
+
365
+ - **Enter** / **Space** - Open/close or select item
366
+ - **↓** / **↑** - Navigate between items
367
+ - **Esc** - Close and return focus to button
368
+ - **Tab** / **Shift+Tab** - Cycle through items within dialog (does not close)
369
+
304
370
  ### Variants
305
371
 
306
372
  **Navigation menu:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "accessible-kit",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Lightweight, accessible UI component library with full ARIA support. Zero dependencies, vanilla JavaScript.",
5
5
  "main": "src/js/index.js",
6
6
  "type": "module",
@@ -18,6 +18,7 @@ class AccessibleDropdown {
18
18
 
19
19
  // Options
20
20
  this.options = {
21
+ dropdownRole: options.dropdownRole || "menu",
21
22
  closeOnSelect: options.closeOnSelect !== false,
22
23
  closeOnOutsideClick: options.closeOnOutsideClick !== false,
23
24
  closeOnEscape: options.closeOnEscape !== false,
@@ -91,18 +92,24 @@ class AccessibleDropdown {
91
92
  .substr(2, 9)}`;
92
93
  }
93
94
 
94
- // Button ARIA attributes
95
- this.button.setAttribute("aria-haspopup", "true");
95
+ // Button ARIA attributes - set aria-haspopup based on dropdown role
96
+ const ariaHaspopupValue = this.options.dropdownRole === "menu" ? "true" : this.options.dropdownRole;
97
+ this.button.setAttribute("aria-haspopup", ariaHaspopupValue);
96
98
  this.button.setAttribute("aria-expanded", "false");
97
99
  this.button.setAttribute("aria-controls", this.menu.id);
98
100
 
99
- // Menu ARIA attributes
100
- this.menu.setAttribute("role", "menu");
101
+ // Menu ARIA attributes - set role based on dropdown role
102
+ this.menu.setAttribute("role", this.options.dropdownRole);
101
103
  this.menu.setAttribute("aria-labelledby", this.button.id);
102
104
 
103
- // Menu items ARIA attributes
105
+ // Menu items ARIA attributes - set item role based on dropdown role
106
+ const itemRole = this.options.dropdownRole === "menu" ? "menuitem" :
107
+ this.options.dropdownRole === "listbox" ? "option" : null;
108
+
104
109
  this.items.forEach((item) => {
105
- item.setAttribute("role", "menuitem");
110
+ if (itemRole) {
111
+ item.setAttribute("role", itemRole);
112
+ }
106
113
  if (!item.hasAttribute("tabindex")) {
107
114
  item.setAttribute("tabindex", "-1");
108
115
  }
@@ -184,32 +191,64 @@ class AccessibleDropdown {
184
191
  }
185
192
 
186
193
  handleItemKeydown(e, index) {
194
+ // Common navigation for all patterns
187
195
  switch (e.key) {
188
196
  case "Enter":
189
197
  case " ":
190
198
  e.preventDefault();
191
199
  this.selectItem(index);
192
200
  break;
193
- case "ArrowDown":
194
- e.preventDefault();
195
- this.focusNextItem();
196
- break;
197
- case "ArrowUp":
198
- e.preventDefault();
199
- this.focusPreviousItem();
200
- break;
201
- case "Home":
202
- e.preventDefault();
203
- this.setFocusedItem(0);
204
- break;
205
- case "End":
206
- e.preventDefault();
207
- this.setFocusedItem(this.items.length - 1);
208
- break;
209
201
  case "Tab":
210
- this.close();
202
+ // For dialog pattern, Tab should cycle within items
203
+ // For menu and listbox, close on Tab
204
+ if (this.options.dropdownRole === "dialog") {
205
+ e.preventDefault();
206
+ // Tab forward or backward through items
207
+ if (e.shiftKey) {
208
+ this.focusPreviousItem();
209
+ } else {
210
+ this.focusNextItem();
211
+ }
212
+ } else {
213
+ // Menu and listbox patterns close on Tab
214
+ this.close();
215
+ }
211
216
  break;
212
217
  }
218
+
219
+ // Pattern-specific navigation
220
+ if (this.options.dropdownRole === "menu" || this.options.dropdownRole === "listbox") {
221
+ switch (e.key) {
222
+ case "ArrowDown":
223
+ e.preventDefault();
224
+ this.focusNextItem();
225
+ break;
226
+ case "ArrowUp":
227
+ e.preventDefault();
228
+ this.focusPreviousItem();
229
+ break;
230
+ case "Home":
231
+ e.preventDefault();
232
+ this.setFocusedItem(0);
233
+ break;
234
+ case "End":
235
+ e.preventDefault();
236
+ this.setFocusedItem(this.items.length - 1);
237
+ break;
238
+ }
239
+ } else if (this.options.dropdownRole === "dialog") {
240
+ // Dialog pattern: Arrow keys for navigation (less strict)
241
+ switch (e.key) {
242
+ case "ArrowDown":
243
+ e.preventDefault();
244
+ this.focusNextItem();
245
+ break;
246
+ case "ArrowUp":
247
+ e.preventDefault();
248
+ this.focusPreviousItem();
249
+ break;
250
+ }
251
+ }
213
252
  }
214
253
 
215
254
  handleItemClick(e, index) {
@@ -320,6 +359,7 @@ function initDropdowns() {
320
359
 
321
360
  dropdowns.forEach((dropdown) => {
322
361
  const options = {
362
+ dropdownRole: dropdown.dataset.dropdownRole || "menu",
323
363
  closeOnSelect: dropdown.dataset.closeOnSelect !== "false",
324
364
  closeOnOutsideClick:
325
365
  dropdown.dataset.closeOnOutsideClick !== "false",
@@ -194,6 +194,10 @@ class AccessibleModal {
194
194
  el.getClientRects().length > 0
195
195
  );
196
196
 
197
+ if (!isVisible) {
198
+ return false;
199
+ }
200
+
197
201
  // Check if element or any parent has aria-hidden="true"
198
202
  let currentElement = el;
199
203
  while (currentElement && currentElement !== this.dialog) {
@@ -214,7 +218,7 @@ class AccessibleModal {
214
218
  return false;
215
219
  }
216
220
 
217
- return isVisible;
221
+ return true;
218
222
  });
219
223
 
220
224
  this.firstFocusable = this.focusableElements[0] || null;
@@ -225,24 +229,39 @@ class AccessibleModal {
225
229
  handleFocusTrap(e) {
226
230
  if (!this.isOpen || e.key !== "Tab") return;
227
231
 
232
+ // Update focusable elements before each Tab to catch dynamic changes (e.g., collapse panels)
233
+ this.updateFocusableElements();
234
+
228
235
  // If no focusable elements, prevent default
229
236
  if (this.focusableElements.length === 0) {
230
237
  e.preventDefault();
231
238
  return;
232
239
  }
233
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
+
234
247
  // Shift + Tab (backward)
235
248
  if (e.shiftKey) {
236
- if (document.activeElement === this.firstFocusable) {
237
- e.preventDefault();
249
+ if (currentIndex <= 0) {
250
+ // At first element or not in list - go to last
238
251
  this.lastFocusable.focus();
252
+ } else {
253
+ // Go to previous element
254
+ this.focusableElements[currentIndex - 1].focus();
239
255
  }
240
256
  }
241
257
  // Tab (forward)
242
258
  else {
243
- if (document.activeElement === this.lastFocusable) {
244
- e.preventDefault();
259
+ if (currentIndex === -1 || currentIndex >= this.focusableElements.length - 1) {
260
+ // Not in list or at last element - go to first
245
261
  this.firstFocusable.focus();
262
+ } else {
263
+ // Go to next element
264
+ this.focusableElements[currentIndex + 1].focus();
246
265
  }
247
266
  }
248
267
  }
@@ -237,24 +237,39 @@ class AccessibleOffcanvas {
237
237
  handleFocusTrap(e) {
238
238
  if (!this.isOpen || e.key !== "Tab") return;
239
239
 
240
+ // Update focusable elements before each Tab to catch dynamic changes (e.g., collapse panels)
241
+ this.updateFocusableElements();
242
+
240
243
  // If no focusable elements, prevent default
241
244
  if (this.focusableElements.length === 0) {
242
245
  e.preventDefault();
243
246
  return;
244
247
  }
245
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
+
246
255
  // Shift + Tab (backward)
247
256
  if (e.shiftKey) {
248
- if (document.activeElement === this.firstFocusable) {
249
- e.preventDefault();
257
+ if (currentIndex <= 0) {
258
+ // At first element or not in list - go to last
250
259
  this.lastFocusable.focus();
260
+ } else {
261
+ // Go to previous element
262
+ this.focusableElements[currentIndex - 1].focus();
251
263
  }
252
264
  }
253
265
  // Tab (forward)
254
266
  else {
255
- if (document.activeElement === this.lastFocusable) {
256
- e.preventDefault();
267
+ if (currentIndex === -1 || currentIndex >= this.focusableElements.length - 1) {
268
+ // Not in list or at last element - go to first
257
269
  this.firstFocusable.focus();
270
+ } else {
271
+ // Go to next element
272
+ this.focusableElements[currentIndex + 1].focus();
258
273
  }
259
274
  }
260
275
  }
package/src/js/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * a11y-kit
3
3
  * Lightweight, accessible UI component library with full ARIA support
4
4
  *
5
- * @version 1.0.4
5
+ * @version 1.0.5
6
6
  * @license MIT
7
7
  */
8
8