automatica11y 0.4.1 → 0.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 (47) hide show
  1. package/README.md +37 -130
  2. package/package.json +30 -2
  3. package/skills/automatica11y-runner/SKILL.md +16 -3
  4. package/skills/automatica11y-runner/references/fixtures.md +24 -0
  5. package/src/cli.js +1 -0
  6. package/src/commands/common.js +14 -11
  7. package/src/frameworks/angular-errors.js +27 -0
  8. package/src/frameworks/angular-selectors.js +36 -0
  9. package/src/frameworks/angular.js +162 -0
  10. package/src/frameworks/errors.js +12 -0
  11. package/src/frameworks/html.js +163 -0
  12. package/src/frameworks/index.js +13 -7
  13. package/src/frameworks/react.js +8 -4
  14. package/src/frameworks/svelte-errors.js +30 -0
  15. package/src/frameworks/svelte.js +162 -0
  16. package/src/frameworks/vue.js +2 -1
  17. package/src/globals.d.ts +123 -6
  18. package/src/harness/bundle.js +2 -1
  19. package/src/harness/generate/angular-recipes.js +360 -0
  20. package/src/harness/generate/dialects.js +35 -1
  21. package/src/harness/generate/jsx-recipes.js +4 -6
  22. package/src/harness/generate/probe.js +16 -5
  23. package/src/harness/npm-install.js +47 -11
  24. package/src/harness/shadow.js +1 -1
  25. package/src/harness/storybook.js +18 -5
  26. package/src/plan/build-plan.js +1 -0
  27. package/src/plan/classify.js +39 -11
  28. package/src/plan/mapping.js +8 -6
  29. package/src/plan/resolve-npm.js +49 -18
  30. package/src/plan/subpath.js +17 -0
  31. package/src/report/comparison.js +4 -1
  32. package/src/report/index.js +1 -1
  33. package/src/report/parts.js +13 -1
  34. package/src/report/single.js +1 -1
  35. package/src/run/audit-npm.js +95 -27
  36. package/src/run/fail-check.js +10 -2
  37. package/src/run/generate-fixture.js +7 -5
  38. package/src/run/run-plan.js +10 -8
  39. package/src/run/summary.js +1 -1
  40. package/src/schema.js +10 -2
  41. package/src/tiers/computed/checks.js +3 -1
  42. package/src/tiers/conditions/kit.js +3 -3
  43. package/src/tiers/interactions/archetypes.js +6 -2
  44. package/src/tiers/interactions/focus-indicator.js +4 -2
  45. package/src/tiers/interactions/helpers.js +2 -1
  46. package/src/tiers/rules/ibm.js +2 -2
  47. package/src/tiers/rules/index.js +1 -1
package/src/globals.d.ts CHANGED
@@ -1,12 +1,129 @@
1
1
  /** Properties our init scripts and helpers put on the page's window. They only exist inside the browser. */
2
+ type A11yColor = number[];
3
+ type A11yBox = { x: number; y: number; width: number; height: number };
4
+ type A11yTextPart = { key: number; text: string; color: A11yColor | null; size: number; weight: string; backdrop: A11yColor | null; undetermined: string | null };
5
+ type A11yFocusPart = {
6
+ outlineStyle: string;
7
+ outlineWidth: string;
8
+ outlineColor: string;
9
+ boxShadow: string;
10
+ borderTopStyle: string;
11
+ borderTopColor: string;
12
+ borderTopWidth: string;
13
+ backgroundColor: string;
14
+ color: string;
15
+ textDecorationLine: string;
16
+ rendered: boolean;
17
+ };
18
+ type A11yFocusSnapshot = { parts: Record<string, A11yFocusPart>; box: A11yBox };
19
+
20
+ interface A11yHelpers {
21
+ uid(node: Node | null): number | null;
22
+ queryDeep(selector: string): Element | null;
23
+ queryAllDeep(selector: string): Element[];
24
+ deepActive(): Element | null;
25
+ within(root: Node | null, node: Node | null): boolean;
26
+ visible(element: Element | null): boolean;
27
+ snapshot(): {
28
+ clicks: number;
29
+ triggerTag: string | null;
30
+ triggerRole: string | null;
31
+ expanded: string | null;
32
+ rootExists: boolean;
33
+ rootVisible: boolean;
34
+ rootModal: boolean;
35
+ activeIsTrigger: boolean;
36
+ activeInRoot: boolean;
37
+ activeUid: number | null;
38
+ activeTag: string | null;
39
+ activeText: string;
40
+ activeRole: string | null;
41
+ bodyActive: boolean;
42
+ };
43
+ items(selector: string): Array<{ uid: number | null; text: string; active: boolean; selected: boolean; hasSelected: boolean; visible: boolean }>;
44
+ live(): {
45
+ ancestorUids: Array<number | null>;
46
+ present: boolean;
47
+ visible: boolean;
48
+ text: string;
49
+ named: boolean;
50
+ region: { uid: number | null; role: string | null; ariaLive: string | null; politeness: string; isMessage: boolean } | null;
51
+ regionUids: Array<number | null>;
52
+ };
53
+ focusDismiss(): { found: false } | { found: true; tag: string; label: string };
54
+ nameSources(): string[] | null;
55
+ errorInfo(): { invalid: boolean; ariaInvalid: boolean; linked: string[]; live: string[] } | null;
56
+ focusSnapshot(): A11yFocusSnapshot | null;
57
+ remember(): void;
58
+ lastSnapshot(): A11yFocusSnapshot | null;
59
+ inputValue(): string | null;
60
+ }
61
+
62
+ interface A11yMeasure {
63
+ rgba(css: string): A11yColor | null;
64
+ text(scope?: string): A11yTextPart[] | null;
65
+ boundary(): {
66
+ outside: A11yColor | null;
67
+ undetermined: string | null;
68
+ parts: Array<{ kind: string; color: A11yColor; width?: number }>;
69
+ graphics: Array<{ kind: string; color: A11yColor }>;
70
+ hasText: boolean;
71
+ inputLike: boolean;
72
+ box: A11yBox;
73
+ } | null;
74
+ focusStyles(atRest: boolean): {
75
+ outside: A11yColor | null;
76
+ inside: A11yColor | null;
77
+ undetermined: string | null;
78
+ outline: { width: number; offset: number; color: A11yColor | null } | null;
79
+ shadows: Array<{ inset: boolean; x: number; y: number; blur: number; spread: number; color: A11yColor | null }>;
80
+ border: { width: number; color: A11yColor } | null;
81
+ box: A11yBox;
82
+ } | null;
83
+ compareShots(before: string, after: string): Promise<{ changed: number; strong: number; max: number; width: number; height: number }>;
84
+ }
85
+
86
+ interface A11yConditions {
87
+ describe(element: Element | null): string;
88
+ animations(): Array<{ kind: string; name: string; target: string; duration: number | null; iterations: number | "infinite"; props: string[]; moving: boolean }>;
89
+ overflow(rootSelector?: string): {
90
+ viewportWidth: number;
91
+ scrollWidth: number;
92
+ offenders: Array<{ element: string; right: number; width: number; left: number }>;
93
+ offenderCount: number;
94
+ root: { element: string; left: number; right: number } | null;
95
+ };
96
+ clipping(): Array<{ visuallyHidden: boolean; element: string; hidesX: boolean; hidesY: boolean; overX: number; overY: number; text: string }>;
97
+ applySpacing(): void;
98
+ translucentSurfaces(): Array<{ element: string; background: string; backdropFilter: string }>;
99
+ forcedColorOptOuts(): string[];
100
+ appearance(): { colorScheme: string; background: string; color: string };
101
+ }
102
+
103
+ interface PackageExport {
104
+ name: string;
105
+ type: string;
106
+ parts: string[];
107
+ }
108
+
109
+ type AngularExportInfo =
110
+ | { kind: "component" | "directive"; selectors: Array<Array<string | number>>; inputs: string[]; outputs: string[]; exportAs: string[]; standalone: boolean; moduleName?: string | null }
111
+ | { kind: "module"; declares: string[]; exports: string[] }
112
+ | { kind: "service"; methods: string[] }
113
+ | { kind: "error"; message: string };
114
+
115
+ interface AngularPackageExport extends PackageExport {
116
+ angular: AngularExportInfo;
117
+ }
118
+
2
119
  interface Window {
3
- __a11y: any;
4
- __a11yMeasure: any;
5
- __a11yConditions: any;
6
- __vsr: any;
120
+ __a11y: A11yHelpers;
121
+ __a11yMeasure: A11yMeasure;
122
+ __a11yConditions: A11yConditions;
123
+ __vsr: Pick<typeof import("@guidepup/virtual-screen-reader"), "Virtual" | "virtual">;
7
124
  __a11yClicks: number;
8
- __a11yLast: any;
9
- __a11yExports?: any;
125
+ __a11yLast: Element | null;
126
+ __a11yExports?: Array<PackageExport | AngularPackageExport>;
10
127
  __a11yDefined?: string[];
11
128
  __a11yClosedShadowHosts?: string[];
12
129
  }
@@ -42,7 +42,8 @@ export async function bundleEntries({ entries, outdir, workDir, framework = null
42
42
  absWorkingDir: workDir,
43
43
  nodePaths: [join(workDir, "node_modules")],
44
44
  alias: settings.alias,
45
- loader: ASSET_LOADERS,
45
+ plugins: settings.plugins ?? [],
46
+ loader: { ...ASSET_LOADERS, ...(settings.esbuild.loader ?? {}) },
46
47
  define: { "process.env.NODE_ENV": '"development"', ...(settings.define ?? {}) },
47
48
  logLevel: "silent",
48
49
  });
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Candidate Angular fixtures, built from what discovery read off each exported class: its kind, selectors, inputs, outputs,
3
+ * `exportAs` names, and a service's methods. A selector says which element and attribute turn a class on, an input named
4
+ * after a selector attribute (`matMenuTriggerFor`) points at another class, and `exportAs` says how a template names it.
5
+ * Nothing here knows any one library. A candidate is a guess until the probe has seen it behave (see probe.js).
6
+ */
7
+ import { markupFor } from "../../frameworks/angular-selectors.js";
8
+ import { score } from "../../plan/mapping.js";
9
+ import { ARCHETYPE_PATTERNS } from "../storybook.js";
10
+ import { markingSource } from "./marking.js";
11
+ import { ATTEMPT_LIMIT, GENERATABLE } from "./shared.js";
12
+
13
+ const words = (text) => String(text).replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z])([A-Z][a-z])/g, "$1 $2").replace(/[-_[\]]/g, " ");
14
+
15
+ /** The shape of one record discovery wrote for a class. */
16
+ /** @typedef {{ name: string, angular: { kind: string, selectors?: unknown[][], inputs?: string[], outputs?: string[], exportAs?: string[], standalone?: boolean, moduleName?: string | null, methods?: string[] } }} Part */
17
+
18
+ const selectorText = (part) => (part.angular.selectors ?? []).flat().filter((x) => typeof x === "string").join(" ");
19
+
20
+ /** Does the class's name or selector say it belongs to this archetype? */
21
+ const fits = (archetype, part) => ARCHETYPE_PATTERNS[archetype].test(words(`${part.name} ${selectorText(part)}`));
22
+
23
+ /** The classes of a package that look like this archetype, plain Angular classes only, best fit first. */
24
+ function pool(archetype, exports) {
25
+ return exports
26
+ .filter((e) => e.angular && ["component", "directive", "service"].includes(e.angular.kind) && fits(archetype, e))
27
+ .sort((a, b) => score(archetype, b.name) - score(archetype, a.name) || a.name.localeCompare(b.name));
28
+ }
29
+
30
+ /** Can a template use this class? A standalone one is imported by name, and another is imported through the module that declares it. */
31
+ const importName = (part) => (part.angular.kind === "service" || part.angular.standalone ? part.name : part.angular.moduleName ?? null);
32
+
33
+ const usable = (...parts) => parts.every((part) => part && importName(part));
34
+
35
+ /**
36
+ * One element that turns a class on, as markup. `extra` holds attributes the recipe adds, and `bind` turns an attribute that is also
37
+ * an input into a binding (`[matMenuTriggerFor]="m"`). `values` gives a text value to an attribute or input of that name.
38
+ * @param {Part} part
39
+ * @param {{ prefer?: string, fallback?: string, extra?: string[], bind?: Record<string, string>, values?: Record<string, string>, inner?: string }} [options]
40
+ * @returns {string | null}
41
+ */
42
+ function element(part, { prefer, fallback = "div", extra = [], bind = {}, values = {}, inner = "" } = {}) {
43
+ // A selector that names its own element (`mat-tab`) can't be put on the preferred one, so it keeps its element.
44
+ const markup = markupFor(part.angular.selectors, { prefer, fallback }) ?? markupFor(part.angular.selectors, { fallback });
45
+ if (!markup) return null;
46
+ const attrs = markup.attrs.map(([name, value]) => {
47
+ if (name in bind) return `[${name}]="${bind[name]}"`;
48
+ if (name in values) return `${name}="${values[name]}"`;
49
+ return value ? `${name}="${value}"` : name;
50
+ });
51
+ for (const [name, value] of Object.entries(bind)) if (!markup.attrs.some(([attr]) => attr === name)) attrs.push(`[${name}]="${value}"`);
52
+ for (const [name, value] of Object.entries(values)) if (!markup.attrs.some(([attr]) => attr === name)) attrs.push(`${name}="${value}"`);
53
+ const open = [markup.tag, ...extra, ...attrs].join(" ");
54
+ return VOID.has(markup.tag) ? `<${open}>` : `<${open}>${inner}</${markup.tag}>`;
55
+ }
56
+
57
+ const VOID = new Set(["input", "img", "br", "hr", "area", "base", "col", "embed", "link", "meta", "source", "track", "wbr"]);
58
+
59
+ const exportRef = (part) => part.angular.exportAs?.[0] ?? null;
60
+
61
+ /** The classes that can point at another (`[matMenuTriggerFor]`), with the input that does it, matched to classes that have an `exportAs`. */
62
+ function linked(archetype, exports, targetPattern) {
63
+ const all = pool(archetype, exports);
64
+ // The class that is pointed at needn't have the archetype's own name (a listbox for a combobox), so a name pattern widens it.
65
+ const targets = [...new Set([...all, ...exports.filter((e) => e.angular && targetPattern.test(e.name))])].filter((e) => e.angular?.kind !== "service");
66
+ const pairs = [];
67
+ for (const pointer of all) {
68
+ const inputs = pointer.angular.inputs ?? [];
69
+ const attrs = new Set((pointer.angular.selectors ?? []).flat().filter((x) => typeof x === "string"));
70
+ const input = inputs.find((name) => attrs.has(name) && /For$|^for$|Trigger|Target|Menu|Panel|Content|Overlay/.test(name)) ?? inputs.find((name) => /For$/.test(name)) ?? inputs.find((name) => name === archetype || /^(menu|panel|popup|listbox|content|target|overlay|origin)$/.test(name)) ?? inputs.find((name) => attrs.has(name));
71
+ if (!input) continue;
72
+ // A library names its parts alike (`MatMenuTrigger`, `MatMenu`), so a target that starts the same way comes first and the rest are left out.
73
+ const prefix = pointer.name.match(/^[A-Z][a-z0-9]*/)?.[0] ?? "";
74
+ const near = targets.filter((target) => target.name.startsWith(prefix));
75
+ // The plainest name first: `Menu` before `MenuBar`.
76
+ for (const target of (near.length ? near : targets).slice().sort((a, b) => a.name.length - b.name.length)) {
77
+ if (target !== pointer && exportRef(target) && usable(pointer, target)) pairs.push({ pointer, target, input });
78
+ }
79
+ }
80
+ return pairs;
81
+ }
82
+
83
+ const nameMatches = (parts, pattern) => parts.filter((part) => pattern.test(part.name));
84
+
85
+ /**
86
+ * The first text-like input of a class, such as `label` or `title`, so a recipe can fill it in. A directive that carries its
87
+ * value on its own attribute (`brnTabsTrigger="one"`) counts too.
88
+ */
89
+ const textInput = (part, pattern = /^(label|title|header|heading|summary|text|value|name)$/) => {
90
+ const inputs = part.angular.inputs ?? [];
91
+ const attrs = new Set((part.angular.selectors ?? []).flat().filter((x) => typeof x === "string"));
92
+ return inputs.find((name) => pattern.test(name)) ?? inputs.find((name) => attrs.has(name) && part.angular.kind === "directive") ?? null;
93
+ };
94
+
95
+ /**
96
+ * The source of one fixture. The marking code runs outside Angular's zone, so its polling doesn't trigger change detection.
97
+ * @param {{ archetype: string, pkg: string, parts: Part[], template: string, fields?: string, before?: string, extraImports?: string[] }} input
98
+ */
99
+ function fixture({ archetype, pkg, parts, template, fields = "", before = "", extraImports = [] }) {
100
+ const names = [...new Set(parts.map(importName))].filter(Boolean);
101
+ const imports = parts.filter((part) => part.angular.kind !== "service").map(importName);
102
+ const core = ["Component", "NgZone", "inject", "signal", ...extraImports];
103
+ return `import { ${[...new Set(core)].join(", ")} } from "@angular/core";
104
+ import { ${names.join(", ")} } from ${JSON.stringify(pkg)};
105
+
106
+ ${markingSource(archetype)}${before}
107
+ class Fixture {
108
+ zone = inject(NgZone);
109
+ ${fields ? `${fields}\n` : ""} constructor() {
110
+ this.zone.runOutsideAngular(() => startMarking());
111
+ }
112
+ }
113
+ Component({ selector: "app-fixture", imports: [${[...new Set(imports)].join(", ")}], template: ${JSON.stringify(template)} })(Fixture);
114
+ export default Fixture;
115
+ `;
116
+ }
117
+
118
+ const candidate = (id, summary, parts, source) => ({ id, summary, source, used: [...new Set(parts.map((part) => part.name))] });
119
+
120
+ const ITEMS = "[{ label: 'One', value: 'one', title: 'One', header: 'One', text: 'One', id: 'one', key: 'one', content: 'First panel.' }, { label: 'Two', value: 'two', title: 'Two', header: 'Two', text: 'Two', id: 'two', key: 'two', content: 'Second panel.' }]";
121
+ const itemsInput = (part) => (part.angular.inputs ?? []).find((name) => /^(items|tabs|options|panels|model|data|links)$/.test(name)) ?? null;
122
+
123
+ // ---- tooltip ----
124
+
125
+ function tooltips(pkg, exports) {
126
+ const out = [];
127
+ for (const part of pool("tooltip", exports).filter((p) => p.angular.kind !== "service" && usable(p))) {
128
+ const attrs = new Set((part.angular.selectors ?? []).flat().filter((x) => typeof x === "string"));
129
+ const input = (part.angular.inputs ?? []).find((name) => attrs.has(name)) ?? (part.angular.inputs ?? []).find((name) => /^(tip|tooltip|content|text|label|message|description)$/i.test(name) || /tooltip$/i.test(name));
130
+ if (!input) continue;
131
+ const host = element(part, { prefer: "button", extra: ['type="button"', "data-a11y-trigger"], values: { [input]: "Saves your work." }, inner: "Save" });
132
+ if (host) out.push(candidate(`tooltip-${input}`, `a button with ${part.name} and its ${input} text`, [part], fixture({ archetype: "tooltip", pkg, parts: [part], template: host })));
133
+ }
134
+ return out;
135
+ }
136
+
137
+ // ---- live region ----
138
+
139
+ function messages(pkg, exports) {
140
+ const out = [];
141
+ const all = pool("live-region", exports);
142
+ for (const part of all.filter((p) => p.angular.kind !== "service" && usable(p))) {
143
+ const shownBy = (part.angular.inputs ?? []).find((name) => /^(open|opened|visible|show|shown|isOpen|display)$/.test(name));
144
+ const message = element(part, { extra: [], inner: "Saved." });
145
+ if (!message) continue;
146
+ out.push(candidate(`message-if-${part.name}`, `${part.name} added when the trigger is pressed`, [part], fixture({
147
+ archetype: "live-region", pkg, parts: [part], fields: " shown = signal(false);",
148
+ template: `<button type="button" data-a11y-trigger (click)="shown.set(true)">Show message</button>@if (shown()) {${message}}`,
149
+ })));
150
+ if (shownBy) {
151
+ const flagged = element(part, { bind: { [shownBy]: "shown()" }, inner: "Saved." });
152
+ out.push(candidate(`message-input-${part.name}`, `${part.name} shown through its ${shownBy} input`, [part], fixture({
153
+ archetype: "live-region", pkg, parts: [part], fields: " shown = signal(false);",
154
+ template: `<button type="button" data-a11y-trigger (click)="shown.set(true)">Show message</button>${flagged}`,
155
+ })));
156
+ }
157
+ }
158
+ for (const service of all.filter((p) => p.angular.kind === "service")) {
159
+ const method = (service.angular.methods ?? []).find((name) => /^(open|show|notify|add|success|info|error|warn|message|announce)/i.test(name));
160
+ if (!method) continue;
161
+ out.push(candidate(`message-service-${service.name}`, `${service.name}.${method}() called when the trigger is pressed`, [service], fixture({
162
+ archetype: "live-region", pkg, parts: [service], fields: ` service = inject(${service.name});`,
163
+ template: `<button type="button" data-a11y-trigger (click)="service.${method}('Saved.')">Show message</button>`,
164
+ })));
165
+ }
166
+ return out;
167
+ }
168
+
169
+ // ---- form field ----
170
+
171
+ function fields(pkg, exports) {
172
+ const out = [];
173
+ const all = pool("form-field", exports).filter((p) => p.angular.kind !== "service" && usable(p));
174
+ const wrappers = all.filter((p) => /Field|Form|Group|Wrapper|Control/.test(p.name) && !/Label|Input|Error|Hint/.test(p.name.replace(/(Form|Input)Field/, "")));
175
+ const labels = nameMatches(all, /Label/);
176
+ const inputs = all.filter((p) => /Input|Control|TextField|Textarea|Native|Field/.test(p.name) && !/Label|Error|Hint|Prefix|Suffix/.test(p.name));
177
+ for (const input of inputs.slice(0, 3)) {
178
+ const field = element(input, { prefer: "input", extra: ['id="name"', "data-a11y-trigger"] });
179
+ if (!field) continue;
180
+ const label = '<label for="name">Name</label>';
181
+ for (const wrapper of wrappers.filter((w) => w !== input).slice(0, 2)) {
182
+ const labelPart = labels.find((l) => l !== wrapper);
183
+ const labelText = labelPart && usable(labelPart) ? element(labelPart, { prefer: "label", extra: ['for="name"'], inner: "Name" }) : label;
184
+ const wrapped = element(wrapper, { inner: `${labelText ?? label}${field}` });
185
+ if (wrapped) out.push(candidate(`field-wrapper-${wrapper.name}`, `${wrapper.name} around a label and ${input.name}`, [wrapper, input, ...(labelPart ? [labelPart] : [])], fixture({ archetype: "form-field", pkg, parts: [wrapper, input, ...(labelPart && usable(labelPart) ? [labelPart] : [])], template: wrapped })));
186
+ }
187
+ out.push(candidate(`field-bare-${input.name}`, `${input.name} on a native input with its own label`, [input], fixture({ archetype: "form-field", pkg, parts: [input], template: `${label}${field}` })));
188
+ }
189
+ for (const part of all.filter((p) => p.angular.kind === "component" && (p.angular.inputs ?? []).some((name) => /^(label|placeholder|value)$/.test(name)))) {
190
+ const labelInput = (part.angular.inputs ?? []).find((name) => name === "label");
191
+ const self = element(part, { prefer: "div", extra: ["data-a11y-trigger"], values: labelInput ? { label: "Name" } : {} });
192
+ if (self) out.push(candidate(`field-component-${part.name}`, `${part.name} as the whole field`, [part], fixture({ archetype: "form-field", pkg, parts: [part], template: self })));
193
+ }
194
+ return out;
195
+ }
196
+
197
+ // ---- tabs and accordion: a group with child parts, or a data-driven component ----
198
+
199
+ function groups(archetype, pkg, exports, { groupPattern, childPattern, childTag }) {
200
+ const out = [];
201
+ const all = pool(archetype, exports).filter((p) => p.angular.kind !== "service" && usable(p));
202
+ const containers = all.filter((p) => groupPattern.test(p.name));
203
+ const plain = (p) => p.angular && p.angular.kind !== "service" && usable(p) && childPattern.test(p.name) && !groupPattern.test(p.name);
204
+ // A child needn't have the archetype's own name (`MatExpansionPanel`), so a part named like its container (`Mat…`) counts too.
205
+ const near = (container) => exports.filter((p) => plain(p) && p.name.startsWith(container.name.match(/^[A-Z][a-z0-9]*/)?.[0] ?? "\0"));
206
+ // The plainest child comes first: `MatTab` before `MatTabLabel`, which only works inside the other.
207
+ const byLength = (a, b) => a.name.length - b.name.length;
208
+ const stemOf = (name) => name.replace(/(Group|s)$/, "");
209
+ /** A part that sits beside the children (`AccordionHeader`, `TabList`): named like the child or like the container. */
210
+ const partOf = (pattern, child, container) => exports.find((p) => p.angular && p.angular.kind !== "service" && usable(p) && pattern.test(p.name) && (p.name.startsWith(child.name) || (container && p.name.startsWith(stemOf(container.name)))));
211
+ const isAccordion = archetype === "accordion";
212
+ const headerFor = (child, container) => (isAccordion ? partOf(/Header$/, child, container) : null);
213
+ const triggerFor = (child, container) => (isAccordion ? partOf(/Trigger$/, child, container) : null);
214
+ const contentFor = (child, container) => (archetype === "tabs" || isAccordion ? partOf(/Content$/, child, container) : null);
215
+ const listFor = (child, container) => (archetype === "tabs" ? partOf(/List$/, child, container) : null);
216
+ /** One child with its header, trigger, and content parts, when the library has them. */
217
+ const panel = (child, text, parts) => {
218
+ const labelInput = textInput(child);
219
+ const trigger = parts.trigger ? element(parts.trigger, { prefer: "button", extra: ['type="button"'], values: textInput(parts.trigger) ? { [textInput(parts.trigger)]: text } : {}, inner: text }) : null;
220
+ // A header that doesn't name its own element is a heading, which is what a disclosure trigger sits in.
221
+ const header = parts.header ? element(parts.header, { prefer: "h3", inner: trigger ?? text }) : trigger;
222
+ const body = `${text} content.`;
223
+ const content = parts.content && archetype === "accordion" ? element(parts.content, { prefer: "div", inner: body }) : null;
224
+ // A text input (`label`) names the child, so the text between the tags is its content. A value input only tells children apart.
225
+ const named = labelInput && /^(label|title|header|heading|summary|text|name)$/.test(labelInput);
226
+ const markup = element(child, { prefer: childTag, extra: [], values: labelInput ? { [labelInput]: text } : {}, inner: header ? `${header}${content ?? body}` : named ? body : text });
227
+ return markup && childTag === "button" && markup.startsWith("<button ") ? markup.replace("<button ", '<button type="button" ') : markup;
228
+ };
229
+ /** A tab's content panel sits outside the list, tied to its tab by the same value. */
230
+ const contents = (content, labels) => (content ? labels.map((text) => element(content, { prefer: "div", values: textInput(content) ? { [textInput(content)]: text } : {}, inner: `${text} content.` })).join("") : "");
231
+ for (const container of containers.slice(0, 2)) {
232
+ const children = [...new Set([...all.filter(plain), ...near(container)])].sort(byLength);
233
+ for (const child of children.slice(0, 2)) {
234
+ const parts = { header: headerFor(child, container), trigger: triggerFor(child, container), content: contentFor(child, container) };
235
+ const list = listFor(child, container);
236
+ const one = panel(child, "One", parts);
237
+ const two = panel(child, "Two", parts);
238
+ const valueInput = textInput(container);
239
+ const first = valueInput ? { [valueInput]: "One" } : {};
240
+ const used = [container, child, ...[parts.header, parts.trigger, parts.content, list].filter(Boolean)];
241
+ if (!one || !two) continue;
242
+ if (list) {
243
+ const wrappedList = element(list, { prefer: "div", inner: `${one}${two}` });
244
+ const nested = wrappedList && element(container, { values: first, inner: `${wrappedList}${contents(parts.content, ["One", "Two"])}` });
245
+ if (nested) out.push(candidate(`${archetype}-list-${container.name}-${list.name}-${child.name}`, `${container.name} holding ${list.name} with two ${child.name}`, used, fixture({ archetype, pkg, parts: used, template: nested })));
246
+ }
247
+ const wrapped = element(container, { values: first, inner: `${one}${two}` });
248
+ if (wrapped) out.push(candidate(`${archetype}-group-${container.name}-${child.name}`, `${container.name} holding two ${child.name}`, used, fixture({ archetype, pkg, parts: used, template: wrapped })));
249
+ }
250
+ }
251
+ if (archetype === "accordion") {
252
+ // A single disclosure panel, with no container around it.
253
+ for (const child of [...new Set([...all.filter(plain), ...containers.flatMap(near)])].sort(byLength).slice(0, 2)) {
254
+ const parts = { header: headerFor(child, null), trigger: triggerFor(child, null), content: contentFor(child, null) };
255
+ const alone = panel(child, "Details", parts);
256
+ const used = [child, ...[parts.header, parts.trigger, parts.content].filter(Boolean)];
257
+ if (alone) out.push(candidate(`${archetype}-single-${child.name}`, `${child.name} on its own`, used, fixture({ archetype, pkg, parts: used, template: alone })));
258
+ }
259
+ }
260
+ for (const part of all.filter((p) => itemsInput(p))) {
261
+ const input = itemsInput(part);
262
+ const host = element(part, { bind: { [input]: "items" } });
263
+ if (host) out.push(candidate(`${archetype}-items-${part.name}`, `${part.name} given its ${input} input`, [part], fixture({ archetype, pkg, parts: [part], fields: ` items = ${ITEMS};`, template: host })));
264
+ }
265
+ return out;
266
+ }
267
+
268
+ const tabs = (pkg, exports) => groups("tabs", pkg, exports, { groupPattern: /(Tabs|TabGroup|TabList|TabNav|TabNavBar|TabBar|TabView)$|^Tab(s|List|Group|Nav|Bar)$/, childPattern: /Tabs?(Link|Item|Label|Panel|Trigger)?$/, childTag: "button" });
269
+
270
+ function accordions(pkg, exports) {
271
+ const out = groups("accordion", pkg, exports, { groupPattern: /(Accordion|Expansion|Collapse|Collapsible)s?$/, childPattern: /(Panel|Item|Section|Tab|Disclosure)$/, childTag: "div" });
272
+ for (const { pointer, target, input } of linked("accordion", exports, /Panel|Content|Collaps/)) {
273
+ const ref = "panel";
274
+ const trigger = element(pointer, { prefer: "button", extra: ['type="button"', "data-a11y-trigger"], bind: { [input]: ref }, inner: "Details" });
275
+ const body = element(target, { extra: [`#${ref}="${exportRef(target)}"`], inner: "More about this." });
276
+ if (trigger && body) out.push(candidate(`accordion-ref-${pointer.name}-${target.name}`, `${pointer.name} pointing at ${target.name}`, [pointer, target], fixture({ archetype: "accordion", pkg, parts: [pointer, target], template: `${trigger}${body}` })));
277
+ }
278
+ return out;
279
+ }
280
+
281
+ // ---- menu and combobox: a pointer directive and a panel it points at, with item parts ----
282
+
283
+ function menus(pkg, exports) {
284
+ const out = [];
285
+ const items = exports.filter((p) => p.angular && /Menu.*Item|Item/.test(p.name) && fits("menu", p) && p.angular.kind !== "service" && usable(p));
286
+ for (const { pointer, target, input } of linked("menu", exports, /Menu|Panel/)) {
287
+ const trigger = element(pointer, { prefer: "button", extra: ['type="button"', "data-a11y-trigger"], bind: { [input]: "m" }, inner: "Actions" });
288
+ for (const item of [items[0] ?? null]) {
289
+ const entry = item ? element(item, { prefer: "button", extra: ['type="button"'], inner: "Copy" }) : '<button type="button">Copy</button>';
290
+ const panel = element(target, { extra: [`#m="${exportRef(target)}"`], inner: entry ?? "" });
291
+ if (trigger && panel) out.push(candidate(`menu-ref-${pointer.name}-${target.name}`, `${pointer.name} pointing at ${target.name}`, [pointer, target, ...(item ? [item] : [])], fixture({ archetype: "menu", pkg, parts: [pointer, target, ...(item ? [item] : [])], template: `${trigger}${panel}` })));
292
+ }
293
+ }
294
+ for (const part of pool("menu", exports).filter((p) => p.angular.kind !== "service" && usable(p) && itemsInput(p))) {
295
+ const input = itemsInput(part);
296
+ const host = element(part, { bind: { [input]: "items" } });
297
+ if (host) out.push(candidate(`menu-items-${part.name}`, `${part.name} given its ${input} input`, [part], fixture({ archetype: "menu", pkg, parts: [part], fields: ` items = ${ITEMS};`, template: host })));
298
+ }
299
+ return out;
300
+ }
301
+
302
+ function comboboxes(pkg, exports) {
303
+ const out = [];
304
+ const options = exports.filter((p) => p.angular && /Option/.test(p.name) && p.angular.kind !== "service" && usable(p));
305
+ for (const { pointer, target, input } of linked("combobox", exports, /Listbox|Options|Panel|Autocomplete|Overlay|Popup/)) {
306
+ const field = element(pointer, { prefer: "input", extra: ['type="text"', 'aria-label="Fruit"', "data-a11y-trigger"], bind: { [input]: "l" } });
307
+ const option = options[0] ? element(options[0], { prefer: "div", inner: "Apple" }) : '<div role="option">Apple</div>';
308
+ const panel = element(target, { extra: [`#l="${exportRef(target)}"`], inner: option ?? "" });
309
+ const used = [pointer, target, ...(options[0] ? [options[0]] : [])];
310
+ if (field && panel) out.push(candidate(`combobox-ref-${pointer.name}-${target.name}`, `${pointer.name} pointing at ${target.name}`, used, fixture({ archetype: "combobox", pkg, parts: used, template: `${field}${panel}` })));
311
+ }
312
+ for (const part of pool("combobox", exports).filter((p) => p.angular.kind === "component" && usable(p) && (p.angular.inputs ?? []).some((name) => /^(options|items|suggestions)$/.test(name)))) {
313
+ const input = (part.angular.inputs ?? []).find((name) => /^(options|items|suggestions)$/.test(name));
314
+ const host = element(part, { prefer: "div", bind: { [input]: "items" }, extra: ['aria-label="Fruit"'] });
315
+ if (host) out.push(candidate(`combobox-items-${part.name}`, `${part.name} given its ${input} input`, [part], fixture({ archetype: "combobox", pkg, parts: [part], fields: ` items = ${ITEMS};`, template: host })));
316
+ }
317
+ return out;
318
+ }
319
+
320
+ // ---- dialog ----
321
+
322
+ const CONTENT = `Component({ selector: "app-dialog-content", template: '<h2>Edit profile</h2><p>Update your details.</p><button type="button">Close</button>' })(DialogContent);\n`;
323
+
324
+ function dialogs(pkg, exports) {
325
+ const out = [];
326
+ const all = pool("dialog", exports);
327
+ for (const service of all.filter((p) => p.angular.kind === "service")) {
328
+ const method = (service.angular.methods ?? []).find((name) => /^open/i.test(name));
329
+ if (!method) continue;
330
+ out.push(candidate(`dialog-service-${service.name}`, `${service.name}.${method}() with a small content component`, [service], fixture({
331
+ archetype: "dialog", pkg, parts: [service], before: `class DialogContent {}\n${CONTENT}`, fields: ` dialog = inject(${service.name});\n content = DialogContent;`,
332
+ template: `<button type="button" data-a11y-trigger (click)="dialog.${method}(content)">Open dialog</button>`,
333
+ })));
334
+ }
335
+ for (const part of all.filter((p) => p.angular.kind === "component" && usable(p))) {
336
+ const shownBy = (part.angular.inputs ?? []).find((name) => /^(open|opened|visible|show|isOpen|display)$/.test(name));
337
+ if (!shownBy) continue;
338
+ const closers = (part.angular.outputs ?? []).filter((name) => /close|hide|Change$/.test(name)).map((name) => (/Change$/.test(name) ? `(${name})="shown.set($event === true)"` : `(${name})="shown.set(false)"`));
339
+ const host = element(part, { extra: closers, bind: { [shownBy]: "shown()" }, inner: '<h2>Edit profile</h2><p>Update your details.</p><button type="button" (click)="shown.set(false)">Close</button>' });
340
+ if (host) out.push(candidate(`dialog-input-${part.name}`, `${part.name} shown through its ${shownBy} input`, [part], fixture({ archetype: "dialog", pkg, parts: [part], fields: " shown = signal(false);", template: `<button type="button" data-a11y-trigger (click)="shown.set(true)">Open dialog</button>${host}` })));
341
+ }
342
+ return out;
343
+ }
344
+
345
+ const BUILDERS = { dialog: dialogs, menu: menus, tooltip: tooltips, tabs, accordion: accordions, combobox: comboboxes, "form-field": fields, "live-region": messages };
346
+
347
+ /**
348
+ * Candidates for an Angular package, from what discovery read off its classes.
349
+ * @param {{ archetype: string, pkg: string, exports: Array<{ name: string, angular?: AngularExportInfo }> }} input
350
+ * @returns {{ candidates: Array<{ id: string, summary: string, source: string, used: string[] }>, reason: string | null }}
351
+ */
352
+ export function generateAngular({ archetype, pkg, exports }) {
353
+ if (!GENERATABLE.has(archetype)) return { candidates: [], reason: `Nothing is generated for the ${archetype} archetype.` };
354
+ const found = BUILDERS[archetype](pkg, exports);
355
+ const seen = new Set();
356
+ const candidates = found.filter((c) => (seen.has(c.id) ? false : seen.add(c.id))).slice(0, ATTEMPT_LIMIT);
357
+ return candidates.length
358
+ ? { candidates, reason: null }
359
+ : { candidates: [], reason: `No Angular class looks like the ${archetype} archetype and shows a way to wire one (a selector, an input, or a service method), so a fixture has to be written.` };
360
+ }
@@ -7,7 +7,7 @@ import { markingSource } from "./marking.js";
7
7
  const cap = (text) => text.charAt(0).toUpperCase() + text.slice(1);
8
8
  const indentBy = (text, spaces) => text.split("\n").map((line) => (line ? " ".repeat(spaces) + line : line)).join("\n");
9
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 */
10
+ /** @typedef {{ id: string, extension: string, openProps: string[][], labelFor: string, click: (action: string) => string, fragment: (inner: string) => string, when: (condition: string, markup: string) => 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
11
 
12
12
  /** @type {Dialect} */
13
13
  export const reactDialect = {
@@ -15,6 +15,9 @@ export const reactDialect = {
15
15
  extension: "jsx",
16
16
  openProps: [["open", "onClose"], ["open", "onOpenChange"], ["isOpen", "onOpenChange"], ["isOpen", "onClose"], ["opened", "onClose"]],
17
17
  labelFor: "htmlFor",
18
+ click: (action) => `onClick={() => ${action}}`,
19
+ fragment: (inner) => `<>\n${indentBy(inner, 2)}\n</>`,
20
+ when: (condition, markup) => `{${condition} && ${markup}}`,
18
21
  declare: (name, init) => `const [${name}, set${cap(name)}] = useState(${init});`,
19
22
  read: (name) => name,
20
23
  write: (name, value) => `set${cap(name)}(${value})`,
@@ -40,6 +43,9 @@ export const vueDialect = {
40
43
  // Vue components usually take a model value and say they changed it with an update event, or take `open` and emit `update:open`.
41
44
  openProps: [["open", "onUpdate:open"], ["modelValue", "onUpdate:modelValue"], ["visible", "onUpdate:visible"], ["show", "onUpdate:show"], ["open", "onClose"], ["isOpen", "onClose"]],
42
45
  labelFor: "for",
46
+ click: (action) => `onClick={() => ${action}}`,
47
+ fragment: (inner) => `<>\n${indentBy(inner, 2)}\n</>`,
48
+ when: (condition, markup) => `{${condition} && ${markup}}`,
43
49
  declare: (name, init) => `const ${name} = ref(${init});`,
44
50
  read: (name) => `${name}.value`,
45
51
  write: (name, value) => `(${name}.value = ${value})`,
@@ -62,3 +68,31 @@ ${indentBy(body, 6)}
62
68
  `;
63
69
  },
64
70
  };
71
+
72
+ /** @type {Dialect} */
73
+ export const svelteDialect = {
74
+ id: "svelte",
75
+ extension: "svelte",
76
+ // A Svelte component takes `open` and calls back when it changes, or takes `open` and calls `onclose`.
77
+ openProps: [["open", "onOpenChange"], ["open", "onclose"], ["visible", "onclose"], ["isOpen", "onclose"], ["opened", "onclose"]],
78
+ labelFor: "for",
79
+ click: (action) => `onclick={() => ${action}}`,
80
+ // Svelte 5 markup can have several top-level nodes, so a fragment is just its children.
81
+ fragment: (inner) => inner,
82
+ when: (condition, markup) => `{#if ${condition}}${markup}{/if}`,
83
+ declare: (name, init) => `let ${name} = $state(${init});`,
84
+ read: (name) => name,
85
+ write: (name, value) => `${name} = ${value}`,
86
+ attr: (name, expr) => `${name}={${expr}}`,
87
+ frame(archetype, pkg, body, hooks = "") {
88
+ return `<script>
89
+ import * as Lib from ${JSON.stringify(pkg)};
90
+ import { onMount } from "svelte";
91
+ ${indentBy(markingSource(archetype), 2)}
92
+ onMount(() => startMarking());
93
+ ${hooks ? `${hooks}\n` : ""}</script>
94
+
95
+ ${body}
96
+ `;
97
+ },
98
+ };
@@ -60,10 +60,8 @@ function dialogControlled([openProp, closeProp]) {
60
60
  const inner = content ? tag(content, "", dialogContent(kit)) : dialogContent(kit);
61
61
  const overlay = kit.pick("overlay", "backdrop");
62
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
- </>`;
63
+ const body = d.fragment(`<button type="button" data-a11y-trigger ${d.click(d.write("open", "true"))}>Open dialog</button>
64
+ ${tag(root, controls, `${overlay ? `<${overlay} />\n` : ""}${inner}`)}`);
67
65
  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
66
  };
69
67
  }
@@ -195,14 +193,14 @@ const fieldSingle = (id, summary, markup) => (makeKit, pkg, d) => {
195
193
  const messageConditional = (makeKit, pkg, d) => {
196
194
  const kit = makeKit();
197
195
  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>`;
196
+ const body = `<div>\n <button type="button" data-a11y-trigger ${d.click(d.write("on", "true"))}>Show message</button>\n <div>${d.when(d.read("on"), `<${kit.self}>Saved.</${kit.self}>`)}</div>\n</div>`;
199
197
  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
198
  };
201
199
 
202
200
  const messageControlled = (prop) => (makeKit, pkg, d) => {
203
201
  const kit = makeKit();
204
202
  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>`;
203
+ const body = `<div>\n <button type="button" data-a11y-trigger ${d.click(d.write("on", "true"))}>Show message</button>\n <${kit.self} ${d.attr(prop, d.read("on"))}>Saved.</${kit.self}>\n</div>`;
206
204
  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
205
  };
208
206