@reticular/speakable 1.7.0 → 1.8.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.
@@ -67,6 +67,12 @@ var SUPPORTED_ROLES = [
67
67
  "meter",
68
68
  "progressbar",
69
69
  "status",
70
+ "switch",
71
+ "slider",
72
+ "spinbutton",
73
+ "tab",
74
+ "tablist",
75
+ "tabpanel",
70
76
  "document",
71
77
  "application",
72
78
  "separator"
@@ -366,7 +372,10 @@ var INPUT_TYPE_ROLE_MAP = {
366
372
  "search": "textbox",
367
373
  "tel": "textbox",
368
374
  "url": "textbox",
369
- "number": "textbox"
375
+ // Per ARIA, <input type="number"> has an implicit role of spinbutton
376
+ // (VoiceOver announces it as "stepper").
377
+ "number": "spinbutton",
378
+ "range": "slider"
370
379
  };
371
380
  function computeRole(element) {
372
381
  const warnings = [];
@@ -399,6 +408,11 @@ function computeImplicitRole(element, _warnings) {
399
408
  if (tagName === "input") {
400
409
  return computeInputRole(element);
401
410
  }
411
+ if (tagName === "select") {
412
+ const multiple = element.hasAttribute("multiple");
413
+ const size = parseInt(element.getAttribute("size") || "0", 10);
414
+ return multiple || size > 1 ? "listbox" : "combobox";
415
+ }
402
416
  if (tagName === "a") {
403
417
  return element.hasAttribute("href") ? "link" : null;
404
418
  }
@@ -802,6 +816,7 @@ function processChildNode(node, children, warnings) {
802
816
  if (node.nodeType === 1) {
803
817
  const element = node;
804
818
  if (isHidden(element)) return;
819
+ if (isConsumedLabel(element)) return;
805
820
  if (!isAccessible(element)) {
806
821
  const grandchildren = collectChildrenFromRolelessElement(element, warnings);
807
822
  children.push(...grandchildren);
@@ -811,6 +826,25 @@ function processChildNode(node, children, warnings) {
811
826
  if (childNode) children.push(childNode);
812
827
  }
813
828
  }
829
+ function isConsumedLabel(element) {
830
+ if (element.tagName.toLowerCase() !== "label") return false;
831
+ const forId = element.getAttribute("for");
832
+ if (forId) {
833
+ const doc = element.ownerDocument;
834
+ const target = doc?.getElementById(forId);
835
+ if (target && isLabelableControl(target)) return true;
836
+ }
837
+ const wrapped = element.querySelector("input, select, textarea, button, meter, progress");
838
+ if (wrapped && isLabelableControl(wrapped)) return true;
839
+ return false;
840
+ }
841
+ function isLabelableControl(element) {
842
+ const tag = element.tagName.toLowerCase();
843
+ if (tag === "input") {
844
+ return (element.getAttribute("type") || "text").toLowerCase() !== "hidden";
845
+ }
846
+ return tag === "select" || tag === "textarea" || tag === "button" || tag === "meter" || tag === "progress";
847
+ }
814
848
  function collectChildrenFromRolelessElement(element, warnings) {
815
849
  return collectAccessibleChildren(element, warnings);
816
850
  }
@@ -891,6 +925,30 @@ function buildAccessibilityTree(rootElement, sourceHash) {
891
925
  };
892
926
  return { model, warnings };
893
927
  }
928
+ var SET_MEMBER_ROLES = /* @__PURE__ */ new Set([
929
+ "radio",
930
+ "option",
931
+ "listitem",
932
+ "tab"
933
+ ]);
934
+ function assignImplicitSetPosition(children) {
935
+ const totals = /* @__PURE__ */ new Map();
936
+ for (const child of children) {
937
+ if (SET_MEMBER_ROLES.has(child.role)) {
938
+ totals.set(child.role, (totals.get(child.role) ?? 0) + 1);
939
+ }
940
+ }
941
+ const seen = /* @__PURE__ */ new Map();
942
+ for (const child of children) {
943
+ if (!SET_MEMBER_ROLES.has(child.role)) continue;
944
+ const total = totals.get(child.role);
945
+ if (total < 2) continue;
946
+ const pos = (seen.get(child.role) ?? 0) + 1;
947
+ seen.set(child.role, pos);
948
+ if (child.state.posinset === void 0) child.state.posinset = pos;
949
+ if (child.state.setsize === void 0) child.state.setsize = total;
950
+ }
951
+ }
894
952
  function buildNodeRecursive(element, warnings) {
895
953
  if (isHidden(element)) {
896
954
  return null;
@@ -927,7 +985,15 @@ function buildNodeRecursive(element, warnings) {
927
985
  element: w.element
928
986
  })));
929
987
  const focusInfo = extractFocusInfo(element);
930
- const children = collectAccessibleChildren(element, warnings);
988
+ let children = collectAccessibleChildren(element, warnings);
989
+ const isNativeSelect = element.tagName.toLowerCase() === "select";
990
+ if (isNativeSelect && roleResult.role === "combobox") {
991
+ children = [];
992
+ if (stateResult.state.expanded === void 0) {
993
+ stateResult.state.expanded = false;
994
+ }
995
+ }
996
+ assignImplicitSetPosition(children);
931
997
  const node = {
932
998
  role: roleResult.role,
933
999
  name: nameResult.name,
@@ -1424,8 +1490,45 @@ function renderNodeVoiceOver(node, announcements, c) {
1424
1490
  renderNodeVoiceOver(child, announcements, c);
1425
1491
  }
1426
1492
  }
1493
+ function isValueFirstWidget(role) {
1494
+ return role === "slider" || role === "spinbutton" || role === "combobox";
1495
+ }
1496
+ function isValueBeforeRoleWidget(role) {
1497
+ return role === "progressbar" || role === "meter";
1498
+ }
1499
+ function isSilentContainerVoiceOver(node) {
1500
+ if (node.role !== "list" && node.role !== "status") return false;
1501
+ if (node.name) return false;
1502
+ return node.children.length > 0;
1503
+ }
1427
1504
  function formatNodeVoiceOver(node, c) {
1505
+ if (isSilentContainerVoiceOver(node)) {
1506
+ return "";
1507
+ }
1428
1508
  const parts = [];
1509
+ const valueFirst = isValueFirstWidget(node.role) && node.value !== void 0;
1510
+ if (valueFirst) {
1511
+ const valueText = node.value.text || String(node.value.current);
1512
+ if (valueText) parts.push(valueText);
1513
+ if (node.name) parts.push(c.elementName(node.name));
1514
+ const roleText = formatRoleVoiceOver(node);
1515
+ if (roleText) parts.push(c.roleName(roleText));
1516
+ const stateText2 = formatStatesVoiceOver(node);
1517
+ if (stateText2) parts.push(c.stateName(stateText2));
1518
+ if (node.description) parts.push(c.description(node.description));
1519
+ return parts.join(", ");
1520
+ }
1521
+ if (isValueBeforeRoleWidget(node.role) && node.value !== void 0) {
1522
+ if (node.name) parts.push(c.elementName(node.name));
1523
+ const valueText = node.value.text || String(node.value.current);
1524
+ if (valueText) parts.push(valueText);
1525
+ const roleText = formatRoleVoiceOver(node);
1526
+ if (roleText) parts.push(c.roleName(roleText));
1527
+ const stateText2 = formatStatesVoiceOver(node);
1528
+ if (stateText2) parts.push(c.stateName(stateText2));
1529
+ if (node.description) parts.push(c.description(node.description));
1530
+ return parts.join(", ");
1531
+ }
1429
1532
  const roleFirst = shouldAnnounceRoleFirst(node.role);
1430
1533
  if (roleFirst) {
1431
1534
  const roleText = formatRoleVoiceOver(node);
@@ -1454,6 +1557,9 @@ function formatNodeVoiceOver(node, c) {
1454
1557
  parts.push(valueText);
1455
1558
  }
1456
1559
  }
1560
+ if (node.state.posinset !== void 0 && node.state.setsize !== void 0) {
1561
+ parts.push(`${node.state.posinset} of ${node.state.setsize}`);
1562
+ }
1457
1563
  if (node.description) {
1458
1564
  parts.push(c.description(node.description));
1459
1565
  }
@@ -1489,7 +1595,7 @@ function formatRoleVoiceOver(node) {
1489
1595
  case "list":
1490
1596
  return "list";
1491
1597
  case "listitem":
1492
- return "item";
1598
+ return "";
1493
1599
  case "navigation":
1494
1600
  return "navigation";
1495
1601
  case "main":
@@ -1545,6 +1651,18 @@ function formatRoleVoiceOver(node) {
1545
1651
  return "progress indicator";
1546
1652
  case "status":
1547
1653
  return "status";
1654
+ case "switch":
1655
+ return "switch";
1656
+ case "slider":
1657
+ return "slider";
1658
+ case "spinbutton":
1659
+ return "stepper";
1660
+ case "tab":
1661
+ return "tab";
1662
+ case "tablist":
1663
+ return node.name ? "tab group" : "tab group";
1664
+ case "tabpanel":
1665
+ return "tab panel";
1548
1666
  case "group":
1549
1667
  return node.name ? "group" : "";
1550
1668
  case "document":
@@ -1565,6 +1683,10 @@ function formatStatesVoiceOver(node) {
1565
1683
  if (node.state.checked !== void 0) {
1566
1684
  if (node.state.checked === "mixed") {
1567
1685
  states.push("mixed");
1686
+ } else if (node.role === "switch") {
1687
+ states.push(node.state.checked ? "on" : "off");
1688
+ } else if (node.role === "radio") {
1689
+ if (node.state.checked) states.push("selected");
1568
1690
  } else {
1569
1691
  states.push(node.state.checked ? "checked" : "unchecked");
1570
1692
  }
@@ -1573,10 +1695,10 @@ function formatStatesVoiceOver(node) {
1573
1695
  if (node.state.pressed === "mixed") {
1574
1696
  states.push("mixed");
1575
1697
  } else {
1576
- states.push(node.state.pressed ? "pressed" : "not pressed");
1698
+ states.push(node.state.pressed ? "selected" : "unselected");
1577
1699
  }
1578
1700
  }
1579
- if (node.state.selected !== void 0) {
1701
+ if (node.state.selected !== void 0 && node.role !== "tab") {
1580
1702
  states.push(node.state.selected ? "selected" : "unselected");
1581
1703
  }
1582
1704
  if (node.state.disabled) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticular/speakable",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "CLI tool for analyzing HTML accessibility announcements",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -59,6 +59,9 @@
59
59
  "url": "https://github.com/R3TICULAR/AnnounceKit/issues"
60
60
  },
61
61
  "devDependencies": {
62
+ "@guidepup/guidepup": "^0.38.0",
63
+ "@guidepup/playwright": "^0.21.0",
64
+ "@playwright/test": "^1.64.1",
62
65
  "@storybook/addon-a11y": "^8.6.18",
63
66
  "@storybook/addon-essentials": "^8.6.14",
64
67
  "@storybook/html": "^8.6.18",
@@ -70,6 +73,7 @@
70
73
  "fast-check": "^3.15.0",
71
74
  "storybook": "^8.6.18",
72
75
  "tsup": "^8.5.0",
76
+ "tsx": "^4.23.15",
73
77
  "typescript": "^5.3.0",
74
78
  "vitest": "^3.1.0"
75
79
  },