Skip to content

Commit 817e535

Browse files
dmealingclaude
andcommitted
fix(codegen-ts-react): give dispatched form controls an accessible name
The view-kind-dispatched controls (<select>/<textarea>/checkbox/radio) bind via form.register(...), which does not carry the aria-label that form.input.<field> attaches — leaving them with no accessible name (a regression for enum fields, which previously rendered via scalarBlock/form.input with an aria-label). Add aria-label={<entity>.<field>.label} to each dispatched control (the group name on the radio <fieldset>), matching the scalar path. Add aria-label assertions + a non-required-dropdown empty-option test; correct the stale header comment. Found by the final whole-branch review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent f902cd5 commit 817e535

2 files changed

Lines changed: 29 additions & 9 deletions

File tree

server/typescript/packages/codegen-ts-react/src/templates/form-file.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
// React form template — emits a per-entity Form component that delegates
2-
// to useEntityForm from @metaobjectsdev/react. The generated file
3-
// is ~25 lines: wire up the form, render each field's pre-bound input,
4-
// let the helper carry all metadata-derived attrs.
2+
// to useEntityForm from @metaobjectsdev/react. Each visible field is
3+
// dispatched to a control by its declared view kind.
54
//
65
// What gets emitted:
7-
// - A `<EntityName>Form` component that calls useEntityForm and spreads
8-
// form.input.<field> onto <input> elements.
6+
// - A `<EntityName>Form` component. A plain scalar field (no view kind)
7+
// spreads form.input.<field> onto a typed <input> (carries aria-label
8+
// via useEntityForm's `.input` accessor). A field with a view kind
9+
// (view.textarea/checkbox/radio, or an enum defaulting to a dropdown)
10+
// is dispatched by fieldControlFor to a <select>/<textarea>/checkbox
11+
// <input>/radio <fieldset>, bound via `{...form.register("<name>")}`
12+
// and given a matching explicit `aria-label`.
913
// - Per-entity onSubmit/defaultValues typed against the Row type.
1014
//
1115
// Fields excluded from the form by default:
@@ -344,20 +348,20 @@ ${control}
344348
const options = values.map((v) => ` <option value="${v}">${v}</option>`).join("\n");
345349
return labelAndError(
346350
name,
347-
` <select className="metaobjects-field-input" {...form.register("${name}")}>\n${empty}${options}\n </select>`,
351+
` <select aria-label={${entityName}.${name}.label} className="metaobjects-field-input" {...form.register("${name}")}>\n${empty}${options}\n </select>`,
348352
);
349353
}
350354
if (kind === VIEW_SUBTYPE_TEXTAREA) {
351355
// Configurable @rows is deferred (see the spec's Deferred work); default to 4.
352356
return labelAndError(
353357
name,
354-
` <textarea className="metaobjects-field-input" rows={4} {...form.register("${name}")} />`,
358+
` <textarea aria-label={${entityName}.${name}.label} className="metaobjects-field-input" rows={4} {...form.register("${name}")} />`,
355359
);
356360
}
357361
if (kind === VIEW_SUBTYPE_CHECKBOX) {
358362
return labelAndError(
359363
name,
360-
` <input type="checkbox" className="metaobjects-field-checkbox" {...form.register("${name}")} />`,
364+
` <input aria-label={${entityName}.${name}.label} type="checkbox" className="metaobjects-field-checkbox" {...form.register("${name}")} />`,
361365
);
362366
}
363367
if (kind === VIEW_SUBTYPE_RADIO) {
@@ -368,7 +372,10 @@ ${control}
368372
` <label className="metaobjects-field-radio"><input type="radio" value="${v}" {...form.register("${name}")} /> ${v}</label>`,
369373
)
370374
.join("\n");
371-
return labelAndError(name, ` <fieldset className="metaobjects-field-radios">\n${radios}\n </fieldset>`);
375+
return labelAndError(
376+
name,
377+
` <fieldset aria-label={${entityName}.${name}.label} className="metaobjects-field-radios">\n${radios}\n </fieldset>`,
378+
);
372379
}
373380
return scalarBlock(name);
374381
};

server/typescript/packages/codegen-ts-react/test/form-view-dispatch.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ async function loadModel(): Promise<{ root: MetaRoot; report: MetaObject }> {
3333
{ "field.boolean": { name: "archived", children: [{ "view.checkbox": {} }] } },
3434
// view.radio over enum values -> radio fieldset
3535
{ "field.enum": { name: "tier", "@values": ["free", "pro"], children: [{ "view.radio": {} }] } },
36+
// non-required enum with an explicit dropdown -> empty option IS emitted
37+
{ "field.enum": { name: "priority", "@values": ["low", "high"], children: [{ "view.dropdown": {} }] } },
3638
// @formExclude -> absent from the form
3739
{ "field.string": { name: "internalNote", "@formExclude": true } },
3840
],
@@ -71,6 +73,7 @@ describe("form controls — view-kind dispatch", () => {
7173
expect(out).toContain('<option value="active">');
7274
expect(out).toContain('form.register("status")');
7375
expect(out).not.toMatch(/form\.input\.status/);
76+
expect(out).toContain("aria-label={Report.status.label}");
7477
});
7578

7679
test("view.textarea renders a <textarea> with the default row count", async () => {
@@ -79,13 +82,15 @@ describe("form controls — view-kind dispatch", () => {
7982
expect(out).toContain("<textarea");
8083
expect(out).toContain("rows={4}");
8184
expect(out).toContain('form.register("notes")');
85+
expect(out).toContain("aria-label={Report.notes.label}");
8286
});
8387

8488
test("view.checkbox renders a checkbox input", async () => {
8589
const { root, report } = await loadModel();
8690
const out = renderFormFile(report, ctxFor(root));
8791
expect(out).toContain('type="checkbox"');
8892
expect(out).toContain('form.register("archived")');
93+
expect(out).toContain("aria-label={Report.archived.label}");
8994
});
9095

9196
test("view.radio renders a radio fieldset over the enum values", async () => {
@@ -95,6 +100,14 @@ describe("form controls — view-kind dispatch", () => {
95100
expect(out).toContain('type="radio"');
96101
expect(out).toContain('value="free"');
97102
expect(out).toContain('value="pro"');
103+
expect(out).toContain("aria-label={Report.tier.label}");
104+
});
105+
106+
test("a non-required dropdown emits the empty option", async () => {
107+
const { root, report } = await loadModel();
108+
const out = renderFormFile(report, ctxFor(root));
109+
expect(out).toContain('<option value="">');
110+
expect(out).toContain("aria-label={Report.priority.label}");
98111
});
99112

100113
test("a scalar string with no view keeps the existing bound <input>", async () => {

0 commit comments

Comments
 (0)