aria-ease 6.5.1 → 6.7.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.
Files changed (29) hide show
  1. package/README.md +88 -24
  2. package/bin/{chunk-AUJAN4RK.js → chunk-2TOYEY5L.js} +87 -40
  3. package/bin/chunk-VPBHLMAS.js +127 -0
  4. package/bin/cli.cjs +403 -237
  5. package/bin/cli.js +8 -123
  6. package/bin/configLoader-XRF6VM4J.js +7 -0
  7. package/{dist/contractTestRunnerPlaywright-7F756CFB.js → bin/contractTestRunnerPlaywright-UAOFNS7Z.js} +121 -60
  8. package/bin/{test-C3CMRHSI.js → test-WRIJHN6H.js} +65 -24
  9. package/dist/{chunk-AUJAN4RK.js → chunk-2TOYEY5L.js} +87 -40
  10. package/dist/configLoader-IT4PWCJB.js +128 -0
  11. package/{bin/contractTestRunnerPlaywright-7F756CFB.js → dist/contractTestRunnerPlaywright-UAOFNS7Z.js} +121 -60
  12. package/dist/index.cjs +471 -137
  13. package/dist/index.d.cts +6 -1
  14. package/dist/index.d.ts +6 -1
  15. package/dist/index.js +127 -35
  16. package/dist/src/menu/index.cjs +62 -11
  17. package/dist/src/menu/index.js +62 -11
  18. package/dist/src/utils/test/aria-contracts/accordion/accordion.contract.json +8 -8
  19. package/dist/src/utils/test/aria-contracts/combobox/combobox.listbox.contract.json +4 -4
  20. package/dist/src/utils/test/aria-contracts/menu/menu.contract.json +172 -34
  21. package/dist/src/utils/test/aria-contracts/tabs/tabs.contract.json +10 -10
  22. package/dist/src/utils/test/{chunk-AUJAN4RK.js → chunk-2TOYEY5L.js} +85 -41
  23. package/dist/src/utils/test/configLoader-LD4RV2WQ.js +126 -0
  24. package/dist/src/utils/test/{contractTestRunnerPlaywright-HL73FADJ.js → contractTestRunnerPlaywright-IRJOAEMT.js} +117 -59
  25. package/dist/src/utils/test/index.cjs +403 -125
  26. package/dist/src/utils/test/index.d.cts +7 -1
  27. package/dist/src/utils/test/index.d.ts +7 -1
  28. package/dist/src/utils/test/index.js +61 -23
  29. package/package.json +1 -1
package/dist/index.d.cts CHANGED
@@ -194,6 +194,8 @@ declare function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, lis
194
194
 
195
195
  declare function makeTabsAccessible({ tabListId, tabsClass, tabPanelsClass, orientation, activateOnFocus, callback }: TabsConfig): AccessibilityInstance;
196
196
 
197
+ type StrictnessMode = 'minimal' | 'balanced' | 'strict' | 'paranoid';
198
+
197
199
  /**
198
200
  * Runs static and interactions accessibility test on UI components.
199
201
  * @param {string} componentName The name of the component contract to test against
@@ -201,7 +203,10 @@ declare function makeTabsAccessible({ tabListId, tabsClass, tabPanelsClass, orie
201
203
  * @param {string} url Optional URL to run full Playwright E2E tests. If omitted, uses isolated component testing with page.setContent()
202
204
  */
203
205
 
204
- declare function testUiComponent(componentName: string, component: HTMLElement | null, url: string | null): Promise<JestAxeResult>;
206
+ type TestAuditOptions = {
207
+ strictness?: StrictnessMode;
208
+ };
209
+ declare function testUiComponent(componentName: string, component: HTMLElement | null, url: string | null, options?: TestAuditOptions): Promise<JestAxeResult>;
205
210
  /**
206
211
  * Cleanup function to close the shared Playwright browser
207
212
  * Call this in afterAll() or after all tests complete
package/dist/index.d.ts CHANGED
@@ -194,6 +194,8 @@ declare function makeComboboxAccessible({ comboboxInputId, comboboxButtonId, lis
194
194
 
195
195
  declare function makeTabsAccessible({ tabListId, tabsClass, tabPanelsClass, orientation, activateOnFocus, callback }: TabsConfig): AccessibilityInstance;
196
196
 
197
+ type StrictnessMode = 'minimal' | 'balanced' | 'strict' | 'paranoid';
198
+
197
199
  /**
198
200
  * Runs static and interactions accessibility test on UI components.
199
201
  * @param {string} componentName The name of the component contract to test against
@@ -201,7 +203,10 @@ declare function makeTabsAccessible({ tabListId, tabsClass, tabPanelsClass, orie
201
203
  * @param {string} url Optional URL to run full Playwright E2E tests. If omitted, uses isolated component testing with page.setContent()
202
204
  */
203
205
 
204
- declare function testUiComponent(componentName: string, component: HTMLElement | null, url: string | null): Promise<JestAxeResult>;
206
+ type TestAuditOptions = {
207
+ strictness?: StrictnessMode;
208
+ };
209
+ declare function testUiComponent(componentName: string, component: HTMLElement | null, url: string | null, options?: TestAuditOptions): Promise<JestAxeResult>;
205
210
  /**
206
211
  * Cleanup function to close the shared Playwright browser
207
212
  * Call this in afterAll() or after all tests complete
package/dist/index.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import {
2
2
  ContractReporter,
3
3
  closeSharedBrowser,
4
- contract_default
5
- } from "./chunk-AUJAN4RK.js";
4
+ contract_default,
5
+ normalizeLevel,
6
+ normalizeStrictness,
7
+ resolveEnforcement
8
+ } from "./chunk-2TOYEY5L.js";
6
9
  import "./chunk-I2KLQ2HA.js";
7
10
 
8
11
  // src/accordion/src/makeAccordionAccessible/makeAccordionAccessible.ts
@@ -464,11 +467,14 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
464
467
  for (let i = 0; i < allItems.length; i++) {
465
468
  const item = allItems.item(i);
466
469
  const isNested = isItemInNestedSubmenu(item);
470
+ const isDisabled = item.getAttribute("aria-disabled") === "true";
467
471
  if (!isNested) {
468
472
  if (!item.hasAttribute("tabindex")) {
469
473
  item.setAttribute("tabindex", "-1");
470
474
  }
471
- filteredItems.push(item);
475
+ if (!isDisabled) {
476
+ filteredItems.push(item);
477
+ }
472
478
  }
473
479
  }
474
480
  }
@@ -493,9 +499,14 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
493
499
  const items = getItems();
494
500
  items.forEach((item) => {
495
501
  item.setAttribute("role", "menuitem");
496
- if (item.hasAttribute("data-submenu-id")) {
502
+ const submenuId = item.getAttribute("data-submenu-id") ?? item.getAttribute("aria-controls");
503
+ const hasSubmenuTriggerAttributes = item.hasAttribute("aria-haspopup") && submenuId;
504
+ if (submenuId && (item.hasAttribute("data-submenu-id") || hasSubmenuTriggerAttributes)) {
497
505
  item.setAttribute("aria-haspopup", "menu");
498
- item.setAttribute("aria-controls", item.getAttribute("data-submenu-id"));
506
+ item.setAttribute("aria-controls", submenuId);
507
+ if (!item.hasAttribute("aria-expanded")) {
508
+ item.setAttribute("aria-expanded", "false");
509
+ }
499
510
  }
500
511
  });
501
512
  }
@@ -504,24 +515,43 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
504
515
  const nextIndex = (currentIndex + direction + len) % len;
505
516
  elementItems.item(nextIndex).focus();
506
517
  }
518
+ function focusItemAtIndex(items, index) {
519
+ if (items.length === 0) return;
520
+ items[index]?.focus();
521
+ }
507
522
  function hasSubmenu(menuItem) {
508
523
  return menuItem.hasAttribute("aria-controls") && menuItem.hasAttribute("aria-haspopup") && menuItem.getAttribute("role") === "menuitem";
509
524
  }
525
+ function closeAncestorMenusFromTrigger(triggerEl) {
526
+ let currentTrigger = triggerEl;
527
+ while (currentTrigger && currentTrigger.getAttribute("role") === "menuitem") {
528
+ const parentMenu = currentTrigger.closest('[role="menu"]');
529
+ if (!parentMenu) break;
530
+ parentMenu.style.display = "none";
531
+ currentTrigger.setAttribute("aria-expanded", "false");
532
+ const parentTriggerId = parentMenu.getAttribute("aria-labelledby");
533
+ if (!parentTriggerId) break;
534
+ const nextTrigger = document.getElementById(parentTriggerId);
535
+ if (!nextTrigger) break;
536
+ currentTrigger = nextTrigger;
537
+ }
538
+ }
510
539
  intializeMenuItems();
511
540
  function handleItemsKeydown(event, menuItem, menuItemIndex) {
512
541
  switch (event.key) {
513
- case "ArrowUp":
514
542
  case "ArrowLeft": {
515
543
  if (event.key === "ArrowLeft" && triggerButton.getAttribute("role") === "menuitem") {
516
544
  event.preventDefault();
517
545
  closeMenu();
518
546
  return;
519
547
  }
548
+ break;
549
+ }
550
+ case "ArrowUp": {
520
551
  event.preventDefault();
521
552
  moveFocus2(toNodeListLike(getFilteredItems()), menuItemIndex, -1);
522
553
  break;
523
554
  }
524
- case "ArrowDown":
525
555
  case "ArrowRight": {
526
556
  if (event.key === "ArrowRight" && hasSubmenu(menuItem)) {
527
557
  event.preventDefault();
@@ -531,10 +561,24 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
531
561
  return;
532
562
  }
533
563
  }
564
+ break;
565
+ }
566
+ case "ArrowDown": {
534
567
  event.preventDefault();
535
568
  moveFocus2(toNodeListLike(getFilteredItems()), menuItemIndex, 1);
536
569
  break;
537
570
  }
571
+ case "Home": {
572
+ event.preventDefault();
573
+ focusItemAtIndex(getFilteredItems(), 0);
574
+ break;
575
+ }
576
+ case "End": {
577
+ event.preventDefault();
578
+ const items = getFilteredItems();
579
+ focusItemAtIndex(items, items.length - 1);
580
+ break;
581
+ }
538
582
  case "Escape": {
539
583
  event.preventDefault();
540
584
  closeMenu();
@@ -547,15 +591,25 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
547
591
  case "Enter":
548
592
  case " ": {
549
593
  event.preventDefault();
594
+ if (hasSubmenu(menuItem)) {
595
+ const submenuId = menuItem.getAttribute("aria-controls");
596
+ if (submenuId) {
597
+ openSubmenu(submenuId);
598
+ return;
599
+ }
600
+ }
550
601
  menuItem.click();
602
+ closeMenu();
603
+ if (onOpenChange) {
604
+ onOpenChange(false);
605
+ }
551
606
  break;
552
607
  }
553
608
  case "Tab": {
554
- if (!event.shiftKey || event.shiftKey) {
555
- closeMenu();
556
- if (onOpenChange) {
557
- onOpenChange(false);
558
- }
609
+ closeMenu();
610
+ closeAncestorMenusFromTrigger(triggerButton);
611
+ if (onOpenChange) {
612
+ onOpenChange(false);
559
613
  }
560
614
  break;
561
615
  }
@@ -656,6 +710,7 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
656
710
  }
657
711
  }
658
712
  function closeMenu() {
713
+ submenuInstances.forEach((instance) => instance.closeMenu());
659
714
  setAria(false);
660
715
  menuDiv.style.display = "none";
661
716
  removeListeners();
@@ -687,7 +742,6 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
687
742
  }
688
743
  triggerButton.addEventListener("click", handleTriggerClick);
689
744
  document.addEventListener("click", handleClickOutside);
690
- triggerButton.setAttribute("data-menu-initialized", "true");
691
745
  function cleanup() {
692
746
  removeListeners();
693
747
  triggerButton.removeEventListener("click", handleTriggerClick);
@@ -1445,8 +1499,9 @@ import { axe } from "jest-axe";
1445
1499
 
1446
1500
  // src/utils/test/src/contractTestRunner.ts
1447
1501
  import fs from "fs/promises";
1448
- async function runContractTests(componentName, component) {
1502
+ async function runContractTests(componentName, component, strictness) {
1449
1503
  const reporter = new ContractReporter(false);
1504
+ const strictnessMode = normalizeStrictness(strictness);
1450
1505
  const contractTyped = contract_default;
1451
1506
  const contractPath = contractTyped[componentName]?.path;
1452
1507
  if (!contractPath) {
@@ -1460,19 +1515,42 @@ async function runContractTests(componentName, component) {
1460
1515
  const failures = [];
1461
1516
  const passes = [];
1462
1517
  const skipped = [];
1463
- const failuresBeforeStatic = failures.length;
1518
+ const warnings = [];
1519
+ const classifyFailure = (message, levelRaw) => {
1520
+ const level = normalizeLevel(levelRaw);
1521
+ const enforcement = resolveEnforcement(level, strictnessMode);
1522
+ if (enforcement === "error") {
1523
+ failures.push(message);
1524
+ return { status: "fail", level, detail: message };
1525
+ }
1526
+ if (enforcement === "warning") {
1527
+ warnings.push(message);
1528
+ return { status: "warn", level, detail: message };
1529
+ }
1530
+ const ignoredMessage = `${message} (ignored by strictness=${strictnessMode}, level=${level})`;
1531
+ skipped.push(ignoredMessage);
1532
+ return { status: "skip", level, detail: ignoredMessage };
1533
+ };
1534
+ let staticPassed = 0;
1535
+ let staticFailed = 0;
1536
+ let staticWarnings = 0;
1464
1537
  for (const test of componentContract.static[0].assertions) {
1465
1538
  if (test.target !== "relative") {
1539
+ const staticLevel = normalizeLevel(test.level);
1466
1540
  const selector = componentContract.selectors[test.target];
1467
1541
  if (!selector) {
1468
- failures.push(`Selector for target ${test.target} not found.`);
1469
- reporter.reportStaticTest(`${test.target} has required ARIA attributes`, false, `Selector for target ${test.target} not found.`);
1542
+ const outcome = classifyFailure(`Selector for target ${test.target} not found.`, test.level);
1543
+ if (outcome.status === "fail") staticFailed += 1;
1544
+ if (outcome.status === "warn") staticWarnings += 1;
1545
+ reporter.reportStaticTest(`${test.target} has required ARIA attributes`, outcome.status, outcome.detail, outcome.level);
1470
1546
  continue;
1471
1547
  }
1472
1548
  const target = component.querySelector(selector);
1473
1549
  if (!target) {
1474
- failures.push(`Target ${test.target} not found.`);
1475
- reporter.reportStaticTest(`${test.target} has required ARIA attributes`, false, `Target ${test.target} not found.`);
1550
+ const outcome = classifyFailure(`Target ${test.target} not found.`, test.level);
1551
+ if (outcome.status === "fail") staticFailed += 1;
1552
+ if (outcome.status === "warn") staticWarnings += 1;
1553
+ reporter.reportStaticTest(`${test.target} has required ARIA attributes`, outcome.status, outcome.detail, outcome.level);
1476
1554
  continue;
1477
1555
  }
1478
1556
  const attributeValue = target.getAttribute(test.attribute);
@@ -1480,35 +1558,38 @@ async function runContractTests(componentName, component) {
1480
1558
  const attributes = test.attribute.split(" | ");
1481
1559
  const hasAnyAttribute = attributes.some((attr) => target.hasAttribute(attr));
1482
1560
  if (!hasAnyAttribute) {
1483
- failures.push(test.failureMessage + ` None of the attributes "${test.attribute}" are present.`);
1484
- reporter.reportStaticTest(`${test.target} has ${test.attribute}`, false, test.failureMessage);
1561
+ const outcome = classifyFailure(test.failureMessage + ` None of the attributes "${test.attribute}" are present.`, test.level);
1562
+ if (outcome.status === "fail") staticFailed += 1;
1563
+ if (outcome.status === "warn") staticWarnings += 1;
1564
+ reporter.reportStaticTest(`${test.target} has ${test.attribute}`, outcome.status, outcome.detail, outcome.level);
1485
1565
  } else {
1486
1566
  passes.push(`At least one of the attributes "${test.attribute}" exists on the element.`);
1487
- reporter.reportStaticTest(`${test.target} has ${test.attribute}`, true);
1567
+ staticPassed += 1;
1568
+ reporter.reportStaticTest(`${test.target} has ${test.attribute}`, "pass", void 0, staticLevel);
1488
1569
  }
1489
1570
  } else if (!attributeValue || !test.expectedValue.split(" | ").includes(attributeValue)) {
1490
- failures.push(test.failureMessage + ` Attribute value does not match expected value. Expected: ${test.expectedValue}, Found: ${attributeValue}`);
1491
- reporter.reportStaticTest(`${test.target} has ${test.attribute}="${test.expectedValue}"`, false, test.failureMessage);
1571
+ const outcome = classifyFailure(test.failureMessage + ` Attribute value does not match expected value. Expected: ${test.expectedValue}, Found: ${attributeValue}`, test.level);
1572
+ if (outcome.status === "fail") staticFailed += 1;
1573
+ if (outcome.status === "warn") staticWarnings += 1;
1574
+ reporter.reportStaticTest(`${test.target} has ${test.attribute}="${test.expectedValue}"`, outcome.status, outcome.detail, outcome.level);
1492
1575
  } else {
1493
1576
  passes.push(`Attribute value matches expected value. Expected: ${test.expectedValue}, Found: ${attributeValue}`);
1494
- reporter.reportStaticTest(`${test.target} has ${test.attribute}="${attributeValue}"`, true);
1577
+ staticPassed += 1;
1578
+ reporter.reportStaticTest(`${test.target} has ${test.attribute}="${attributeValue}"`, "pass", void 0, staticLevel);
1495
1579
  }
1496
1580
  }
1497
1581
  }
1498
1582
  for (const dynamicTest of componentContract.dynamic) {
1499
1583
  skipped.push(dynamicTest.description);
1500
- reporter.reportTest(dynamicTest, "skip");
1584
+ reporter.reportTest({ description: dynamicTest.description, level: dynamicTest.level }, "skip");
1501
1585
  }
1502
- const staticTotal = componentContract.static[0].assertions.length;
1503
- const staticFailed = failures.length - failuresBeforeStatic;
1504
- const staticPassed = Math.max(0, staticTotal - staticFailed);
1505
- reporter.reportStatic(staticPassed, staticFailed);
1586
+ reporter.reportStatic(staticPassed, staticFailed, staticWarnings);
1506
1587
  reporter.summary(failures);
1507
- return { passes, failures, skipped };
1588
+ return { passes, failures, skipped, warnings };
1508
1589
  }
1509
1590
 
1510
1591
  // src/utils/test/src/test.ts
1511
- async function testUiComponent(componentName, component, url) {
1592
+ async function testUiComponent(componentName, component, url, options = {}) {
1512
1593
  if (!componentName || typeof componentName !== "string") {
1513
1594
  throw new Error("\u274C testUiComponent requires a valid componentName (string)");
1514
1595
  }
@@ -1545,14 +1626,25 @@ Error: ${error instanceof Error ? error.message : String(error)}`
1545
1626
  }
1546
1627
  return null;
1547
1628
  }
1629
+ let strictness = normalizeStrictness(options.strictness);
1630
+ if (options.strictness === void 0 && typeof window === "undefined") {
1631
+ try {
1632
+ const { loadConfig } = await import("./configLoader-IT4PWCJB.js");
1633
+ const { config } = await loadConfig(process.cwd());
1634
+ const componentStrictness = config.test?.components?.find((comp) => comp?.name === componentName)?.strictness;
1635
+ strictness = normalizeStrictness(componentStrictness ?? config.test?.strictness);
1636
+ } catch {
1637
+ strictness = "balanced";
1638
+ }
1639
+ }
1548
1640
  let contract;
1549
1641
  try {
1550
1642
  if (url) {
1551
1643
  const devServerUrl = await checkDevServer(url);
1552
1644
  if (devServerUrl) {
1553
1645
  console.log(`\u{1F3AD} Running Playwright tests on ${devServerUrl}`);
1554
- const { runContractTestsPlaywright } = await import("./contractTestRunnerPlaywright-7F756CFB.js");
1555
- contract = await runContractTestsPlaywright(componentName, devServerUrl);
1646
+ const { runContractTestsPlaywright } = await import("./contractTestRunnerPlaywright-UAOFNS7Z.js");
1647
+ contract = await runContractTestsPlaywright(componentName, devServerUrl, strictness);
1556
1648
  } else {
1557
1649
  throw new Error(
1558
1650
  `\u274C Dev server not running at ${url}
@@ -1561,7 +1653,7 @@ Please start your dev server and try again.`
1561
1653
  }
1562
1654
  } else if (component) {
1563
1655
  console.log(`\u{1F3AD} Running component contract tests in JSDOM mode`);
1564
- contract = await runContractTests(componentName, component);
1656
+ contract = await runContractTests(componentName, component, strictness);
1565
1657
  } else {
1566
1658
  throw new Error("\u274C Either component or URL must be provided");
1567
1659
  }
@@ -47,11 +47,14 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
47
47
  for (let i = 0; i < allItems.length; i++) {
48
48
  const item = allItems.item(i);
49
49
  const isNested = isItemInNestedSubmenu(item);
50
+ const isDisabled = item.getAttribute("aria-disabled") === "true";
50
51
  if (!isNested) {
51
52
  if (!item.hasAttribute("tabindex")) {
52
53
  item.setAttribute("tabindex", "-1");
53
54
  }
54
- filteredItems.push(item);
55
+ if (!isDisabled) {
56
+ filteredItems.push(item);
57
+ }
55
58
  }
56
59
  }
57
60
  }
@@ -76,9 +79,14 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
76
79
  const items = getItems();
77
80
  items.forEach((item) => {
78
81
  item.setAttribute("role", "menuitem");
79
- if (item.hasAttribute("data-submenu-id")) {
82
+ const submenuId = item.getAttribute("data-submenu-id") ?? item.getAttribute("aria-controls");
83
+ const hasSubmenuTriggerAttributes = item.hasAttribute("aria-haspopup") && submenuId;
84
+ if (submenuId && (item.hasAttribute("data-submenu-id") || hasSubmenuTriggerAttributes)) {
80
85
  item.setAttribute("aria-haspopup", "menu");
81
- item.setAttribute("aria-controls", item.getAttribute("data-submenu-id"));
86
+ item.setAttribute("aria-controls", submenuId);
87
+ if (!item.hasAttribute("aria-expanded")) {
88
+ item.setAttribute("aria-expanded", "false");
89
+ }
82
90
  }
83
91
  });
84
92
  }
@@ -87,24 +95,43 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
87
95
  const nextIndex = (currentIndex + direction + len) % len;
88
96
  elementItems.item(nextIndex).focus();
89
97
  }
98
+ function focusItemAtIndex(items, index) {
99
+ if (items.length === 0) return;
100
+ items[index]?.focus();
101
+ }
90
102
  function hasSubmenu(menuItem) {
91
103
  return menuItem.hasAttribute("aria-controls") && menuItem.hasAttribute("aria-haspopup") && menuItem.getAttribute("role") === "menuitem";
92
104
  }
105
+ function closeAncestorMenusFromTrigger(triggerEl) {
106
+ let currentTrigger = triggerEl;
107
+ while (currentTrigger && currentTrigger.getAttribute("role") === "menuitem") {
108
+ const parentMenu = currentTrigger.closest('[role="menu"]');
109
+ if (!parentMenu) break;
110
+ parentMenu.style.display = "none";
111
+ currentTrigger.setAttribute("aria-expanded", "false");
112
+ const parentTriggerId = parentMenu.getAttribute("aria-labelledby");
113
+ if (!parentTriggerId) break;
114
+ const nextTrigger = document.getElementById(parentTriggerId);
115
+ if (!nextTrigger) break;
116
+ currentTrigger = nextTrigger;
117
+ }
118
+ }
93
119
  intializeMenuItems();
94
120
  function handleItemsKeydown(event, menuItem, menuItemIndex) {
95
121
  switch (event.key) {
96
- case "ArrowUp":
97
122
  case "ArrowLeft": {
98
123
  if (event.key === "ArrowLeft" && triggerButton.getAttribute("role") === "menuitem") {
99
124
  event.preventDefault();
100
125
  closeMenu();
101
126
  return;
102
127
  }
128
+ break;
129
+ }
130
+ case "ArrowUp": {
103
131
  event.preventDefault();
104
132
  moveFocus(toNodeListLike(getFilteredItems()), menuItemIndex, -1);
105
133
  break;
106
134
  }
107
- case "ArrowDown":
108
135
  case "ArrowRight": {
109
136
  if (event.key === "ArrowRight" && hasSubmenu(menuItem)) {
110
137
  event.preventDefault();
@@ -114,10 +141,24 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
114
141
  return;
115
142
  }
116
143
  }
144
+ break;
145
+ }
146
+ case "ArrowDown": {
117
147
  event.preventDefault();
118
148
  moveFocus(toNodeListLike(getFilteredItems()), menuItemIndex, 1);
119
149
  break;
120
150
  }
151
+ case "Home": {
152
+ event.preventDefault();
153
+ focusItemAtIndex(getFilteredItems(), 0);
154
+ break;
155
+ }
156
+ case "End": {
157
+ event.preventDefault();
158
+ const items = getFilteredItems();
159
+ focusItemAtIndex(items, items.length - 1);
160
+ break;
161
+ }
121
162
  case "Escape": {
122
163
  event.preventDefault();
123
164
  closeMenu();
@@ -130,15 +171,25 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
130
171
  case "Enter":
131
172
  case " ": {
132
173
  event.preventDefault();
174
+ if (hasSubmenu(menuItem)) {
175
+ const submenuId = menuItem.getAttribute("aria-controls");
176
+ if (submenuId) {
177
+ openSubmenu(submenuId);
178
+ return;
179
+ }
180
+ }
133
181
  menuItem.click();
182
+ closeMenu();
183
+ if (onOpenChange) {
184
+ onOpenChange(false);
185
+ }
134
186
  break;
135
187
  }
136
188
  case "Tab": {
137
- if (!event.shiftKey || event.shiftKey) {
138
- closeMenu();
139
- if (onOpenChange) {
140
- onOpenChange(false);
141
- }
189
+ closeMenu();
190
+ closeAncestorMenusFromTrigger(triggerButton);
191
+ if (onOpenChange) {
192
+ onOpenChange(false);
142
193
  }
143
194
  break;
144
195
  }
@@ -237,6 +288,7 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
237
288
  }
238
289
  }
239
290
  function closeMenu() {
291
+ submenuInstances.forEach((instance) => instance.closeMenu());
240
292
  setAria(false);
241
293
  menuDiv.style.display = "none";
242
294
  removeListeners();
@@ -268,7 +320,6 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
268
320
  }
269
321
  triggerButton.addEventListener("click", handleTriggerClick);
270
322
  document.addEventListener("click", handleClickOutside);
271
- triggerButton.setAttribute("data-menu-initialized", "true");
272
323
  function cleanup() {
273
324
  removeListeners();
274
325
  triggerButton.removeEventListener("click", handleTriggerClick);
@@ -45,11 +45,14 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
45
45
  for (let i = 0; i < allItems.length; i++) {
46
46
  const item = allItems.item(i);
47
47
  const isNested = isItemInNestedSubmenu(item);
48
+ const isDisabled = item.getAttribute("aria-disabled") === "true";
48
49
  if (!isNested) {
49
50
  if (!item.hasAttribute("tabindex")) {
50
51
  item.setAttribute("tabindex", "-1");
51
52
  }
52
- filteredItems.push(item);
53
+ if (!isDisabled) {
54
+ filteredItems.push(item);
55
+ }
53
56
  }
54
57
  }
55
58
  }
@@ -74,9 +77,14 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
74
77
  const items = getItems();
75
78
  items.forEach((item) => {
76
79
  item.setAttribute("role", "menuitem");
77
- if (item.hasAttribute("data-submenu-id")) {
80
+ const submenuId = item.getAttribute("data-submenu-id") ?? item.getAttribute("aria-controls");
81
+ const hasSubmenuTriggerAttributes = item.hasAttribute("aria-haspopup") && submenuId;
82
+ if (submenuId && (item.hasAttribute("data-submenu-id") || hasSubmenuTriggerAttributes)) {
78
83
  item.setAttribute("aria-haspopup", "menu");
79
- item.setAttribute("aria-controls", item.getAttribute("data-submenu-id"));
84
+ item.setAttribute("aria-controls", submenuId);
85
+ if (!item.hasAttribute("aria-expanded")) {
86
+ item.setAttribute("aria-expanded", "false");
87
+ }
80
88
  }
81
89
  });
82
90
  }
@@ -85,24 +93,43 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
85
93
  const nextIndex = (currentIndex + direction + len) % len;
86
94
  elementItems.item(nextIndex).focus();
87
95
  }
96
+ function focusItemAtIndex(items, index) {
97
+ if (items.length === 0) return;
98
+ items[index]?.focus();
99
+ }
88
100
  function hasSubmenu(menuItem) {
89
101
  return menuItem.hasAttribute("aria-controls") && menuItem.hasAttribute("aria-haspopup") && menuItem.getAttribute("role") === "menuitem";
90
102
  }
103
+ function closeAncestorMenusFromTrigger(triggerEl) {
104
+ let currentTrigger = triggerEl;
105
+ while (currentTrigger && currentTrigger.getAttribute("role") === "menuitem") {
106
+ const parentMenu = currentTrigger.closest('[role="menu"]');
107
+ if (!parentMenu) break;
108
+ parentMenu.style.display = "none";
109
+ currentTrigger.setAttribute("aria-expanded", "false");
110
+ const parentTriggerId = parentMenu.getAttribute("aria-labelledby");
111
+ if (!parentTriggerId) break;
112
+ const nextTrigger = document.getElementById(parentTriggerId);
113
+ if (!nextTrigger) break;
114
+ currentTrigger = nextTrigger;
115
+ }
116
+ }
91
117
  intializeMenuItems();
92
118
  function handleItemsKeydown(event, menuItem, menuItemIndex) {
93
119
  switch (event.key) {
94
- case "ArrowUp":
95
120
  case "ArrowLeft": {
96
121
  if (event.key === "ArrowLeft" && triggerButton.getAttribute("role") === "menuitem") {
97
122
  event.preventDefault();
98
123
  closeMenu();
99
124
  return;
100
125
  }
126
+ break;
127
+ }
128
+ case "ArrowUp": {
101
129
  event.preventDefault();
102
130
  moveFocus(toNodeListLike(getFilteredItems()), menuItemIndex, -1);
103
131
  break;
104
132
  }
105
- case "ArrowDown":
106
133
  case "ArrowRight": {
107
134
  if (event.key === "ArrowRight" && hasSubmenu(menuItem)) {
108
135
  event.preventDefault();
@@ -112,10 +139,24 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
112
139
  return;
113
140
  }
114
141
  }
142
+ break;
143
+ }
144
+ case "ArrowDown": {
115
145
  event.preventDefault();
116
146
  moveFocus(toNodeListLike(getFilteredItems()), menuItemIndex, 1);
117
147
  break;
118
148
  }
149
+ case "Home": {
150
+ event.preventDefault();
151
+ focusItemAtIndex(getFilteredItems(), 0);
152
+ break;
153
+ }
154
+ case "End": {
155
+ event.preventDefault();
156
+ const items = getFilteredItems();
157
+ focusItemAtIndex(items, items.length - 1);
158
+ break;
159
+ }
119
160
  case "Escape": {
120
161
  event.preventDefault();
121
162
  closeMenu();
@@ -128,15 +169,25 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
128
169
  case "Enter":
129
170
  case " ": {
130
171
  event.preventDefault();
172
+ if (hasSubmenu(menuItem)) {
173
+ const submenuId = menuItem.getAttribute("aria-controls");
174
+ if (submenuId) {
175
+ openSubmenu(submenuId);
176
+ return;
177
+ }
178
+ }
131
179
  menuItem.click();
180
+ closeMenu();
181
+ if (onOpenChange) {
182
+ onOpenChange(false);
183
+ }
132
184
  break;
133
185
  }
134
186
  case "Tab": {
135
- if (!event.shiftKey || event.shiftKey) {
136
- closeMenu();
137
- if (onOpenChange) {
138
- onOpenChange(false);
139
- }
187
+ closeMenu();
188
+ closeAncestorMenusFromTrigger(triggerButton);
189
+ if (onOpenChange) {
190
+ onOpenChange(false);
140
191
  }
141
192
  break;
142
193
  }
@@ -235,6 +286,7 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
235
286
  }
236
287
  }
237
288
  function closeMenu() {
289
+ submenuInstances.forEach((instance) => instance.closeMenu());
238
290
  setAria(false);
239
291
  menuDiv.style.display = "none";
240
292
  removeListeners();
@@ -266,7 +318,6 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
266
318
  }
267
319
  triggerButton.addEventListener("click", handleTriggerClick);
268
320
  document.addEventListener("click", handleClickOutside);
269
- triggerButton.setAttribute("data-menu-initialized", "true");
270
321
  function cleanup() {
271
322
  removeListeners();
272
323
  triggerButton.removeEventListener("click", handleTriggerClick);