automatica11y 0.3.3 → 0.4.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 (47) hide show
  1. package/README.md +25 -7
  2. package/package.json +5 -3
  3. package/skills/automatica11y-runner/SKILL.md +44 -13
  4. package/skills/automatica11y-runner/references/fixtures.md +38 -2
  5. package/src/commands/common.js +5 -2
  6. package/src/frameworks/index.js +42 -0
  7. package/src/frameworks/react.js +77 -0
  8. package/src/frameworks/vue.js +90 -0
  9. package/src/frameworks/wc.js +55 -0
  10. package/src/globals.d.ts +1 -0
  11. package/src/harness/bundle.js +7 -6
  12. package/src/harness/generate/dialects.js +64 -0
  13. package/src/harness/generate/index.js +12 -0
  14. package/src/harness/generate/jsx-recipes.js +224 -0
  15. package/src/harness/generate/jsx.js +76 -0
  16. package/src/harness/generate/kit.js +50 -0
  17. package/src/harness/generate/marking.js +64 -0
  18. package/src/harness/generate/probe.js +97 -0
  19. package/src/harness/generate/shared.js +12 -0
  20. package/src/harness/generate/wc-recipes.js +132 -0
  21. package/src/harness/npm-install.js +38 -7
  22. package/src/harness/settle.js +17 -0
  23. package/src/harness/storybook.js +1 -0
  24. package/src/harness/url.js +13 -3
  25. package/src/plan/classify.js +11 -4
  26. package/src/plan/mapping.js +10 -7
  27. package/src/plan/resolve-npm.js +15 -9
  28. package/src/plan/subpath.js +133 -0
  29. package/src/report/comparison.js +14 -9
  30. package/src/report/parts.js +36 -12
  31. package/src/run/audit-npm.js +118 -28
  32. package/src/run/generate-fixture.js +74 -0
  33. package/src/run/run-plan.js +19 -4
  34. package/src/run/summary.js +5 -0
  35. package/src/schema.js +30 -6
  36. package/src/tiers/computed/checks.js +44 -5
  37. package/src/tiers/computed/color.js +8 -0
  38. package/src/tiers/computed/index.js +2 -2
  39. package/src/tiers/computed/measure-kit.js +30 -10
  40. package/src/tiers/conditions/checks.js +283 -0
  41. package/src/tiers/conditions/index.js +30 -0
  42. package/src/tiers/conditions/kit.js +133 -0
  43. package/src/tiers/interactions/archetypes.js +111 -0
  44. package/src/tiers/interactions/helpers.js +56 -0
  45. package/src/tiers/interactions/index.js +16 -3
  46. package/src/harness/npm-react.js +0 -39
  47. package/src/harness/npm-wc.js +0 -30
package/src/globals.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  interface Window {
3
3
  __a11y: any;
4
4
  __a11yMeasure: any;
5
+ __a11yConditions: any;
5
6
  __vsr: any;
6
7
  __a11yClicks: number;
7
8
  __a11yLast: any;
@@ -22,10 +22,12 @@ export function unresolvedPackages(errors) {
22
22
  /**
23
23
  * Bundle fixture entries into one folder of browser-ready ES modules, plus one HTML shell per entry.
24
24
  * esbuild loads here and only here. It doesn't type-check, and fixtures don't need it to.
25
- * @param {{ entries: Record<string, string>, outdir: string, workDir: string, react?: boolean }} options
25
+ * @param {{ entries: Record<string, string>, outdir: string, workDir: string, framework?: import("../frameworks/index.js").Adapter | null }} options
26
+ * `framework` is the adapter whose bundle settings apply: which packages to keep to one copy, how JSX is turned into calls, and what to define.
26
27
  * @returns {Promise<Record<string, string>>} Entry name to page path, for example `{ dialog: "/dialog.html" }`.
27
28
  */
28
- export async function bundleEntries({ entries, outdir, workDir, react = false }) {
29
+ export async function bundleEntries({ entries, outdir, workDir, framework = null }) {
30
+ const settings = framework?.bundle(workDir) ?? { alias: {}, esbuild: { jsx: "automatic" } };
29
31
  const esbuild = await import("esbuild");
30
32
  mkdirSync(outdir, { recursive: true });
31
33
  try {
@@ -36,13 +38,12 @@ export async function bundleEntries({ entries, outdir, workDir, react = false })
36
38
  format: "esm",
37
39
  platform: "browser",
38
40
  target: "es2022",
39
- jsx: "automatic",
41
+ ...settings.esbuild,
40
42
  absWorkingDir: workDir,
41
43
  nodePaths: [join(workDir, "node_modules")],
42
- // One copy of React for the library and the fixture, or hooks break.
43
- alias: react ? { react: join(workDir, "node_modules", "react"), "react-dom": join(workDir, "node_modules", "react-dom") } : {},
44
+ alias: settings.alias,
44
45
  loader: ASSET_LOADERS,
45
- define: { "process.env.NODE_ENV": '"development"' },
46
+ define: { "process.env.NODE_ENV": '"development"', ...(settings.define ?? {}) },
46
47
  logLevel: "silent",
47
48
  });
48
49
  } catch (error) {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * What differs between JSX frameworks when a fixture is generated: how it holds state, how it starts the marking code
3
+ * and wraps the result, and how a prop is spelled. The recipes in jsx-recipes.js are shared and ask the dialect for these.
4
+ */
5
+ import { markingSource } from "./marking.js";
6
+
7
+ const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
8
+ const indentBy = (text, spaces) => text.split("\n").map((line) => (line ? " ".repeat(spaces) + line : line)).join("\n");
9
+
10
+ /** @typedef {{ id: string, extension: string, openProps: string[][], labelFor: string, declare: (name: string, init: string) => string, read: (name: string) => string, write: (name: string, value: string) => string, attr: (name: string, expr: string) => string, frame: (archetype: string, pkg: string, body: string, hooks?: string) => string }} Dialect */
11
+
12
+ /** @type {Dialect} */
13
+ export const reactDialect = {
14
+ id: "react",
15
+ extension: "jsx",
16
+ openProps: [["open", "onClose"], ["open", "onOpenChange"], ["isOpen", "onOpenChange"], ["isOpen", "onClose"], ["opened", "onClose"]],
17
+ labelFor: "htmlFor",
18
+ declare: (name, init) => `const [${name}, set${cap(name)}] = useState(${init});`,
19
+ read: (name) => name,
20
+ write: (name, value) => `set${cap(name)}(${value})`,
21
+ attr: (name, expr) => `${name}={${expr}}`,
22
+ frame(archetype, pkg, body, hooks = "") {
23
+ return `import * as Lib from ${JSON.stringify(pkg)};
24
+ import { useEffect, useState } from "react";
25
+ ${markingSource(archetype)}
26
+ export default function Fixture() {
27
+ useEffect(() => startMarking(), []);
28
+ ${hooks ? `${indentBy(hooks, 2)}\n` : ""} return (
29
+ ${indentBy(body, 4)}
30
+ );
31
+ }
32
+ `;
33
+ },
34
+ };
35
+
36
+ /** @type {Dialect} */
37
+ export const vueDialect = {
38
+ id: "vue",
39
+ extension: "jsx",
40
+ // Vue components usually take a model value and say they changed it with an update event, or take `open` and emit `update:open`.
41
+ openProps: [["open", "onUpdate:open"], ["modelValue", "onUpdate:modelValue"], ["visible", "onUpdate:visible"], ["show", "onUpdate:show"], ["open", "onClose"], ["isOpen", "onClose"]],
42
+ labelFor: "for",
43
+ declare: (name, init) => `const ${name} = ref(${init});`,
44
+ read: (name) => `${name}.value`,
45
+ write: (name, value) => `(${name}.value = ${value})`,
46
+ // A name with a colon (onUpdate:open) can't be written as a JSX attribute name, so it goes in through a spread.
47
+ attr: (name, expr) => (/^[\w-]+$/.test(name) ? `${name}={${expr}}` : `{...{ ${JSON.stringify(name)}: ${expr} }}`),
48
+ frame(archetype, pkg, body, hooks = "") {
49
+ return `import * as Lib from ${JSON.stringify(pkg)};
50
+ import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
51
+ ${markingSource(archetype)}
52
+ export default defineComponent({
53
+ setup() {
54
+ let stop = null;
55
+ onMounted(() => { stop = startMarking(); });
56
+ onBeforeUnmount(() => { if (stop) stop(); });
57
+ ${hooks ? `${indentBy(hooks, 4)}\n` : ""} return () => (
58
+ ${indentBy(body, 6)}
59
+ );
60
+ },
61
+ });
62
+ `;
63
+ },
64
+ };
@@ -0,0 +1,12 @@
1
+ import { adapterFor } from "../../frameworks/index.js";
2
+
3
+ export { ATTEMPT_LIMIT, GENERATABLE } from "./shared.js";
4
+
5
+ /**
6
+ * Build the candidate fixtures for one archetype of one package, most likely first, using the framework's adapter.
7
+ * @param {{ flavor: string, archetype: string, pkg: string, entry: { export?: string, tag?: string }, exports: Array<{ name: string, type: string, parts?: string[] }>, facts: Record<string, { attributes: string[], members: string[], slots: string[] }>, explicit?: boolean }} input
8
+ * @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
9
+ */
10
+ export function generateCandidates({ flavor, ...input }) {
11
+ return adapterFor(flavor).generate(input);
12
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Candidate fixtures for JSX frameworks (React and Vue), built only from the part names a package exports.
3
+ *
4
+ * Each builder takes a function that makes a fresh kit (see kit.js), picks the parts it needs by common names, and returns
5
+ * a candidate `{ id, summary, source, used }`, or null when the package doesn't have the parts. A candidate is a guess.
6
+ * It only counts once the probe has bundled it, loaded it, and seen the trigger and root behave (see probe.js).
7
+ *
8
+ * The recipes follow how compound components are usually put together (a root, a trigger, a content part, and a title,
9
+ * description, or close part), and how single components are usually switched on (an `open` prop and a close handler).
10
+ * None of them names a library. What differs between frameworks is only how a fixture holds state, starts its marking code,
11
+ * and spells a prop, so a dialect carries those three things (see dialects.js) and the recipes are shared.
12
+ */
13
+
14
+ const indent = (text, spaces) => text.split("\n").map((line) => (line ? " ".repeat(spaces) + line : line)).join("\n");
15
+ /** A JSX element. Short text stays on one line, and anything longer goes on its own lines. */
16
+ const tag = (ref, props, children) => {
17
+ if (!children) return `<${ref}${props} />`;
18
+ return !children.includes("\n") && children.length < 40 && !children.startsWith("<") ? `<${ref}${props}>${children}</${ref}>` : `<${ref}${props}>\n${indent(children, 2)}\n</${ref}>`;
19
+ };
20
+
21
+ /** Wrap a layer in a portal and a positioner when the package has them. */
22
+ function layered(kit, content) {
23
+ const positioner = kit.pick("positioner");
24
+ const positioned = positioner ? tag(positioner, "", content) : content;
25
+ const portal = kit.pick("portal");
26
+ return portal ? tag(portal, "", positioned) : positioned;
27
+ }
28
+
29
+ // ---- dialog ----
30
+
31
+ function dialogContent(kit) {
32
+ const title = kit.pick("title", "heading");
33
+ const description = kit.pick("description", "desc", "contenttext");
34
+ const close = kit.pick("close", "closebutton", "closetrigger", "dismiss");
35
+ return [
36
+ title ? tag(title, "", "Edit profile") : "<h2>Edit profile</h2>",
37
+ description ? tag(description, "", "Update your details.") : "<p>Update your details.</p>",
38
+ close ? tag(close, "", "Close") : '<button type="button">Close</button>',
39
+ ].join("\n");
40
+ }
41
+
42
+ function dialogCompound(makeKit, pkg, d) {
43
+ const kit = makeKit();
44
+ const root = kit.pick("root") ?? kit.self;
45
+ const trigger = kit.pick("trigger", "opener", "activator");
46
+ const content = kit.pick("content", "popup", "window", "panel", "surface");
47
+ if (!root || !trigger || !content) return null;
48
+ const overlay = kit.pick("overlay", "backdrop");
49
+ const layer = `${overlay ? `<${overlay} />\n` : ""}${tag(content, "", dialogContent(kit))}`;
50
+ const body = tag(root, "", `${tag(trigger, " data-a11y-trigger", "Open dialog")}\n${layered(kit, layer)}`);
51
+ return { id: "dialog-compound", summary: "a root, a trigger, and a content part that opens on its own", source: d.frame("dialog", pkg, body), used: kit.used() };
52
+ }
53
+
54
+ function dialogControlled([openProp, closeProp]) {
55
+ return (makeKit, pkg, d) => {
56
+ const kit = makeKit();
57
+ const root = kit.pick("root") ?? kit.self;
58
+ if (!root) return null;
59
+ const content = kit.pick("content", "panel", "popup", "window", "surface");
60
+ const inner = content ? tag(content, "", dialogContent(kit)) : dialogContent(kit);
61
+ const overlay = kit.pick("overlay", "backdrop");
62
+ const controls = ` ${d.attr(openProp, d.read("open"))} ${d.attr(closeProp, `(next) => ${d.write("open", "next === true")}`)}`;
63
+ const body = `<>
64
+ <button type="button" data-a11y-trigger onClick={() => ${d.write("open", "true")}}>Open dialog</button>
65
+ ${indent(tag(root, controls, `${overlay ? `<${overlay} />\n` : ""}${inner}`), 2)}
66
+ </>`;
67
+ return { id: `dialog-controlled-${openProp}-${closeProp}`.replace(/[^\w-]+/g, "-"), summary: `a root controlled with ${openProp} and ${closeProp}`, source: d.frame("dialog", pkg, body, ` ${d.declare("open", "false")}`), used: kit.used() };
68
+ };
69
+ }
70
+
71
+ // ---- menu ----
72
+
73
+ /** How a menu item is written: bare, with a value, or rendered as a div (some libraries render items as a template by default). */
74
+ const MENU_ITEMS = [
75
+ { id: "", summary: "", props: () => "" },
76
+ { id: "-values", summary: " that have values", props: (name) => ` value="${name}"` },
77
+ { id: "-as-div", summary: " rendered as divs", props: () => ' as="div"' },
78
+ ];
79
+
80
+ function menu(variant) {
81
+ return (makeKit, pkg, d) => {
82
+ const kit = makeKit();
83
+ const root = kit.pick("root") ?? kit.self;
84
+ const trigger = kit.pick("trigger", "button", "opener");
85
+ const content = kit.pick("content", "popup", "list", "items");
86
+ const item = kit.pick("item", "menuitem", "option");
87
+ if (!root || !trigger || !content || !item) return null;
88
+ const items = [tag(item, variant.props("edit"), "Edit"), tag(item, variant.props("delete"), "Delete")].join("\n");
89
+ const body = tag(root, "", `${tag(trigger, " data-a11y-trigger", "Open menu")}\n${layered(kit, tag(content, "", items))}`);
90
+ return { id: `menu-compound${variant.id}`, summary: `a root, a trigger, and a content part with items${variant.summary}`, source: d.frame("menu", pkg, body), used: kit.used() };
91
+ };
92
+ }
93
+
94
+ // ---- tooltip ----
95
+
96
+ function tooltipCompound(makeKit, pkg, d) {
97
+ const kit = makeKit();
98
+ const root = kit.pick("root") ?? kit.self;
99
+ const trigger = kit.pick("trigger");
100
+ const content = kit.pick("content", "popup", "bubble");
101
+ if (!root || !trigger || !content) return null;
102
+ const provider = kit.pick("provider");
103
+ const inner = tag(root, "", `${tag(trigger, " data-a11y-trigger", "Save")}\n${layered(kit, tag(content, "", "Saves your work."))}`);
104
+ const body = provider ? tag(provider, "", inner) : inner;
105
+ return { id: "tooltip-compound", summary: "a root, a trigger, and a content part", source: d.frame("tooltip", pkg, body), used: kit.used() };
106
+ }
107
+
108
+ const tooltipSingle = (prop) => (makeKit, pkg, d) => {
109
+ const kit = makeKit();
110
+ if (!kit.self) return null;
111
+ const body = `<${kit.self} ${prop}="Saves your work.">\n <button type="button" data-a11y-trigger>Save</button>\n</${kit.self}>`;
112
+ return { id: `tooltip-single-${prop}`, summary: `one component that wraps the trigger and takes ${prop}`, source: d.frame("tooltip", pkg, body), used: [kit.base] };
113
+ };
114
+
115
+ // ---- tabs ----
116
+
117
+ const TAB_VARIANTS = [
118
+ { id: "value", rootProps: ' defaultValue="one"', item: (v) => ` value="${v}"`, summary: "tabs matched to panels by value" },
119
+ { id: "index", rootProps: "", item: () => "", summary: "tabs matched to panels by position" },
120
+ { id: "default-index", rootProps: " defaultIndex={0}", item: () => "", summary: "tabs matched by position, starting at index 0" },
121
+ ];
122
+
123
+ const tabs = (variant) => (makeKit, pkg, d) => {
124
+ const kit = makeKit();
125
+ const root = kit.pick("root") ?? kit.self;
126
+ const tab = kit.pick("trigger", "tab");
127
+ const panel = kit.pick("content", "panel", "tabpanel");
128
+ if (!root || !tab || !panel) return null;
129
+ const list = kit.pick("list", "tablist", "tabs");
130
+ const tabsMarkup = `${tag(tab, `${variant.item("one")} data-a11y-trigger`, "One")}\n${tag(tab, variant.item("two"), "Two")}`;
131
+ const body = tag(root, variant.rootProps, `${list ? tag(list, "", tabsMarkup) : tabsMarkup}\n${tag(panel, variant.item("one"), "First panel")}\n${tag(panel, variant.item("two"), "Second panel")}`);
132
+ return { id: `tabs-${variant.id}`, summary: variant.summary, source: d.frame("tabs", pkg, body), used: kit.used() };
133
+ };
134
+
135
+ // ---- accordion ----
136
+
137
+ const ACCORDION_ROOTS = [["", "no root props"], [' type="single" collapsible', "single, collapsible"], [" collapsible", "collapsible"], [" multiple", "multiple"]];
138
+
139
+ const accordion = ([rootProps, label]) => (makeKit, pkg, d) => {
140
+ const kit = makeKit();
141
+ const root = kit.pick("root") ?? kit.self;
142
+ const item = kit.pick("item");
143
+ const trigger = kit.pick("trigger", "button", "summary");
144
+ const content = kit.pick("content", "panel", "itempanel", "body");
145
+ if (!root || !trigger || !content) return null;
146
+ const header = kit.pick("header", "itemheader", "heading");
147
+ const section = (name) => {
148
+ const button = tag(trigger, name === "one" ? " data-a11y-trigger" : "", `Section ${name}`);
149
+ return `${header ? tag(header, "", button) : button}\n${tag(content, "", "More information.")}`;
150
+ };
151
+ // Some libraries have no item part: a disclosure is just a root with a button and a panel.
152
+ if (!item) {
153
+ if (rootProps) return null;
154
+ return { id: "accordion-disclosure", summary: "a root with a button and a panel", source: d.frame("accordion", pkg, tag(root, "", section("one"))), used: kit.used() };
155
+ }
156
+ const one = (name, value) => tag(item, ` value="${value}"`, section(name));
157
+ const body = tag(root, rootProps, `${one("one", "one")}\n${one("two", "two")}`);
158
+ return { id: `accordion-${label.replace(/[^a-z]+/g, "-")}`, summary: `an accordion with ${label}`, source: d.frame("accordion", pkg, body), used: kit.used() };
159
+ };
160
+
161
+ // ---- combobox ----
162
+
163
+ const combobox = (itemProps) => (makeKit, pkg, d) => {
164
+ const kit = makeKit();
165
+ const root = kit.pick("root") ?? kit.self;
166
+ const input = kit.pick("input", "control");
167
+ const content = kit.pick("content", "list", "popup", "listbox");
168
+ const item = kit.pick("item", "option");
169
+ if (!root || !input || !content || !item) return null;
170
+ const items = [tag(item, itemProps ? ' value="apple"' : "", "Apple"), tag(item, itemProps ? ' value="banana"' : "", "Banana")].join("\n");
171
+ const body = tag(root, "", `${tag(input, ' data-a11y-trigger aria-label="Fruit"', "")}\n${layered(kit, tag(content, "", items))}`);
172
+ return { id: `combobox-compound${itemProps ? "-values" : ""}`, summary: `a root, an input, and a list of options${itemProps ? " that have values" : ""}`, source: d.frame("combobox", pkg, body), used: kit.used() };
173
+ };
174
+
175
+ // ---- form field ----
176
+
177
+ function fieldCompound(makeKit, pkg, d) {
178
+ const kit = makeKit();
179
+ const root = kit.pick("root", "field");
180
+ const label = kit.pick("label");
181
+ const input = kit.pick("input", "control");
182
+ if (!root || !label || !input) return null;
183
+ const body = tag(root, "", `${tag(label, "", "Name")}\n${tag(input, " data-a11y-trigger", "")}`);
184
+ return { id: "field-compound", summary: "a root, a label, and an input part", source: d.frame("form-field", pkg, body), used: kit.used() };
185
+ }
186
+
187
+ const fieldSingle = (id, summary, markup) => (makeKit, pkg, d) => {
188
+ const kit = makeKit();
189
+ if (!kit.self) return null;
190
+ return { id, summary, source: d.frame("form-field", pkg, markup(kit.self, d)), used: [kit.base] };
191
+ };
192
+
193
+ // ---- live region ----
194
+
195
+ const messageConditional = (makeKit, pkg, d) => {
196
+ const kit = makeKit();
197
+ if (!kit.self) return null;
198
+ const body = `<div>\n <button type="button" data-a11y-trigger onClick={() => ${d.write("on", "true")}}>Show message</button>\n <div>{${d.read("on")} && <${kit.self}>Saved.</${kit.self}>}</div>\n</div>`;
199
+ return { id: "message-mounted", summary: "a message that is mounted when the trigger is pressed", source: d.frame("live-region", pkg, body, ` ${d.declare("on", "false")}`), used: [kit.base] };
200
+ };
201
+
202
+ const messageControlled = (prop) => (makeKit, pkg, d) => {
203
+ const kit = makeKit();
204
+ if (!kit.self) return null;
205
+ const body = `<div>\n <button type="button" data-a11y-trigger onClick={() => ${d.write("on", "true")}}>Show message</button>\n <${kit.self} ${d.attr(prop, d.read("on"))}>Saved.</${kit.self}>\n</div>`;
206
+ return { id: `message-${prop}`, summary: `a message shown with its ${prop} prop`, source: d.frame("live-region", pkg, body, ` ${d.declare("on", "false")}`), used: [kit.base] };
207
+ };
208
+
209
+ /** The builders for each archetype, most likely first. */
210
+ export const buildRecipes = (d) => ({
211
+ dialog: [dialogCompound, ...d.openProps.map(dialogControlled)],
212
+ menu: MENU_ITEMS.map(menu),
213
+ tooltip: [tooltipCompound, ...["title", "label", "content", "text", "tip"].map(tooltipSingle)],
214
+ tabs: TAB_VARIANTS.map(tabs),
215
+ accordion: ACCORDION_ROOTS.map(accordion),
216
+ combobox: [combobox(true), combobox(false)],
217
+ "form-field": [
218
+ fieldCompound,
219
+ fieldSingle("field-wrapped-label", "an input inside a wrapping label", (self) => `<label>\n Name\n <${self} data-a11y-trigger />\n</label>`),
220
+ fieldSingle("field-label-prop", "an input that takes a label prop", (self) => `<${self} label="Name" data-a11y-trigger />`),
221
+ fieldSingle("field-label-for", "an input matched to a label by id", (self, d) => `<div>\n <label ${d.labelFor}="name-field">Name</label>\n <${self} id="name-field" data-a11y-trigger />\n</div>`),
222
+ ],
223
+ "live-region": [messageConditional, ...["open", "isOpen", "visible", "show"].map(messageControlled)],
224
+ });
@@ -0,0 +1,76 @@
1
+ import { ARCHETYPE_PATTERNS } from "../storybook.js";
2
+ import { score } from "../../plan/mapping.js";
3
+ import { buildKit } from "./kit.js";
4
+ import { buildRecipes } from "./jsx-recipes.js";
5
+ import { ATTEMPT_LIMIT, GENERATABLE, nameSaysSo } from "./shared.js";
6
+
7
+ /** The names a part of a compound component ends with. A name that ends in one belongs to a family that shares the rest. */
8
+ const PART_SUFFIX = /(Root|Trigger|Portal|Overlay|Backdrop|Positioner|Content|Popup|Panel|Title|Description|Close|Header|Item|List|Input|Label|Control|Provider)$/;
9
+
10
+ /**
11
+ * The prefix a family of flat exports shares. `DialogClose` and `DialogRoot` belong to `Dialog`, even when no export is
12
+ * named `Dialog`. It counts only when at least two exports start with it.
13
+ */
14
+ export function inferBase(name, exports) {
15
+ if (!name) return null;
16
+ const base = name.replace(PART_SUFFIX, "");
17
+ if (!base || base === name) return null;
18
+ return exports.filter((e) => e.name.startsWith(base) && e.name !== base).length >= 2 ? base : null;
19
+ }
20
+
21
+ const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[-_]/g, " ");
22
+
23
+ /**
24
+ * The component families in a package that look like this archetype, best first. A family is a base name and the parts that share
25
+ * it (`DropdownMenuRoot`, `DropdownMenuItem`), a namespaced export with parts, or a single export. A family ranks by how well its
26
+ * base fits the archetype's own name, then by how many parts it has. At most three are tried, so a library with a context menu,
27
+ * a dropdown menu, and a menubar gets the plainest ones first.
28
+ */
29
+ export function familyBases(archetype, exports) {
30
+ const families = new Map();
31
+ for (const e of exports) {
32
+ if (!/^[A-Z]/.test(e.name) || !ARCHETYPE_PATTERNS[archetype].test(words(e.name))) continue;
33
+ const base = inferBase(e.name, exports) ?? e.name;
34
+ if (families.has(base)) continue;
35
+ const size = exports.filter((other) => other.name !== base && other.name.startsWith(base)).length + (exports.find((other) => other.name === base)?.parts?.length ?? 0);
36
+ families.set(base, { base, fit: score(archetype, base), size });
37
+ }
38
+ return [...families.values()].sort((a, b) => b.fit - a.fit || b.size - a.size || a.base.localeCompare(b.base)).slice(0, 3).map((f) => f.base);
39
+ }
40
+
41
+ /**
42
+ * Candidate fixtures for a JSX framework: the recipes, run over the parts of the component the mapping found,
43
+ * then over the package itself when its own name says it is this archetype.
44
+ * @param {import("./dialects.js").Dialect} dialect
45
+ * @param {{ archetype: string, pkg: string, entry: { export?: string }, exports: Array<{ name: string, type: string, parts?: string[] }>, explicit?: boolean }} input
46
+ * `explicit` means a person named the export in a mapping, so only that component is tried.
47
+ * @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
48
+ */
49
+ export function generateJsx(dialect, { archetype, pkg, entry, exports, explicit = false }) {
50
+ if (!GENERATABLE.has(archetype)) return { candidates: [], reason: `Nothing is generated for the ${archetype} archetype.` };
51
+ const recipes = buildRecipes(dialect)[archetype] ?? [];
52
+ const bases = [];
53
+ if (explicit && entry.export) {
54
+ bases.push(inferBase(entry.export, exports) ?? entry.export);
55
+ } else {
56
+ bases.push(...familyBases(archetype, exports));
57
+ if (bases.length === 0 && entry.export) bases.push(inferBase(entry.export, exports) ?? entry.export);
58
+ }
59
+ if (nameSaysSo(archetype, pkg)) bases.push(null);
60
+ const candidates = [];
61
+ const seen = new Set();
62
+ for (const base of [...new Set(bases)]) {
63
+ const makeKit = () => buildKit(exports, base);
64
+ for (const recipe of recipes) {
65
+ const candidate = recipe(makeKit, pkg, dialect);
66
+ if (candidate && !seen.has(candidate.source)) {
67
+ seen.add(candidate.source);
68
+ candidates.push(candidate);
69
+ }
70
+ }
71
+ }
72
+ if (candidates.length === 0) {
73
+ return { candidates: [], reason: `The package's exports don't have the parts a ${archetype} recipe needs${entry.export ? ` (looked at ${entry.export} and the exports that start with it)` : ""}.` };
74
+ }
75
+ return { candidates: candidates.slice(0, ATTEMPT_LIMIT), reason: null };
76
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Find the parts of a compound component from the names a package exports.
3
+ *
4
+ * Two shapes are common. A namespaced export carries its parts as properties (`Dialog.Root`, `Dialog.Trigger`).
5
+ * A flat set of exports shares a prefix (`DialogRoot`, `DialogTrigger`, or `Dialog` plus `DialogContent`).
6
+ * A package that is one component's parts, such as a `Root` and a `Trigger` with no `Dialog` in front, works too.
7
+ * Parts are found by name only. Nothing here knows any one library.
8
+ */
9
+
10
+ /**
11
+ * @param {Array<{ name: string, type: string, parts?: string[] }>} exports
12
+ * @param {string | null} base The export that names the component, or null when the package itself is the component.
13
+ */
14
+ export function buildKit(exports, base) {
15
+ /** @type {Map<string, string>} lower-case part name to the expression that reaches it */
16
+ const parts = new Map();
17
+ const baseInfo = base ? exports.find((e) => e.name === base) : null;
18
+ if (base) {
19
+ for (const part of baseInfo?.parts ?? []) parts.set(part.toLowerCase(), `Lib.${base}.${part}`);
20
+ for (const e of exports) {
21
+ if (e.name === base || !e.name.startsWith(base)) continue;
22
+ const rest = e.name.slice(base.length);
23
+ if (/^[A-Z]/.test(rest) && !parts.has(rest.toLowerCase())) parts.set(rest.toLowerCase(), `Lib.${e.name}`);
24
+ }
25
+ } else {
26
+ for (const e of exports) if (/^[A-Z]/.test(e.name) && !parts.has(e.name.toLowerCase())) parts.set(e.name.toLowerCase(), `Lib.${e.name}`);
27
+ }
28
+ const used = new Set();
29
+ return {
30
+ base,
31
+ /** The component itself (`Lib.Dialog`), or null for a package with no single base. */
32
+ self: base && baseInfo ? `Lib.${base}` : null,
33
+ /** True when any of these part names exists. */
34
+ has: (...names) => names.some((name) => parts.has(name)),
35
+ /** The first part that exists, as a JSX tag. Records what was used. */
36
+ pick(...names) {
37
+ for (const name of names) {
38
+ const ref = parts.get(name);
39
+ if (ref) {
40
+ used.add(ref.replace(/^Lib\./, ""));
41
+ return ref;
42
+ }
43
+ }
44
+ return null;
45
+ },
46
+ /** What the candidates used, for the report. */
47
+ used: () => [...used],
48
+ names: () => [...parts.keys()],
49
+ };
50
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The code a generated fixture carries to mark its trigger and root.
3
+ *
4
+ * The harness needs exactly one element with `data-a11y-trigger` and, once the component is showing, one with
5
+ * `data-a11y-root` on the element that carries the role. A generated fixture can't know how a library forwards props,
6
+ * so it doesn't rely on that. It passes the trigger attribute where it can, and this code then marks whatever is missing,
7
+ * by role, every 50 milliseconds, reaching into open shadow roots. The probe that follows checks the result.
8
+ */
9
+
10
+ /** What to look for, per archetype. `root` is a selector, or "trigger", "parent", or "controls". */
11
+ export const MARKING = {
12
+ dialog: { root: "dialog, [role=dialog], [role=alertdialog]", trigger: "[aria-haspopup], [aria-expanded], button, [role=button]" },
13
+ menu: { root: "[role=menu]", trigger: "[aria-haspopup], [aria-expanded], button, [role=button]" },
14
+ tooltip: { root: "[role=tooltip]", trigger: "button, [role=button], a[href], input" },
15
+ tabs: { root: "trigger", trigger: "[role=tab]" },
16
+ accordion: { root: "controls", trigger: "[aria-expanded], summary, button" },
17
+ combobox: { root: "[role=listbox]", trigger: "input[role=combobox], [role=combobox], input" },
18
+ "form-field": { root: "parent", trigger: "input:not([type=hidden]), textarea, select, [role=textbox], [role=combobox], [role=checkbox], [role=switch]" },
19
+ "live-region": { root: "[role=alert], [role=status], [role=log], [aria-live=polite], [aria-live=assertive]", trigger: "button, [role=button]" },
20
+ };
21
+
22
+ /** The roles each archetype's root may carry, so the probe can tell a right mark from a wrong one. */
23
+ export const ROOT_ROLES = {
24
+ dialog: ["dialog", "alertdialog"],
25
+ menu: ["menu", "menubar"],
26
+ tooltip: ["tooltip"],
27
+ tabs: ["tab"],
28
+ combobox: ["listbox", "combobox", "grid", "tree"],
29
+ "live-region": ["alert", "status", "log"],
30
+ };
31
+
32
+ /** The source that goes at the top of a generated fixture. It defines `startMarking()`, which returns a function that stops it. */
33
+ export function markingSource(archetype) {
34
+ const config = MARKING[archetype];
35
+ return `function a11yDeepAll(selector, root) {
36
+ const scope = root || document;
37
+ const found = [...scope.querySelectorAll(selector)];
38
+ for (const el of scope.querySelectorAll("*")) if (el.shadowRoot) found.push(...a11yDeepAll(selector, el.shadowRoot));
39
+ return found;
40
+ }
41
+ function a11yMark() {
42
+ const config = ${JSON.stringify(config)};
43
+ if (a11yDeepAll("[data-a11y-trigger]").length === 0) {
44
+ const fallback = a11yDeepAll(config.trigger).find((el) => el.getClientRects().length > 0);
45
+ if (fallback) fallback.setAttribute("data-a11y-trigger", "");
46
+ }
47
+ const trigger = a11yDeepAll("[data-a11y-trigger]")[0];
48
+ if (a11yDeepAll("[data-a11y-root]").length > 0) return;
49
+ let root = null;
50
+ if (config.root === "trigger") root = trigger;
51
+ else if (config.root === "parent") root = trigger && trigger.parentElement;
52
+ else if (config.root === "controls") {
53
+ const id = trigger && trigger.getAttribute("aria-controls");
54
+ root = (id && document.getElementById(id)) || a11yDeepAll("[role=region]")[0];
55
+ } else root = a11yDeepAll(config.root)[0];
56
+ if (root) root.setAttribute("data-a11y-root", "");
57
+ }
58
+ function startMarking() {
59
+ a11yMark();
60
+ const timer = setInterval(a11yMark, 50);
61
+ return () => clearInterval(timer);
62
+ }
63
+ `;
64
+ }
@@ -0,0 +1,97 @@
1
+ import { installHelpers } from "../../tiers/interactions/helpers.js";
2
+ import { openPage } from "../url.js";
3
+ import { ROOT_ROLES } from "./marking.js";
4
+
5
+ /** Archetypes whose root appears after the trigger is activated, as the audit expects. */
6
+ const OPENS = new Set(["dialog", "menu", "tooltip", "accordion", "combobox", "live-region"]);
7
+
8
+ const FOCUSABLE_FIELD = "input:not([type=hidden]), textarea, select, [contenteditable=true], [role=textbox], [role=combobox], [role=checkbox], [role=switch], [role=radio], [role=slider], [role=spinbutton]";
9
+
10
+ /**
11
+ * Check that a generated fixture does what the audit needs, before the audit trusts it.
12
+ * It loads the page, then checks that exactly one element is marked as the trigger, that nothing logged an error,
13
+ * that activating the trigger shows a root, and that the root carries a role that fits the archetype.
14
+ * A fixture that fails any of these isn't used, and the reason says which.
15
+ * @param {import("playwright-core").Browser} browser
16
+ * @param {string} url
17
+ * @param {string} archetype
18
+ * @returns {Promise<{ ok: boolean, reason: string | null }>}
19
+ */
20
+ export async function probeFixture(browser, url, archetype) {
21
+ const errors = [];
22
+ /** @type {Awaited<ReturnType<typeof openPage>> | null} */
23
+ let opened = null;
24
+ try {
25
+ opened = await openPage(browser, url, {
26
+ waitUntil: "load",
27
+ beforeGoto: async (page) => {
28
+ page.on("pageerror", (error) => errors.push(error.message.split("\n")[0]));
29
+ page.on("console", (message) => {
30
+ if (message.type() === "error" && !/favicon|Failed to load resource/i.test(message.text())) errors.push(message.text().split("\n")[0]);
31
+ });
32
+ await page.addInitScript(installHelpers);
33
+ },
34
+ });
35
+ const page = opened.page;
36
+ try {
37
+ await page.waitForFunction(() => window.__a11y?.queryAllDeep("[data-a11y-trigger]").length > 0, undefined, { timeout: 4000 });
38
+ } catch {
39
+ return { ok: false, reason: errors.length ? `it logged an error and rendered nothing to mark as the trigger: ${errors[0]}` : "no element could be marked as the trigger" };
40
+ }
41
+ await page.waitForTimeout(150);
42
+ const count = await page.evaluate(() => window.__a11y.queryAllDeep("[data-a11y-trigger]").length);
43
+ if (count !== 1) return { ok: false, reason: `${count} elements were marked as the trigger` };
44
+ if (errors.length) return { ok: false, reason: `it logged an error: ${errors[0]}` };
45
+
46
+ if (archetype === "form-field") {
47
+ const field = await page.evaluate((selector) => window.__a11y.queryDeep("[data-a11y-trigger]")?.matches(selector) ?? false, FOCUSABLE_FIELD);
48
+ return field ? { ok: true, reason: null } : { ok: false, reason: "the element marked as the trigger isn't a field a person can type in" };
49
+ }
50
+ if (archetype === "tabs") {
51
+ const role = await page.evaluate(() => window.__a11y.queryDeep("[data-a11y-trigger]")?.getAttribute("role") ?? null);
52
+ return role === "tab" ? { ok: true, reason: null } : { ok: false, reason: `the element marked as the trigger has ${role ? `role ${role}` : "no role"}, not tab` };
53
+ }
54
+ if (!OPENS.has(archetype)) return { ok: true, reason: null };
55
+
56
+ const trigger = page.locator("[data-a11y-trigger]").first();
57
+ try {
58
+ if (archetype === "tooltip") await trigger.focus();
59
+ else await trigger.click({ timeout: 3000 });
60
+ } catch (error) {
61
+ return { ok: false, reason: `the trigger couldn't be activated: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}` };
62
+ }
63
+ try {
64
+ await page.waitForFunction(
65
+ ({ live, needsRoot }) => {
66
+ const state = window.__a11y.live();
67
+ if (live) return state.present && state.visible && Boolean(state.text || state.named);
68
+ // The trigger can say it's expanded an instant before the root is marked, so a role-carrying root has to be there itself.
69
+ if (needsRoot) return state.present && state.visible;
70
+ const trigger = window.__a11y.queryDeep("[data-a11y-trigger]");
71
+ return (state.present && state.visible) || trigger?.getAttribute("aria-expanded") === "true";
72
+ },
73
+ { live: archetype === "live-region", needsRoot: Boolean(ROOT_ROLES[archetype]) },
74
+ { timeout: 4000 },
75
+ );
76
+ } catch {
77
+ return { ok: false, reason: `activating the trigger showed no ${archetype === "live-region" ? "message" : "element marked as the root"}${ROOT_ROLES[archetype] ? ` (looked for an element with role ${ROOT_ROLES[archetype].join(", ")})` : ""}, which can also mean the library doesn't set that role` };
78
+ }
79
+ const roles = ROOT_ROLES[archetype];
80
+ if (roles) {
81
+ const fits = await page.evaluate((allowed) => {
82
+ const root = window.__a11y.queryDeep("[data-a11y-root]");
83
+ if (!root) return null;
84
+ const role = root.getAttribute("role");
85
+ return { role, fits: Boolean(role && allowed.includes(role)) || root.localName === "dialog" || root.hasAttribute("aria-live") };
86
+ }, roles);
87
+ if (!fits) return { ok: false, reason: "the root wasn't marked" };
88
+ if (!fits.fits) return { ok: false, reason: `the element marked as the root has ${fits.role ? `role ${fits.role}` : "no role"}, not ${roles.join(" or ")}` };
89
+ }
90
+ if (errors.length) return { ok: false, reason: `it logged an error when the trigger was activated: ${errors[0]}` };
91
+ return { ok: true, reason: null };
92
+ } catch (error) {
93
+ return { ok: false, reason: error instanceof Error ? error.message.split("\n")[0] : String(error) };
94
+ } finally {
95
+ await opened?.close();
96
+ }
97
+ }
@@ -0,0 +1,12 @@
1
+ import { ARCHETYPE_PATTERNS } from "../storybook.js";
2
+
3
+ /** Archetypes a fixture can be generated for. Buttons and links use templates. A chart has nothing to wire. */
4
+ export const GENERATABLE = new Set(["dialog", "menu", "tooltip", "tabs", "accordion", "combobox", "form-field", "live-region"]);
5
+
6
+ /** The most candidates probed for one archetype, so a package that matches nothing doesn't cost minutes. */
7
+ export const ATTEMPT_LIMIT = 8;
8
+
9
+ /** Does the package's own name say it is this archetype? `@scope/react-dialog` is a dialog. */
10
+ export function nameSaysSo(archetype, pkg) {
11
+ return ARCHETYPE_PATTERNS[archetype].test(pkg.replace(/[^a-z0-9]+/gi, " "));
12
+ }