aria-ease 6.6.0 → 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 +75 -15
  2. package/bin/{chunk-LKN5PRYD.js → chunk-2TOYEY5L.js} +87 -35
  3. package/bin/chunk-VPBHLMAS.js +127 -0
  4. package/bin/cli.cjs +380 -231
  5. package/bin/cli.js +8 -123
  6. package/bin/configLoader-XRF6VM4J.js +7 -0
  7. package/{dist/contractTestRunnerPlaywright-PC6JOYYV.js → bin/contractTestRunnerPlaywright-UAOFNS7Z.js} +98 -59
  8. package/bin/{test-LP723IXM.js → test-WRIJHN6H.js} +65 -24
  9. package/dist/{chunk-LKN5PRYD.js → chunk-2TOYEY5L.js} +87 -35
  10. package/dist/configLoader-IT4PWCJB.js +128 -0
  11. package/{bin/contractTestRunnerPlaywright-PC6JOYYV.js → dist/contractTestRunnerPlaywright-UAOFNS7Z.js} +98 -59
  12. package/dist/index.cjs +404 -125
  13. package/dist/index.d.cts +6 -1
  14. package/dist/index.d.ts +6 -1
  15. package/dist/index.js +83 -29
  16. package/dist/src/menu/index.cjs +18 -5
  17. package/dist/src/menu/index.js +18 -5
  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 +44 -19
  21. package/dist/src/utils/test/aria-contracts/tabs/tabs.contract.json +3 -3
  22. package/dist/src/utils/test/{chunk-LKN5PRYD.js → chunk-2TOYEY5L.js} +85 -36
  23. package/dist/src/utils/test/configLoader-LD4RV2WQ.js +126 -0
  24. package/dist/src/utils/test/{contractTestRunnerPlaywright-RGKMGXND.js → contractTestRunnerPlaywright-IRJOAEMT.js} +94 -58
  25. package/dist/src/utils/test/index.cjs +380 -119
  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-LKN5PRYD.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
@@ -519,6 +522,20 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
519
522
  function hasSubmenu(menuItem) {
520
523
  return menuItem.hasAttribute("aria-controls") && menuItem.hasAttribute("aria-haspopup") && menuItem.getAttribute("role") === "menuitem";
521
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
+ }
522
539
  intializeMenuItems();
523
540
  function handleItemsKeydown(event, menuItem, menuItemIndex) {
524
541
  switch (event.key) {
@@ -589,11 +606,10 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
589
606
  break;
590
607
  }
591
608
  case "Tab": {
592
- if (!event.shiftKey || event.shiftKey) {
593
- closeMenu();
594
- if (onOpenChange) {
595
- onOpenChange(false);
596
- }
609
+ closeMenu();
610
+ closeAncestorMenusFromTrigger(triggerButton);
611
+ if (onOpenChange) {
612
+ onOpenChange(false);
597
613
  }
598
614
  break;
599
615
  }
@@ -1483,8 +1499,9 @@ import { axe } from "jest-axe";
1483
1499
 
1484
1500
  // src/utils/test/src/contractTestRunner.ts
1485
1501
  import fs from "fs/promises";
1486
- async function runContractTests(componentName, component) {
1502
+ async function runContractTests(componentName, component, strictness) {
1487
1503
  const reporter = new ContractReporter(false);
1504
+ const strictnessMode = normalizeStrictness(strictness);
1488
1505
  const contractTyped = contract_default;
1489
1506
  const contractPath = contractTyped[componentName]?.path;
1490
1507
  if (!contractPath) {
@@ -1498,19 +1515,42 @@ async function runContractTests(componentName, component) {
1498
1515
  const failures = [];
1499
1516
  const passes = [];
1500
1517
  const skipped = [];
1501
- 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;
1502
1537
  for (const test of componentContract.static[0].assertions) {
1503
1538
  if (test.target !== "relative") {
1539
+ const staticLevel = normalizeLevel(test.level);
1504
1540
  const selector = componentContract.selectors[test.target];
1505
1541
  if (!selector) {
1506
- failures.push(`Selector for target ${test.target} not found.`);
1507
- 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);
1508
1546
  continue;
1509
1547
  }
1510
1548
  const target = component.querySelector(selector);
1511
1549
  if (!target) {
1512
- failures.push(`Target ${test.target} not found.`);
1513
- 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);
1514
1554
  continue;
1515
1555
  }
1516
1556
  const attributeValue = target.getAttribute(test.attribute);
@@ -1518,35 +1558,38 @@ async function runContractTests(componentName, component) {
1518
1558
  const attributes = test.attribute.split(" | ");
1519
1559
  const hasAnyAttribute = attributes.some((attr) => target.hasAttribute(attr));
1520
1560
  if (!hasAnyAttribute) {
1521
- failures.push(test.failureMessage + ` None of the attributes "${test.attribute}" are present.`);
1522
- 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);
1523
1565
  } else {
1524
1566
  passes.push(`At least one of the attributes "${test.attribute}" exists on the element.`);
1525
- reporter.reportStaticTest(`${test.target} has ${test.attribute}`, true);
1567
+ staticPassed += 1;
1568
+ reporter.reportStaticTest(`${test.target} has ${test.attribute}`, "pass", void 0, staticLevel);
1526
1569
  }
1527
1570
  } else if (!attributeValue || !test.expectedValue.split(" | ").includes(attributeValue)) {
1528
- failures.push(test.failureMessage + ` Attribute value does not match expected value. Expected: ${test.expectedValue}, Found: ${attributeValue}`);
1529
- 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);
1530
1575
  } else {
1531
1576
  passes.push(`Attribute value matches expected value. Expected: ${test.expectedValue}, Found: ${attributeValue}`);
1532
- 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);
1533
1579
  }
1534
1580
  }
1535
1581
  }
1536
1582
  for (const dynamicTest of componentContract.dynamic) {
1537
1583
  skipped.push(dynamicTest.description);
1538
- reporter.reportTest(dynamicTest, "skip");
1584
+ reporter.reportTest({ description: dynamicTest.description, level: dynamicTest.level }, "skip");
1539
1585
  }
1540
- const staticTotal = componentContract.static[0].assertions.length;
1541
- const staticFailed = failures.length - failuresBeforeStatic;
1542
- const staticPassed = Math.max(0, staticTotal - staticFailed);
1543
- reporter.reportStatic(staticPassed, staticFailed);
1586
+ reporter.reportStatic(staticPassed, staticFailed, staticWarnings);
1544
1587
  reporter.summary(failures);
1545
- return { passes, failures, skipped };
1588
+ return { passes, failures, skipped, warnings };
1546
1589
  }
1547
1590
 
1548
1591
  // src/utils/test/src/test.ts
1549
- async function testUiComponent(componentName, component, url) {
1592
+ async function testUiComponent(componentName, component, url, options = {}) {
1550
1593
  if (!componentName || typeof componentName !== "string") {
1551
1594
  throw new Error("\u274C testUiComponent requires a valid componentName (string)");
1552
1595
  }
@@ -1583,14 +1626,25 @@ Error: ${error instanceof Error ? error.message : String(error)}`
1583
1626
  }
1584
1627
  return null;
1585
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
+ }
1586
1640
  let contract;
1587
1641
  try {
1588
1642
  if (url) {
1589
1643
  const devServerUrl = await checkDevServer(url);
1590
1644
  if (devServerUrl) {
1591
1645
  console.log(`\u{1F3AD} Running Playwright tests on ${devServerUrl}`);
1592
- const { runContractTestsPlaywright } = await import("./contractTestRunnerPlaywright-PC6JOYYV.js");
1593
- contract = await runContractTestsPlaywright(componentName, devServerUrl);
1646
+ const { runContractTestsPlaywright } = await import("./contractTestRunnerPlaywright-UAOFNS7Z.js");
1647
+ contract = await runContractTestsPlaywright(componentName, devServerUrl, strictness);
1594
1648
  } else {
1595
1649
  throw new Error(
1596
1650
  `\u274C Dev server not running at ${url}
@@ -1599,7 +1653,7 @@ Please start your dev server and try again.`
1599
1653
  }
1600
1654
  } else if (component) {
1601
1655
  console.log(`\u{1F3AD} Running component contract tests in JSDOM mode`);
1602
- contract = await runContractTests(componentName, component);
1656
+ contract = await runContractTests(componentName, component, strictness);
1603
1657
  } else {
1604
1658
  throw new Error("\u274C Either component or URL must be provided");
1605
1659
  }
@@ -102,6 +102,20 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
102
102
  function hasSubmenu(menuItem) {
103
103
  return menuItem.hasAttribute("aria-controls") && menuItem.hasAttribute("aria-haspopup") && menuItem.getAttribute("role") === "menuitem";
104
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
+ }
105
119
  intializeMenuItems();
106
120
  function handleItemsKeydown(event, menuItem, menuItemIndex) {
107
121
  switch (event.key) {
@@ -172,11 +186,10 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
172
186
  break;
173
187
  }
174
188
  case "Tab": {
175
- if (!event.shiftKey || event.shiftKey) {
176
- closeMenu();
177
- if (onOpenChange) {
178
- onOpenChange(false);
179
- }
189
+ closeMenu();
190
+ closeAncestorMenusFromTrigger(triggerButton);
191
+ if (onOpenChange) {
192
+ onOpenChange(false);
180
193
  }
181
194
  break;
182
195
  }
@@ -100,6 +100,20 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
100
100
  function hasSubmenu(menuItem) {
101
101
  return menuItem.hasAttribute("aria-controls") && menuItem.hasAttribute("aria-haspopup") && menuItem.getAttribute("role") === "menuitem";
102
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
+ }
103
117
  intializeMenuItems();
104
118
  function handleItemsKeydown(event, menuItem, menuItemIndex) {
105
119
  switch (event.key) {
@@ -170,11 +184,10 @@ function makeMenuAccessible({ menuId, menuItemsClass, triggerId, callback }) {
170
184
  break;
171
185
  }
172
186
  case "Tab": {
173
- if (!event.shiftKey || event.shiftKey) {
174
- closeMenu();
175
- if (onOpenChange) {
176
- onOpenChange(false);
177
- }
187
+ closeMenu();
188
+ closeAncestorMenusFromTrigger(triggerButton);
189
+ if (onOpenChange) {
190
+ onOpenChange(false);
178
191
  }
179
192
  break;
180
193
  }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "meta": {
3
- "id": "aria-ease.accordion",
3
+ "id": "aria-ease.contract.accordion",
4
4
  "version": "1.0.0",
5
5
  "created": "09-02-2026",
6
- "lastUpdated": "15-03-2026",
6
+ "lastUpdated": "19-03-2026",
7
7
  "description": "ARIA Accordion interaction contract. Validates the ARIA and interaction contract for a custom accordion component following the ARIA Authoring Practices Guide accordion with show/hide pattern.",
8
8
  "source": {
9
9
  "apg": "https://www.w3.org/WAI/ARIA/apg/patterns/accordion/",
@@ -199,7 +199,7 @@
199
199
  {
200
200
  "description": "Down Arrow moves focus to next accordion trigger.",
201
201
  "isMultiple": true,
202
- "isOptional": true,
202
+ "level": "optional",
203
203
  "action": [
204
204
  { "type": "keypress", "target": "focusable", "key": "ArrowDown" }
205
205
  ],
@@ -215,7 +215,7 @@
215
215
  {
216
216
  "description": "Up Arrow moves focus to previous accordion trigger.",
217
217
  "isMultiple": true,
218
- "isOptional": true,
218
+ "level": "optional",
219
219
  "action": [
220
220
  { "type": "keypress", "target": "focusable", "key": "ArrowUp" }
221
221
  ],
@@ -231,7 +231,7 @@
231
231
  {
232
232
  "description": "Home moves focus to first accordion trigger.",
233
233
  "isMultiple": true,
234
- "isOptional": true,
234
+ "level": "optional",
235
235
  "action": [
236
236
  { "type": "keypress", "target": "focusable", "key": "Home" }
237
237
  ],
@@ -247,7 +247,7 @@
247
247
  {
248
248
  "description": "End moves focus to last accordion trigger.",
249
249
  "isMultiple": true,
250
- "isOptional": true,
250
+ "level": "optional",
251
251
  "action": [
252
252
  { "type": "keypress", "target": "focusable", "key": "End" }
253
253
  ],
@@ -263,7 +263,7 @@
263
263
  {
264
264
  "description": "Down Arrow wraps focus from last trigger to first trigger.",
265
265
  "isMultiple": true,
266
- "isOptional": true,
266
+ "level": "optional",
267
267
  "action": [
268
268
  { "type": "keypress", "target": "focusable", "key": "ArrowDown" }
269
269
  ],
@@ -279,7 +279,7 @@
279
279
  {
280
280
  "description": "Up Arrow wraps focus from first trigger to last trigger.",
281
281
  "isMultiple": true,
282
- "isOptional": true,
282
+ "level": "optional",
283
283
  "action": [
284
284
  { "type": "keypress", "target": "focusable", "key": "ArrowUp" }
285
285
  ],
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "meta": {
3
- "id": "aria-ease.combobox.listbox",
3
+ "id": "aria-ease.contract.combobox.listbox",
4
4
  "version": "1.0.0",
5
5
  "created": "11-02-2026",
6
- "lastUpdated": "15-03-2026",
6
+ "lastUpdated": "19-03-2026",
7
7
  "description": "ARIA Combobox with Listbox popup interaction contract. Validates the ARIA and interaction contract for a custom combobox with listbox component following the ARIA Authoring Practices Guide combobox with listbox popup pattern.",
8
8
  "source": {
9
9
  "apg": "https://www.w3.org/WAI/ARIA/apg/patterns/combobox/",
@@ -197,7 +197,7 @@
197
197
  },
198
198
  {
199
199
  "description": "Home navigates to first listbox option.",
200
- "isOptional": true,
200
+ "level": "optional",
201
201
  "action": [
202
202
  { "type": "keypress", "target": "input", "key": "ArrowDown" },
203
203
  { "type": "keypress", "target": "input", "key": "End" },
@@ -216,7 +216,7 @@
216
216
  },
217
217
  {
218
218
  "description": "End navigates to last listbox option.",
219
- "isOptional": true,
219
+ "level": "optional",
220
220
  "action": [
221
221
  { "type": "keypress", "target": "input", "key": "ArrowDown" },
222
222
  { "type": "keypress", "target": "input", "key": "End" }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "meta": {
3
- "id": "aria-ease.menu",
3
+ "id": "aria-ease.contract.menu",
4
4
  "version": "1.0.0",
5
5
  "created": "11-02-2026",
6
- "lastUpdated": "15-03-2026",
6
+ "lastUpdated": "19-03-2026",
7
7
  "description": "ARIA Menu interaction contract. Validates the ARIA and interaction contract for a custom menu component following the ARIA Authoring Practices Guide menu with popup pattern",
8
8
  "source": {
9
9
  "apg": "https://www.w3.org/WAI/ARIA/apg/patterns/menubar/",
@@ -44,7 +44,7 @@
44
44
  "from": "container",
45
45
  "attribute": "aria-labelledby",
46
46
  "to": "trigger",
47
- "isOptional": true,
47
+ "level": "optional",
48
48
  "failureMessage": "The APG 1.2 recommends a menu container to have aria-labelledby attribute that references the trigger that controls the menu."
49
49
  },
50
50
  {
@@ -390,7 +390,7 @@
390
390
  ]
391
391
  },
392
392
  {
393
- "description": "Right Arrow on menuitem with submenu opens the submenu, updates ARIA expanded, and focuses the first interactive element of the menu.",
393
+ "description": "Right Arrow from a submenu item opens the submenu, updates ARIA expanded, and focuses first item.",
394
394
  "action": [
395
395
  { "type": "click", "target": "trigger" },
396
396
  { "type": "keypress", "target": "submenuTrigger", "key": "ArrowRight" }
@@ -425,7 +425,7 @@
425
425
  {
426
426
  "target": "container",
427
427
  "assertion": "notToBeVisible",
428
- "failureMessage": "Menu should close after clicking outside."
428
+ "failureMessage": "Menu should close after pressing Tab from a menuitem."
429
429
  },
430
430
  {
431
431
  "target": "trigger",
@@ -433,51 +433,76 @@
433
433
  "attribute": "aria-expanded",
434
434
  "expectedValue": "false",
435
435
  "failureMessage": "Trigger's aria-expanded should be false after Tab closes the menu."
436
- },
436
+ }
437
+ ]
438
+ },
439
+ {
440
+ "description": "Shift+Tab on a menuitem closes the menu and update aria-expanded.",
441
+ "action": [
442
+ { "type": "click", "target": "trigger" },
443
+ { "type": "keypress", "target": "focusable", "key": "Shift+Tab" }
444
+ ],
445
+ "assertions": [
437
446
  {
438
- "target": "submenu",
447
+ "target": "container",
439
448
  "assertion": "notToBeVisible",
440
- "failureMessage": "Shift+Tab should close all submenus, not just top-level menu."
449
+ "failureMessage": "Menu should close after pressing Shift+Tab from a menuitem."
441
450
  },
442
451
  {
443
- "target": "submenuTrigger",
452
+ "target": "trigger",
444
453
  "assertion": "toHaveAttribute",
445
454
  "attribute": "aria-expanded",
446
455
  "expectedValue": "false",
447
- "failureMessage": "Submenu trigger's aria-expanded should be false after Shift+Tab closes the submenu."
456
+ "failureMessage": "Trigger's aria-expanded should be false after Shift+Tab closes the menu."
448
457
  }
449
458
  ]
450
459
  },
451
460
  {
452
- "description": "Shift+Tab on a menuitem closes the menu and update aria-expanded.",
461
+ "description": "Tab from a submenu item closes submenu and updates submenu trigger expanded state.",
453
462
  "action": [
454
463
  { "type": "click", "target": "trigger" },
455
- { "type": "keypress", "target": "focusable", "key": "Shift+Tab" }
464
+ { "type": "keypress", "target": "submenuTrigger", "key": "ArrowRight" },
465
+ { "type": "keypress", "target": "submenuItems", "key": "Tab" }
456
466
  ],
457
467
  "assertions": [
458
468
  {
459
- "target": "container",
469
+ "target": "submenu",
460
470
  "assertion": "notToBeVisible",
461
- "failureMessage": "Menu should close after clicking outside."
471
+ "failureMessage": "Tab should close submenu when focus leaves submenu items."
462
472
  },
463
473
  {
464
- "target": "trigger",
474
+ "target": "submenuTrigger",
465
475
  "assertion": "toHaveAttribute",
466
476
  "attribute": "aria-expanded",
467
477
  "expectedValue": "false",
468
- "failureMessage": "Trigger's aria-expanded should be false after Shift+Tab closes the menu."
469
- },
478
+ "failureMessage": "Submenu trigger's aria-expanded should be false after Tab closes submenu."
479
+ }
480
+ ]
481
+ },
482
+ {
483
+ "description": "Shift+Tab from a submenu item closes submenu and updates submenu trigger expanded state.",
484
+ "action": [
485
+ { "type": "click", "target": "trigger" },
486
+ { "type": "keypress", "target": "submenuTrigger", "key": "ArrowRight" },
487
+ { "type": "keypress", "target": "submenuItems", "key": "Shift+Tab" }
488
+ ],
489
+ "assertions": [
470
490
  {
471
491
  "target": "submenu",
472
492
  "assertion": "notToBeVisible",
473
- "failureMessage": "Shift+Tab should close all submenus, not just top-level menu."
493
+ "failureMessage": "Shift+Tab should close submenu when focus leaves submenu items."
474
494
  },
475
495
  {
476
496
  "target": "submenuTrigger",
477
497
  "assertion": "toHaveAttribute",
478
498
  "attribute": "aria-expanded",
479
499
  "expectedValue": "false",
480
- "failureMessage": "Submenu trigger's aria-expanded should be false after Shift+Tab closes the submenu."
500
+ "failureMessage": "Submenu trigger's aria-expanded should be false after Shift+Tab closes submenu."
501
+ },
502
+ {
503
+ "target": "container",
504
+ "assertion": "notToBeVisible",
505
+ "failureMessage": "Shift+Tab should also close top-level menu after submenu closes."
481
506
  }
482
507
  ]
483
508
  },
@@ -3,7 +3,7 @@
3
3
  "id": "aria-ease.tabs",
4
4
  "version": "1.0.0",
5
5
  "created": "28-02-2026",
6
- "lastUpdated": "15-03-2026",
6
+ "lastUpdated": "19-03-2026",
7
7
  "description": "ARIA tabs interaction contract. Validates the ARIA and interaction contract for a custom tabs component following the ARIA Authoring Practices Guide pattern.",
8
8
  "source": {
9
9
  "apg": "https://www.w3.org/WAI/ARIA/apg/patterns/tabs/",
@@ -226,7 +226,7 @@
226
226
  },
227
227
  {
228
228
  "description": "Home moves focus to first tab and activates it.",
229
- "isOptional": true,
229
+ "level": "optional",
230
230
  "action": [
231
231
  { "type": "keypress", "target": "focusable", "key": "ArrowRight" },
232
232
  { "type": "keypress", "target": "focusable", "key": "Home" }
@@ -250,7 +250,7 @@
250
250
  },
251
251
  {
252
252
  "description": "End moves focus to last tab and activates it.",
253
- "isOptional": true,
253
+ "level": "optional",
254
254
  "action": [
255
255
  { "type": "keypress", "target": "focusable", "key": "End" }
256
256
  ],