@reticular/speakable 1.6.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.
@@ -0,0 +1,57 @@
1
+ import { I as InteractionSequence, a as AnalysisResult, A as AccessibilityTimeline } from '../types-Blh0EnZF.js';
2
+
3
+ /**
4
+ * Shared message protocol for the iframe harness.
5
+ *
6
+ * The parent-page controller and the in-iframe agent exchange these
7
+ * request/response envelopes over `postMessage`. Every request carries a
8
+ * unique `id` so responses can be correlated.
9
+ */
10
+
11
+ /** Serializable subset of CaptureOptions safe to send over postMessage. */
12
+ interface SerializableCaptureOptions {
13
+ componentName: string;
14
+ storyName?: string;
15
+ sequence: InteractionSequence;
16
+ settlePeriod?: number;
17
+ awaitUpgrade?: boolean;
18
+ upgradeTimeoutMs?: number;
19
+ }
20
+
21
+ /**
22
+ * Iframe harness — parent-page controller.
23
+ *
24
+ * Runs in a parent page, mounts a component (URL or HTML string) into an
25
+ * iframe, injects the browser bundle, and drives analysis/capture over
26
+ * `postMessage`. This is the Storybook-independent runtime testing surface.
27
+ */
28
+
29
+ interface HarnessOptions {
30
+ /** An existing iframe to drive, or a container to create one inside. */
31
+ target: HTMLIFrameElement | {
32
+ container: HTMLElement;
33
+ };
34
+ /** Load + inject + request timeout in ms (default: 10000). */
35
+ timeoutMs?: number;
36
+ /** Origins allowed for postMessage. Defaults to same-origin only. */
37
+ allowedOrigins?: string[];
38
+ /**
39
+ * URL to the injectable IIFE bundle. When omitted, the harness assumes the
40
+ * bundle is already present in the iframe (e.g. bundled into the page).
41
+ */
42
+ bundleUrl?: string;
43
+ }
44
+ type LoadSource = {
45
+ url: string;
46
+ } | {
47
+ html: string;
48
+ };
49
+ interface Harness {
50
+ load(source: LoadSource): Promise<void>;
51
+ analyze(selector?: string): Promise<AnalysisResult>;
52
+ captureTimeline(options: SerializableCaptureOptions): Promise<AccessibilityTimeline>;
53
+ destroy(): void;
54
+ }
55
+ declare function createHarness(options: HarnessOptions): Harness;
56
+
57
+ export { type Harness, type HarnessOptions, type LoadSource, type SerializableCaptureOptions, createHarness };
@@ -0,0 +1,176 @@
1
+ // src/harness/protocol.ts
2
+ var HARNESS_CHANNEL = "speakable-harness";
3
+ function isHarnessResponse(data) {
4
+ if (!data || typeof data !== "object") return false;
5
+ const d = data;
6
+ return d.channel === HARNESS_CHANNEL && typeof d.id === "string" && typeof d.ok === "boolean";
7
+ }
8
+ function isHarnessReady(data) {
9
+ if (!data || typeof data !== "object") return false;
10
+ const d = data;
11
+ return d.channel === HARNESS_CHANNEL && d.kind === "ready";
12
+ }
13
+
14
+ // src/harness/index.ts
15
+ var DEFAULT_TIMEOUT = 1e4;
16
+ var idCounter = 0;
17
+ function nextId() {
18
+ idCounter += 1;
19
+ return `req-${Date.now()}-${idCounter}`;
20
+ }
21
+ function isCrossOrigin(url) {
22
+ try {
23
+ const resolved = new URL(url, window.location.href);
24
+ return resolved.origin !== window.location.origin;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+ function createHarness(options) {
30
+ const { timeoutMs = DEFAULT_TIMEOUT, allowedOrigins, bundleUrl } = options;
31
+ let ownsIframe = false;
32
+ let iframe;
33
+ if (options.target instanceof HTMLIFrameElement) {
34
+ iframe = options.target;
35
+ } else {
36
+ iframe = document.createElement("iframe");
37
+ iframe.setAttribute("title", "speakable-harness");
38
+ options.target.container.appendChild(iframe);
39
+ ownsIframe = true;
40
+ }
41
+ const pending = /* @__PURE__ */ new Map();
42
+ function onMessage(event) {
43
+ if (event.source !== iframe.contentWindow) return;
44
+ if (allowedOrigins && allowedOrigins.length > 0 && !allowedOrigins.includes("*")) {
45
+ if (!allowedOrigins.includes(event.origin)) return;
46
+ }
47
+ const data = event.data;
48
+ if (!isHarnessResponse(data)) return;
49
+ const entry = pending.get(data.id);
50
+ if (!entry) return;
51
+ clearTimeout(entry.timer);
52
+ pending.delete(data.id);
53
+ entry.resolve(data);
54
+ }
55
+ window.addEventListener("message", onMessage);
56
+ function targetOrigin() {
57
+ if (allowedOrigins && allowedOrigins.includes("*")) return "*";
58
+ try {
59
+ return iframe.contentWindow ? new URL(iframe.src || window.location.href).origin : "*";
60
+ } catch {
61
+ return "*";
62
+ }
63
+ }
64
+ function request(payload) {
65
+ const id = nextId();
66
+ const win = iframe.contentWindow;
67
+ if (!win) {
68
+ return Promise.reject(new Error("Harness iframe has no content window; call load() first."));
69
+ }
70
+ return new Promise((resolve, reject) => {
71
+ const timer = setTimeout(() => {
72
+ pending.delete(id);
73
+ reject(new Error(`Harness request "${payload.kind}" timed out after ${timeoutMs}ms`));
74
+ }, timeoutMs);
75
+ pending.set(id, { resolve, reject, timer });
76
+ win.postMessage({ channel: HARNESS_CHANNEL, id, ...payload }, targetOrigin());
77
+ });
78
+ }
79
+ function waitForReady() {
80
+ return new Promise((resolve, reject) => {
81
+ const timer = setTimeout(() => {
82
+ window.removeEventListener("message", onReady);
83
+ reject(new Error(`Harness bundle did not signal ready within ${timeoutMs}ms`));
84
+ }, timeoutMs);
85
+ function onReady(event) {
86
+ if (event.source !== iframe.contentWindow) return;
87
+ if (!isHarnessReady(event.data)) return;
88
+ clearTimeout(timer);
89
+ window.removeEventListener("message", onReady);
90
+ resolve();
91
+ }
92
+ window.addEventListener("message", onReady);
93
+ });
94
+ }
95
+ function waitForLoad() {
96
+ return new Promise((resolve, reject) => {
97
+ const timer = setTimeout(() => {
98
+ iframe.removeEventListener("load", onLoad);
99
+ reject(new Error(`Harness iframe failed to load within ${timeoutMs}ms`));
100
+ }, timeoutMs);
101
+ function onLoad() {
102
+ clearTimeout(timer);
103
+ iframe.removeEventListener("load", onLoad);
104
+ resolve();
105
+ }
106
+ iframe.addEventListener("load", onLoad);
107
+ });
108
+ }
109
+ function injectBundle() {
110
+ const doc = iframe.contentDocument;
111
+ if (!doc) {
112
+ throw new Error(
113
+ "Cannot access iframe document for bundle injection. Cross-origin content is not supported; use { html } (srcdoc) or a same-origin URL."
114
+ );
115
+ }
116
+ const script = doc.createElement("script");
117
+ if (bundleUrl) {
118
+ script.src = bundleUrl;
119
+ } else {
120
+ const injected = iframe.contentWindow?.__SPEAKABLE__;
121
+ if (!injected) {
122
+ throw new Error(
123
+ "No bundleUrl provided and the iframe does not already include the Speakable bundle. Pass options.bundleUrl."
124
+ );
125
+ }
126
+ return;
127
+ }
128
+ doc.head?.appendChild(script) ?? doc.documentElement.appendChild(script);
129
+ }
130
+ return {
131
+ async load(source) {
132
+ if ("url" in source && isCrossOrigin(source.url)) {
133
+ throw new Error(
134
+ `Cannot inject into cross-origin URL "${source.url}". Use { html } (srcdoc) or a same-origin URL.`
135
+ );
136
+ }
137
+ const loaded = waitForLoad();
138
+ if ("url" in source) {
139
+ iframe.src = source.url;
140
+ } else {
141
+ iframe.srcdoc = source.html;
142
+ }
143
+ await loaded;
144
+ const ready = waitForReady();
145
+ injectBundle();
146
+ const alreadyPresent = iframe.contentWindow?.__SPEAKABLE__;
147
+ if (alreadyPresent) return;
148
+ await ready;
149
+ },
150
+ async analyze(selector) {
151
+ const res = await request({ kind: "analyze", ...selector && { selector } });
152
+ if (res.ok && res.kind === "analyze") return res.result;
153
+ throw new Error(res.ok ? "Unexpected response kind" : res.error);
154
+ },
155
+ async captureTimeline(opts) {
156
+ const res = await request({ kind: "capture", options: opts });
157
+ if (res.ok && res.kind === "capture") return res.result;
158
+ throw new Error(res.ok ? "Unexpected response kind" : res.error);
159
+ },
160
+ destroy() {
161
+ window.removeEventListener("message", onMessage);
162
+ for (const [, entry] of pending) {
163
+ clearTimeout(entry.timer);
164
+ entry.reject(new Error("Harness destroyed"));
165
+ }
166
+ pending.clear();
167
+ if (ownsIframe && iframe.parentNode) {
168
+ iframe.parentNode.removeChild(iframe);
169
+ }
170
+ }
171
+ };
172
+ }
173
+ export {
174
+ createHarness
175
+ };
176
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/harness/protocol.ts","../../src/harness/index.ts"],"sourcesContent":["/**\n * Shared message protocol for the iframe harness.\n *\n * The parent-page controller and the in-iframe agent exchange these\n * request/response envelopes over `postMessage`. Every request carries a\n * unique `id` so responses can be correlated.\n */\n\nimport type { AnalysisResult } from '../browser/analyze.js';\nimport type { AccessibilityTimeline, InteractionSequence } from '../runtime/types.js';\n\n/** Namespace tag stamped on every harness message to avoid collisions. */\nexport const HARNESS_CHANNEL = 'speakable-harness';\n\n/** Serializable subset of CaptureOptions safe to send over postMessage. */\nexport interface SerializableCaptureOptions {\n componentName: string;\n storyName?: string;\n sequence: InteractionSequence;\n settlePeriod?: number;\n awaitUpgrade?: boolean;\n upgradeTimeoutMs?: number;\n}\n\nexport type HarnessRequest =\n | { channel: typeof HARNESS_CHANNEL; id: string; kind: 'analyze'; selector?: string }\n | { channel: typeof HARNESS_CHANNEL; id: string; kind: 'capture'; options: SerializableCaptureOptions };\n\nexport type HarnessResponse =\n | { channel: typeof HARNESS_CHANNEL; id: string; ok: true; kind: 'analyze'; result: AnalysisResult }\n | { channel: typeof HARNESS_CHANNEL; id: string; ok: true; kind: 'capture'; result: AccessibilityTimeline }\n | { channel: typeof HARNESS_CHANNEL; id: string; ok: false; error: string };\n\n/** Sent by the agent once it has installed its message listener. */\nexport interface HarnessReadyMessage {\n channel: typeof HARNESS_CHANNEL;\n kind: 'ready';\n}\n\nexport function isHarnessRequest(data: unknown): data is HarnessRequest {\n if (!data || typeof data !== 'object') return false;\n const d = data as Record<string, unknown>;\n return d.channel === HARNESS_CHANNEL && typeof d.id === 'string' &&\n (d.kind === 'analyze' || d.kind === 'capture');\n}\n\nexport function isHarnessResponse(data: unknown): data is HarnessResponse {\n if (!data || typeof data !== 'object') return false;\n const d = data as Record<string, unknown>;\n return d.channel === HARNESS_CHANNEL && typeof d.id === 'string' && typeof d.ok === 'boolean';\n}\n\nexport function isHarnessReady(data: unknown): data is HarnessReadyMessage {\n if (!data || typeof data !== 'object') return false;\n const d = data as Record<string, unknown>;\n return d.channel === HARNESS_CHANNEL && d.kind === 'ready';\n}\n","/**\n * Iframe harness — parent-page controller.\n *\n * Runs in a parent page, mounts a component (URL or HTML string) into an\n * iframe, injects the browser bundle, and drives analysis/capture over\n * `postMessage`. This is the Storybook-independent runtime testing surface.\n */\n\nimport type { AnalysisResult } from '../browser/analyze.js';\nimport type { AccessibilityTimeline } from '../runtime/types.js';\nimport {\n HARNESS_CHANNEL,\n isHarnessReady,\n isHarnessResponse,\n type HarnessResponse,\n type SerializableCaptureOptions,\n} from './protocol.js';\n\nexport interface HarnessOptions {\n /** An existing iframe to drive, or a container to create one inside. */\n target: HTMLIFrameElement | { container: HTMLElement };\n /** Load + inject + request timeout in ms (default: 10000). */\n timeoutMs?: number;\n /** Origins allowed for postMessage. Defaults to same-origin only. */\n allowedOrigins?: string[];\n /**\n * URL to the injectable IIFE bundle. When omitted, the harness assumes the\n * bundle is already present in the iframe (e.g. bundled into the page).\n */\n bundleUrl?: string;\n}\n\nexport type LoadSource = { url: string } | { html: string };\n\nexport interface Harness {\n load(source: LoadSource): Promise<void>;\n analyze(selector?: string): Promise<AnalysisResult>;\n captureTimeline(options: SerializableCaptureOptions): Promise<AccessibilityTimeline>;\n destroy(): void;\n}\n\nconst DEFAULT_TIMEOUT = 10000;\n\nlet idCounter = 0;\nfunction nextId(): string {\n idCounter += 1;\n return `req-${Date.now()}-${idCounter}`;\n}\n\nfunction isCrossOrigin(url: string): boolean {\n try {\n const resolved = new URL(url, window.location.href);\n return resolved.origin !== window.location.origin;\n } catch {\n // Relative or malformed — treat as same-origin.\n return false;\n }\n}\n\nexport function createHarness(options: HarnessOptions): Harness {\n const { timeoutMs = DEFAULT_TIMEOUT, allowedOrigins, bundleUrl } = options;\n\n // Resolve or create the iframe.\n let ownsIframe = false;\n let iframe: HTMLIFrameElement;\n if (options.target instanceof HTMLIFrameElement) {\n iframe = options.target;\n } else {\n iframe = document.createElement('iframe');\n iframe.setAttribute('title', 'speakable-harness');\n options.target.container.appendChild(iframe);\n ownsIframe = true;\n }\n\n const pending = new Map<\n string,\n { resolve: (r: HarnessResponse) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }\n >();\n\n function onMessage(event: MessageEvent) {\n // Only accept messages originating from our iframe's window.\n if (event.source !== iframe.contentWindow) return;\n if (allowedOrigins && allowedOrigins.length > 0 && !allowedOrigins.includes('*')) {\n if (!allowedOrigins.includes(event.origin)) return;\n }\n const data = event.data;\n if (!isHarnessResponse(data)) return;\n const entry = pending.get(data.id);\n if (!entry) return;\n clearTimeout(entry.timer);\n pending.delete(data.id);\n entry.resolve(data);\n }\n\n window.addEventListener('message', onMessage);\n\n function targetOrigin(): string {\n if (allowedOrigins && allowedOrigins.includes('*')) return '*';\n try {\n return iframe.contentWindow ? new URL(iframe.src || window.location.href).origin : '*';\n } catch {\n return '*';\n }\n }\n\n function request(payload: Record<string, unknown>): Promise<HarnessResponse> {\n const id = nextId();\n const win = iframe.contentWindow;\n if (!win) {\n return Promise.reject(new Error('Harness iframe has no content window; call load() first.'));\n }\n return new Promise<HarnessResponse>((resolve, reject) => {\n const timer = setTimeout(() => {\n pending.delete(id);\n reject(new Error(`Harness request \"${payload.kind}\" timed out after ${timeoutMs}ms`));\n }, timeoutMs);\n pending.set(id, { resolve, reject, timer });\n win.postMessage({ channel: HARNESS_CHANNEL, id, ...payload }, targetOrigin());\n });\n }\n\n function waitForReady(): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = setTimeout(() => {\n window.removeEventListener('message', onReady);\n reject(new Error(`Harness bundle did not signal ready within ${timeoutMs}ms`));\n }, timeoutMs);\n function onReady(event: MessageEvent) {\n if (event.source !== iframe.contentWindow) return;\n if (!isHarnessReady(event.data)) return;\n clearTimeout(timer);\n window.removeEventListener('message', onReady);\n resolve();\n }\n window.addEventListener('message', onReady);\n });\n }\n\n function waitForLoad(): Promise<void> {\n return new Promise<void>((resolve, reject) => {\n const timer = setTimeout(() => {\n iframe.removeEventListener('load', onLoad);\n reject(new Error(`Harness iframe failed to load within ${timeoutMs}ms`));\n }, timeoutMs);\n function onLoad() {\n clearTimeout(timer);\n iframe.removeEventListener('load', onLoad);\n resolve();\n }\n iframe.addEventListener('load', onLoad);\n });\n }\n\n function injectBundle(): void {\n const doc = iframe.contentDocument;\n if (!doc) {\n throw new Error(\n 'Cannot access iframe document for bundle injection. Cross-origin content is not supported; use { html } (srcdoc) or a same-origin URL.'\n );\n }\n const script = doc.createElement('script');\n if (bundleUrl) {\n script.src = bundleUrl;\n } else {\n // No bundle URL provided and no bundle detected — surface a clear error\n // rather than hanging on waitForReady.\n const injected = (iframe.contentWindow as unknown as { __SPEAKABLE__?: unknown })?.__SPEAKABLE__;\n if (!injected) {\n throw new Error(\n 'No bundleUrl provided and the iframe does not already include the Speakable bundle. Pass options.bundleUrl.'\n );\n }\n return;\n }\n doc.head?.appendChild(script) ?? doc.documentElement.appendChild(script);\n }\n\n return {\n async load(source: LoadSource): Promise<void> {\n // Validate before starting any timers so we don't leak a pending\n // load-wait rejection when we bail out synchronously.\n if ('url' in source && isCrossOrigin(source.url)) {\n throw new Error(\n `Cannot inject into cross-origin URL \"${source.url}\". Use { html } (srcdoc) or a same-origin URL.`\n );\n }\n\n const loaded = waitForLoad();\n if ('url' in source) {\n iframe.src = source.url;\n } else {\n iframe.srcdoc = source.html;\n }\n await loaded;\n\n const ready = waitForReady();\n injectBundle();\n // If the bundle was already present, it may have signaled ready before we\n // attached the listener; fall back to a short probe.\n const alreadyPresent = (iframe.contentWindow as unknown as { __SPEAKABLE__?: unknown })?.__SPEAKABLE__;\n if (alreadyPresent) return;\n await ready;\n },\n\n async analyze(selector?: string): Promise<AnalysisResult> {\n const res = await request({ kind: 'analyze', ...(selector && { selector }) });\n if (res.ok && res.kind === 'analyze') return res.result;\n throw new Error(res.ok ? 'Unexpected response kind' : res.error);\n },\n\n async captureTimeline(opts: SerializableCaptureOptions): Promise<AccessibilityTimeline> {\n const res = await request({ kind: 'capture', options: opts });\n if (res.ok && res.kind === 'capture') return res.result;\n throw new Error(res.ok ? 'Unexpected response kind' : res.error);\n },\n\n destroy(): void {\n window.removeEventListener('message', onMessage);\n for (const [, entry] of pending) {\n clearTimeout(entry.timer);\n entry.reject(new Error('Harness destroyed'));\n }\n pending.clear();\n if (ownsIframe && iframe.parentNode) {\n iframe.parentNode.removeChild(iframe);\n }\n },\n };\n}\n\nexport type { SerializableCaptureOptions } from './protocol.js';\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -16,7 +16,7 @@ interface ModelVersion {
16
16
  * Accessible role following ARIA specification.
17
17
  * V1 supports common interactive and structural roles.
18
18
  */
19
- type AccessibleRole = 'button' | 'link' | 'heading' | 'textbox' | 'checkbox' | 'radio' | 'combobox' | 'listbox' | 'option' | 'list' | 'listitem' | 'article' | 'generic' | 'navigation' | 'main' | 'banner' | 'contentinfo' | 'region' | 'img' | 'complementary' | 'form' | 'search' | 'paragraph' | 'blockquote' | 'code' | 'staticText' | 'table' | 'row' | 'cell' | 'columnheader' | 'rowheader' | 'term' | 'definition' | 'figure' | 'caption' | 'group' | 'dialog' | 'meter' | 'progressbar' | 'status' | 'document' | 'application' | 'separator';
19
+ type AccessibleRole = 'button' | 'link' | 'heading' | 'textbox' | 'checkbox' | 'radio' | 'combobox' | 'listbox' | 'option' | 'list' | 'listitem' | 'article' | 'generic' | 'navigation' | 'main' | 'banner' | 'contentinfo' | 'region' | 'img' | 'complementary' | 'form' | 'search' | 'paragraph' | 'blockquote' | 'code' | 'staticText' | 'table' | 'row' | 'cell' | 'columnheader' | 'rowheader' | 'term' | 'definition' | 'figure' | 'caption' | 'group' | 'dialog' | 'meter' | 'progressbar' | 'status' | 'switch' | 'slider' | 'spinbutton' | 'tab' | 'tablist' | 'tabpanel' | 'document' | 'application' | 'separator';
20
20
  /**
21
21
  * Accessible states and properties.
22
22
  * Only includes properties relevant to the element's role.
package/dist/index.js CHANGED
@@ -48,6 +48,12 @@ var SUPPORTED_ROLES = [
48
48
  "meter",
49
49
  "progressbar",
50
50
  "status",
51
+ "switch",
52
+ "slider",
53
+ "spinbutton",
54
+ "tab",
55
+ "tablist",
56
+ "tabpanel",
51
57
  "document",
52
58
  "application",
53
59
  "separator"
@@ -968,8 +974,45 @@ function renderNodeVoiceOver(node, announcements, c) {
968
974
  renderNodeVoiceOver(child, announcements, c);
969
975
  }
970
976
  }
977
+ function isValueFirstWidget(role) {
978
+ return role === "slider" || role === "spinbutton" || role === "combobox";
979
+ }
980
+ function isValueBeforeRoleWidget(role) {
981
+ return role === "progressbar" || role === "meter";
982
+ }
983
+ function isSilentContainerVoiceOver(node) {
984
+ if (node.role !== "list" && node.role !== "status") return false;
985
+ if (node.name) return false;
986
+ return node.children.length > 0;
987
+ }
971
988
  function formatNodeVoiceOver(node, c) {
989
+ if (isSilentContainerVoiceOver(node)) {
990
+ return "";
991
+ }
972
992
  const parts = [];
993
+ const valueFirst = isValueFirstWidget(node.role) && node.value !== void 0;
994
+ if (valueFirst) {
995
+ const valueText = node.value.text || String(node.value.current);
996
+ if (valueText) parts.push(valueText);
997
+ if (node.name) parts.push(c.elementName(node.name));
998
+ const roleText = formatRoleVoiceOver(node);
999
+ if (roleText) parts.push(c.roleName(roleText));
1000
+ const stateText2 = formatStatesVoiceOver(node);
1001
+ if (stateText2) parts.push(c.stateName(stateText2));
1002
+ if (node.description) parts.push(c.description(node.description));
1003
+ return parts.join(", ");
1004
+ }
1005
+ if (isValueBeforeRoleWidget(node.role) && node.value !== void 0) {
1006
+ if (node.name) parts.push(c.elementName(node.name));
1007
+ const valueText = node.value.text || String(node.value.current);
1008
+ if (valueText) parts.push(valueText);
1009
+ const roleText = formatRoleVoiceOver(node);
1010
+ if (roleText) parts.push(c.roleName(roleText));
1011
+ const stateText2 = formatStatesVoiceOver(node);
1012
+ if (stateText2) parts.push(c.stateName(stateText2));
1013
+ if (node.description) parts.push(c.description(node.description));
1014
+ return parts.join(", ");
1015
+ }
973
1016
  const roleFirst = shouldAnnounceRoleFirst(node.role);
974
1017
  if (roleFirst) {
975
1018
  const roleText = formatRoleVoiceOver(node);
@@ -998,6 +1041,9 @@ function formatNodeVoiceOver(node, c) {
998
1041
  parts.push(valueText);
999
1042
  }
1000
1043
  }
1044
+ if (node.state.posinset !== void 0 && node.state.setsize !== void 0) {
1045
+ parts.push(`${node.state.posinset} of ${node.state.setsize}`);
1046
+ }
1001
1047
  if (node.description) {
1002
1048
  parts.push(c.description(node.description));
1003
1049
  }
@@ -1033,7 +1079,7 @@ function formatRoleVoiceOver(node) {
1033
1079
  case "list":
1034
1080
  return "list";
1035
1081
  case "listitem":
1036
- return "item";
1082
+ return "";
1037
1083
  case "navigation":
1038
1084
  return "navigation";
1039
1085
  case "main":
@@ -1089,6 +1135,18 @@ function formatRoleVoiceOver(node) {
1089
1135
  return "progress indicator";
1090
1136
  case "status":
1091
1137
  return "status";
1138
+ case "switch":
1139
+ return "switch";
1140
+ case "slider":
1141
+ return "slider";
1142
+ case "spinbutton":
1143
+ return "stepper";
1144
+ case "tab":
1145
+ return "tab";
1146
+ case "tablist":
1147
+ return node.name ? "tab group" : "tab group";
1148
+ case "tabpanel":
1149
+ return "tab panel";
1092
1150
  case "group":
1093
1151
  return node.name ? "group" : "";
1094
1152
  case "document":
@@ -1109,6 +1167,10 @@ function formatStatesVoiceOver(node) {
1109
1167
  if (node.state.checked !== void 0) {
1110
1168
  if (node.state.checked === "mixed") {
1111
1169
  states.push("mixed");
1170
+ } else if (node.role === "switch") {
1171
+ states.push(node.state.checked ? "on" : "off");
1172
+ } else if (node.role === "radio") {
1173
+ if (node.state.checked) states.push("selected");
1112
1174
  } else {
1113
1175
  states.push(node.state.checked ? "checked" : "unchecked");
1114
1176
  }
@@ -1117,10 +1179,10 @@ function formatStatesVoiceOver(node) {
1117
1179
  if (node.state.pressed === "mixed") {
1118
1180
  states.push("mixed");
1119
1181
  } else {
1120
- states.push(node.state.pressed ? "pressed" : "not pressed");
1182
+ states.push(node.state.pressed ? "selected" : "unselected");
1121
1183
  }
1122
1184
  }
1123
- if (node.state.selected !== void 0) {
1185
+ if (node.state.selected !== void 0 && node.role !== "tab") {
1124
1186
  states.push(node.state.selected ? "selected" : "unselected");
1125
1187
  }
1126
1188
  if (node.state.disabled) {
@@ -2069,7 +2131,10 @@ var INPUT_TYPE_ROLE_MAP = {
2069
2131
  "search": "textbox",
2070
2132
  "tel": "textbox",
2071
2133
  "url": "textbox",
2072
- "number": "textbox"
2134
+ // Per ARIA, <input type="number"> has an implicit role of spinbutton
2135
+ // (VoiceOver announces it as "stepper").
2136
+ "number": "spinbutton",
2137
+ "range": "slider"
2073
2138
  };
2074
2139
  function computeRole(element) {
2075
2140
  const warnings = [];
@@ -2102,6 +2167,11 @@ function computeImplicitRole(element, _warnings) {
2102
2167
  if (tagName === "input") {
2103
2168
  return computeInputRole(element);
2104
2169
  }
2170
+ if (tagName === "select") {
2171
+ const multiple = element.hasAttribute("multiple");
2172
+ const size = parseInt(element.getAttribute("size") || "0", 10);
2173
+ return multiple || size > 1 ? "listbox" : "combobox";
2174
+ }
2105
2175
  if (tagName === "a") {
2106
2176
  return element.hasAttribute("href") ? "link" : null;
2107
2177
  }
@@ -2511,6 +2581,7 @@ function processChildNode(node, children, warnings) {
2511
2581
  if (node.nodeType === 1) {
2512
2582
  const element = node;
2513
2583
  if (isHidden(element)) return;
2584
+ if (isConsumedLabel(element)) return;
2514
2585
  if (!isAccessible(element)) {
2515
2586
  const grandchildren = collectChildrenFromRolelessElement(element, warnings);
2516
2587
  children.push(...grandchildren);
@@ -2520,18 +2591,74 @@ function processChildNode(node, children, warnings) {
2520
2591
  if (childNode) children.push(childNode);
2521
2592
  }
2522
2593
  }
2594
+ function isConsumedLabel(element) {
2595
+ if (element.tagName.toLowerCase() !== "label") return false;
2596
+ const forId = element.getAttribute("for");
2597
+ if (forId) {
2598
+ const doc = element.ownerDocument;
2599
+ const target = doc?.getElementById(forId);
2600
+ if (target && isLabelableControl(target)) return true;
2601
+ }
2602
+ const wrapped = element.querySelector("input, select, textarea, button, meter, progress");
2603
+ if (wrapped && isLabelableControl(wrapped)) return true;
2604
+ return false;
2605
+ }
2606
+ function isLabelableControl(element) {
2607
+ const tag = element.tagName.toLowerCase();
2608
+ if (tag === "input") {
2609
+ return (element.getAttribute("type") || "text").toLowerCase() !== "hidden";
2610
+ }
2611
+ return tag === "select" || tag === "textarea" || tag === "button" || tag === "meter" || tag === "progress";
2612
+ }
2523
2613
  function collectChildrenFromRolelessElement(element, warnings) {
2614
+ return collectAccessibleChildren(element, warnings);
2615
+ }
2616
+ function collectAccessibleChildren(element, warnings) {
2524
2617
  const children = [];
2525
2618
  if (element.shadowRoot) {
2526
2619
  for (const child of Array.from(element.shadowRoot.childNodes)) {
2527
- processChildNode(child, children, warnings);
2620
+ processShadowNode(child, children, warnings);
2528
2621
  }
2622
+ return children;
2623
+ }
2624
+ if (element.localName && element.localName.includes("-") && element.childNodes.length === 0) {
2625
+ warnings.push({
2626
+ message: `Custom element <${element.localName}> has no accessible children and may have a closed shadow root; its content could not be analyzed.`,
2627
+ element
2628
+ });
2529
2629
  }
2530
2630
  for (const child of Array.from(element.childNodes)) {
2531
2631
  processChildNode(child, children, warnings);
2532
2632
  }
2533
2633
  return children;
2534
2634
  }
2635
+ function processShadowNode(node, children, warnings) {
2636
+ if (node.nodeType === 1) {
2637
+ const element = node;
2638
+ if (element.localName === "slot") {
2639
+ const slot = element;
2640
+ let assigned = [];
2641
+ if (typeof slot.assignedNodes === "function") {
2642
+ try {
2643
+ assigned = Array.from(slot.assignedNodes({ flatten: true }));
2644
+ } catch {
2645
+ assigned = [];
2646
+ }
2647
+ }
2648
+ if (assigned.length > 0) {
2649
+ for (const assignedNode of assigned) {
2650
+ processChildNode(assignedNode, children, warnings);
2651
+ }
2652
+ } else {
2653
+ for (const child of Array.from(slot.childNodes)) {
2654
+ processShadowNode(child, children, warnings);
2655
+ }
2656
+ }
2657
+ return;
2658
+ }
2659
+ }
2660
+ processChildNode(node, children, warnings);
2661
+ }
2535
2662
  function buildAccessibilityTree(rootElement, sourceHash) {
2536
2663
  const warnings = [];
2537
2664
  if (isHidden(rootElement)) {
@@ -2588,6 +2715,30 @@ function buildAccessibilityTreeWithSelector(rootElement, selector, sourceHash) {
2588
2715
  }
2589
2716
  return results;
2590
2717
  }
2718
+ var SET_MEMBER_ROLES = /* @__PURE__ */ new Set([
2719
+ "radio",
2720
+ "option",
2721
+ "listitem",
2722
+ "tab"
2723
+ ]);
2724
+ function assignImplicitSetPosition(children) {
2725
+ const totals = /* @__PURE__ */ new Map();
2726
+ for (const child of children) {
2727
+ if (SET_MEMBER_ROLES.has(child.role)) {
2728
+ totals.set(child.role, (totals.get(child.role) ?? 0) + 1);
2729
+ }
2730
+ }
2731
+ const seen = /* @__PURE__ */ new Map();
2732
+ for (const child of children) {
2733
+ if (!SET_MEMBER_ROLES.has(child.role)) continue;
2734
+ const total = totals.get(child.role);
2735
+ if (total < 2) continue;
2736
+ const pos = (seen.get(child.role) ?? 0) + 1;
2737
+ seen.set(child.role, pos);
2738
+ if (child.state.posinset === void 0) child.state.posinset = pos;
2739
+ if (child.state.setsize === void 0) child.state.setsize = total;
2740
+ }
2741
+ }
2591
2742
  function buildNodeRecursive(element, warnings) {
2592
2743
  if (isHidden(element)) {
2593
2744
  return null;
@@ -2624,15 +2775,15 @@ function buildNodeRecursive(element, warnings) {
2624
2775
  element: w.element
2625
2776
  })));
2626
2777
  const focusInfo = extractFocusInfo(element);
2627
- const children = [];
2628
- if (element.shadowRoot) {
2629
- for (const child of Array.from(element.shadowRoot.childNodes)) {
2630
- processChildNode(child, children, warnings);
2778
+ let children = collectAccessibleChildren(element, warnings);
2779
+ const isNativeSelect = element.tagName.toLowerCase() === "select";
2780
+ if (isNativeSelect && roleResult.role === "combobox") {
2781
+ children = [];
2782
+ if (stateResult.state.expanded === void 0) {
2783
+ stateResult.state.expanded = false;
2631
2784
  }
2632
2785
  }
2633
- for (const child of Array.from(element.childNodes)) {
2634
- processChildNode(child, children, warnings);
2635
- }
2786
+ assignImplicitSetPosition(children);
2636
2787
  const node = {
2637
2788
  role: roleResult.role,
2638
2789
  name: nameResult.name,
@@ -2645,15 +2796,7 @@ function buildNodeRecursive(element, warnings) {
2645
2796
  return node;
2646
2797
  }
2647
2798
  function createGenericContainer(rootElement, warnings) {
2648
- const children = [];
2649
- if (rootElement.shadowRoot) {
2650
- for (const child of Array.from(rootElement.shadowRoot.childNodes)) {
2651
- processChildNode(child, children, warnings);
2652
- }
2653
- }
2654
- for (const child of Array.from(rootElement.childNodes)) {
2655
- processChildNode(child, children, warnings);
2656
- }
2799
+ const children = collectAccessibleChildren(rootElement, warnings);
2657
2800
  return {
2658
2801
  role: "generic",
2659
2802
  name: "",
@@ -4365,8 +4508,10 @@ function createHeuristicAnalyzer(config) {
4365
4508
  };
4366
4509
  }
4367
4510
 
4511
+ // src/version.ts
4512
+ var SPEAKABLE_VERSION = "1.7.0" ? "1.7.0" : "dev";
4513
+
4368
4514
  // src/runtime/timeline-generator.ts
4369
- var SPEAKABLE_VERSION = "1.3.0";
4370
4515
  var TIMELINE_FORMAT_VERSION = "1.0";
4371
4516
  var DEFAULT_SETTLE_PERIOD = 500;
4372
4517
  var DEFAULT_LOAD_TIMEOUT = 1e4;
@@ -5123,7 +5268,6 @@ function classifyModified(entry) {
5123
5268
  // src/runtime/baseline-storage.ts
5124
5269
  import { readFile, writeFile, mkdir, access } from "fs/promises";
5125
5270
  import { join, dirname } from "path";
5126
- var SPEAKABLE_VERSION2 = "1.3.0";
5127
5271
  function createBaselineStorage(baseDir) {
5128
5272
  function sanitize(name) {
5129
5273
  return name.toLowerCase().replace(/[/\\]/g, "-").replace(/[^a-z0-9._-]/g, "-");
@@ -5143,7 +5287,7 @@ function createBaselineStorage(baseDir) {
5143
5287
  const baselineFile = {
5144
5288
  baseline: {
5145
5289
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
5146
- speakableVersion: SPEAKABLE_VERSION2,
5290
+ speakableVersion: SPEAKABLE_VERSION,
5147
5291
  storybookUrl: meta?.storybookUrl,
5148
5292
  interactionSequence: timeline.interactionSequence
5149
5293
  },