Bulk-Fixing Form Label Associations with jscodeshift
The text is already on the screen. A <label> reads “Email address”, the <input> sits directly beneath it, every sighted user understands the pairing, and a screen reader announces “edit text, blank” — because nothing in the markup states the relationship. This is the most rewarding class of accessibility violation to automate, since the transform adds no information: it only makes an existing relationship machine-readable. This guide is part of Codemod-Driven Accessibility Fixes, and it covers the three association shapes, the identifier-generation rule that keeps a sweep idempotent, and the ambiguities the transform must decline.
Root Cause
The label rule fires when a form control exposes no accessible name through any supported mechanism: an associated <label for>, a <label> that contains the control, aria-labelledby, aria-label, or a title attribute. Visual proximity is not a mechanism. Name computation walks the accessibility tree, not the layout, so a label positioned by CSS grid two rows above its input is as unassociated as one on a different page. That rule maps to WCAG 2.2 SC 4.1.2 (Name, Role, Value), and its companion form-field-multiple-labels maps to WCAG 2.2 SC 3.3.2 (Labels or Instructions) — which matters here because a careless transform trips the second while fixing the first.
Hand-written JSX produces this at scale. A component author writes the label and the input as siblings, the design system supplies the spacing, and nothing in the type system asks for an identifier. Six months later a scan reports 214 unlabelled controls across a checkout package, and each one is two attributes away from correct.
Unlike alt text, a wrong edit here is loud. Point htmlFor at an identifier that does not exist and the control is still unnamed, so label fires again. Point it at the wrong control and the other control loses its name, so label fires there. Generate an identifier twice and duplicate-id-aria fires. Every plausible mistake produces a scanner failure on the next run, which is what makes this rule id genuinely safe to sweep: the verification step can catch the transform’s own errors, which is never true of a generated text alternative.
Configuration
Three shapes create the association, and the transform treats them differently. <label htmlFor> pointing at a matching id is the shape it creates: purely additive, two attributes, no nodes moved. A wrapping <label> that already contains its control is a shape it preserves — the association exists by containment, and adding htmlFor on top of it is redundant noise while restructuring a sibling pair into a wrapper moves nodes and risks the layout. aria-labelledby is the fallback for the case where the visible text is not in a <label> element at all, which is common in design systems that style a <span> as a field label.
The identifier is where sweeps usually go wrong. Derive it from the field’s own name, prefixed by the enclosing <fieldset> legend when there is one, and suffixed so it cannot collide with an unrelated identifier that happens to share the word. shipping-email-field is a function of the field itself, so a second run produces the same string, a rerun after an unrelated edit above it produces the same string, and a reviewer reading the diff can tell at a glance whether the pairing is right. A per-file counter cannot do any of that: insert one field at the top of a form and every identifier below it renumbers, so the next sweep produces a diff full of changes that fix nothing.
The transform below implements shape 1 with every guard in place. Each refusal writes a row to a ledger rather than logging and moving on, because the refused controls are the ones that need a person and they should leave the run as a list, not as an absence.
// codemods/associate-labels.js
const { appendFileSync } = require('node:fs');
const CONTROLS = new Set(['input', 'select', 'textarea']);
const NAMED = ['aria-label', 'aria-labelledby'];
const LOOPS = new Set(['map', 'flatMap', 'forEach']);
const slug = (s) =>
String(s).replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').toLowerCase();
const attr = (open, name) =>
open.attributes.find((a) => a.type === 'JSXAttribute' && a.name.name === name);
// Literal values only: id={`${p}-email`} has no build-time value.
const literal = (open, name) => {
const a = attr(open, name);
if (!a || !a.value) return null;
const v = a.value;
return v.type === 'Literal' || v.type === 'StringLiteral' ? String(v.value) : null;
};
// Direct text children only, so {t('email')} never becomes part of an id.
const visibleText = (node) =>
(node.children || [])
.filter((c) => c.type === 'JSXText')
.map((c) => c.value.trim())
.filter(Boolean)
.join(' ');
const isControl = (child) =>
child.type === 'JSXElement' &&
child.openingElement.name.type === 'JSXIdentifier' &&
CONTROLS.has(child.openingElement.name.name);
module.exports = function transformer(file, api, options) {
const j = api.jscodeshift;
const root = j(file.source);
const source = file.source;
let edits = 0;
const refuse = (node, reason) => {
if (!options.refusals) return;
const line = node.loc ? node.loc.start.line : 0;
appendFileSync(options.refusals, JSON.stringify({ file: file.path, line, reason }) + '\n');
};
// A static id inside a list callback duplicates once per row at run time.
const inLoop = (path) => {
for (let p = path.parent; p; p = p.parent) {
const n = p.node;
if (
n.type === 'CallExpression' &&
n.callee.type === 'MemberExpression' &&
n.callee.property.type === 'Identifier' &&
LOOPS.has(n.callee.property.name)
) {
return true;
}
}
return false;
};
const legendPrefix = (path) => {
for (let p = path.parent; p; p = p.parent) {
const n = p.node;
if (n.type !== 'JSXElement') continue;
if (n.openingElement.name.name !== 'fieldset') continue;
const legend = (n.children || []).find(
(c) => c.type === 'JSXElement' && c.openingElement.name.name === 'legend',
);
return legend ? slug(visibleText(legend)) : null;
}
return null;
};
root.find(j.JSXElement, { openingElement: { name: { name: 'label' } } }).forEach((path) => {
const label = path.node;
const open = label.openingElement;
if (attr(open, 'htmlFor')) return; // shape 1 already present
if ((label.children || []).some(isControl)) return; // shape 2: leave containment alone
const siblings = path.parent.node.children || [];
const after = siblings.slice(siblings.indexOf(label) + 1);
// A following label ends this label's scope: fields past it belong to it.
const nextLabel = after.findIndex(
(c) => c.type === 'JSXElement' && c.openingElement.name.name === 'label',
);
const scope = nextLabel === -1 ? after : after.slice(0, nextLabel);
const candidates = scope.filter(isControl);
if (candidates.length === 0) return refuse(open, 'no-control-in-scope');
if (candidates.length > 1) return refuse(open, 'two-candidate-controls');
const control = candidates[0].openingElement;
if (NAMED.some((n) => attr(control, n))) return refuse(control, 'control-already-named');
if (control.attributes.some((a) => a.type === 'JSXSpreadAttribute')) {
return refuse(control, 'spread-may-carry-id');
}
if (inLoop(path)) return refuse(control, 'inside-a-loop-id-would-duplicate');
const existing = attr(control, 'id');
let id = existing ? literal(control, 'id') : null;
if (existing && !id) return refuse(control, 'id-is-a-runtime-expression');
if (!id) {
const base = literal(control, 'name') || visibleText(label);
if (!base) return refuse(control, 'nothing-to-derive-an-id-from');
const prefix = legendPrefix(path);
id = `${prefix ? prefix + '-' : ''}${slug(base)}-field`;
// Derived, never counted. A collision is an ambiguity, so refuse it.
if (source.includes(`"${id}"`)) return refuse(control, 'derived-id-already-in-file');
control.attributes.push(j.jsxAttribute(j.jsxIdentifier('id'), j.literal(id)));
}
open.attributes.push(j.jsxAttribute(j.jsxIdentifier('htmlFor'), j.literal(id)));
edits += 1;
});
if (edits === 0) return null;
return root.toSource({ quote: 'double' });
};
Shape 3 is a second pass over the same tree, and it only runs where shape 1 could not: a control whose nearest preceding element is styled text rather than a <label>. It gives that element an identifier and points aria-labelledby at it, which names the control without moving a single node — the same additive property that makes shape 1 safe.
// codemods/associate-labels.js, continued inside the same transformer
const TEXT_TAGS = new Set(['span', 'div', 'p']);
// Identifiers already claimed by a label, including the ones just written above.
const claimed = new Set(
root
.find(j.JSXOpeningElement, { name: { name: 'label' } })
.nodes()
.map((open) => literal(open, 'htmlFor'))
.filter(Boolean),
);
root.find(j.JSXElement, { openingElement: { name: { name: 'input' } } }).forEach((path) => {
const control = path.node.openingElement;
if (NAMED.some((n) => attr(control, n))) return;
const own = literal(control, 'id');
if (own && claimed.has(own)) return; // shape 1 already named this control
const siblings = path.parent.node.children || [];
const before = siblings.slice(0, siblings.indexOf(path.node)).reverse();
const previous = before.find((c) => c.type === 'JSXElement');
if (!previous) return;
const tag = previous.openingElement.name;
if (tag.type !== 'JSXIdentifier' || !TEXT_TAGS.has(tag.name)) return;
const text = visibleText(previous);
if (!text) return refuse(control, 'adjacent-text-is-not-static');
const labelId = literal(previous.openingElement, 'id') || `${slug(text)}-label`;
if (!literal(previous.openingElement, 'id')) {
if (source.includes(`"${labelId}"`)) return refuse(control, 'derived-label-id-in-use');
previous.openingElement.attributes.push(
j.jsxAttribute(j.jsxIdentifier('id'), j.literal(labelId)),
);
}
control.attributes.push(
j.jsxAttribute(j.jsxIdentifier('aria-labelledby'), j.literal(labelId)),
);
edits += 1;
});
Validation
Test the guards first and the happy path second, because the guards are the part that protects working code. Every refusal case gets a test that asserts the source came back byte-identical.
// codemods/__tests__/associate-labels.test.js
const { mkdtempSync, readFileSync } = require('node:fs');
const { join } = require('node:path');
const { tmpdir } = require('node:os');
const { applyTransform } = require('jscodeshift/dist/testUtils');
const transform = require('../associate-labels');
const refusals = join(mkdtempSync(join(tmpdir(), 'label-')), 'refusals.jsonl');
const run = (source) =>
applyTransform(transform, { parser: 'tsx', refusals }, { source, path: 'Signup.tsx' });
const lastReason = () =>
JSON.parse(readFileSync(refusals, 'utf8').trim().split('\n').pop()).reason;
test('a sibling pair is associated with a name-derived id', () => {
const out = run('<div><label>Email address</label><input name="email" /></div>');
expect(out).toContain('htmlFor="email-field"');
expect(out).toContain('id="email-field"');
});
test('a fieldset legend prefixes the id', () => {
const out = run(
'<fieldset><legend>Shipping</legend>' +
'<label>Email address</label><input name="email" /></fieldset>',
);
expect(out).toContain('id="shipping-email-field"'); // stable across reruns
});
test('two candidate controls are refused', () => {
const source = '<div><label>Email</label><input name="a" /><input name="b" /></div>';
expect(run(source)).toBe(source);
expect(lastReason()).toBe('two-candidate-controls');
});
test('a runtime id is refused rather than overwritten', () => {
const source = '<div><label>Email</label><input id={inputId} name="email" /></div>';
expect(run(source)).toBe(source);
expect(lastReason()).toBe('id-is-a-runtime-expression');
});
test('a control inside a loop is refused', () => {
const source =
'<div>{rows.map((r) => (<div><label>Qty</label><input name="qty" /></div>))}</div>';
expect(run(source)).toBe(source);
expect(lastReason()).toBe('inside-a-loop-id-would-duplicate');
});
test('a wrapping label is untouched and the run is idempotent', () => {
const source = '<label>Email address<input name="email" /></label>';
expect(run(source)).toBe(source);
});
Then prove the association resolves at run time, which is a different claim from “the attributes are present”. A query by label text is the closest a unit test gets to what a screen reader does, because it goes through the same name computation.
// codemods/__tests__/signup-form.test.jsx
import { render, screen } from '@testing-library/react';
test('the input is reachable by its visible label text', () => {
render(
<div>
<label htmlFor="email-field">Email address</label>
<input id="email-field" name="email" type="email" />
</div>,
);
// Fails if htmlFor and id disagree, or if two controls claim the same label.
expect(screen.getByLabelText('Email address')).toHaveAttribute('name', 'email');
});
The last step is the rescan, scoped to the rules this sweep can move. label must fall to zero on the swept package, and the two rules a bad edit would trip must stay flat — a rise in either is the transform’s fault even though neither is the rule it was asked to fix.
| rule id | before | after | delta |
|----------------------------|--------|-------|-------|
| label | 214 | 46 | -168 |
| form-field-multiple-labels | 3 | 3 | 0 |
| duplicate-id-aria | 0 | 0 | 0 |
The 46 that remain are the refusals, and they are a result rather than a shortfall. Grouping the ledger by reason turns the sweep’s coverage gap into an ordered work queue: the largest group is usually a single architectural pattern, and fixing it upstream clears more controls than any further matcher tuning would.
Edge Cases and Conditional Guards
- Two candidate labels. A control with a label above it and helper text styled as a label below it has two plausible names, and choosing wrongly produces a
form-field-multiple-labelsviolation on top of a misleading announcement. The transform refuses whenever more than one control falls inside a label’s scope, and whenever a control already carriesaria-labeloraria-labelledby. - The label lives in another component. A layout component renders the
<label>and passes children, while the field component renders the<input>. jscodeshift sees one file, so the pair is invisible to it and both halves look like orphans. This needs the type-aware path or, better, a labelling prop the component cannot omit — the argument for design-system accessibility defaults. - Dynamically generated ids.
id={useId()}orid={followed by a template literal has no build-time value, so writinghtmlFor="something"would point at a string that never exists in the DOM. The transform refuses on any non-literalidand leaves the pair for a hand fix that reuses the same runtime value on both attributes. - Controls rendered inside a list. A static id inside
rows.map(...)becomes the same id on every row, which trades onelabelviolation for a page full of duplicated identifiers. Any control under amap,flatMaporforEachcallback is refused; the correct fix there is a per-row identifier derived from the row key. - Custom component controls.
<TextField />is not a native control, and the prop that carries the identifier could beid,inputIdorfieldId. Matching on native tags only keeps the transform honest, and the residue is small enough to fix by hand once the component itself takes a required label prop.
Pipeline Impact
The sweep produces one branch per package with a purely additive diff, so review is fast and the revert is a single commit. Wire the rescan comparison into the job as the gate: if label did not fall, or if form-field-multiple-labels or duplicate-id-aria rose, the run fails and no pull request is opened. That branch is then checked by the ordinary workflow in configuring GitHub Actions for automated WCAG checks, which scans the proposed branch rather than trusting the transform’s own accounting.
Commit the refusal ledger with the branch. It is the only record of which controls a machine could not name, and it is the input to two follow-up decisions: which component needs a required label prop, and which forms need a hand fix. Finish by adding one component-level regression test per form the sweep touched, so the association cannot be removed by a later refactor without a failure — the mechanics of that guard belong to regression prevention after fixes, and without it the same 168 controls drift back over a few quarters.
Common Pitfalls
- Writing
forinstead ofhtmlForin JSX, which React drops silently, so the diff looks correct and the association never exists. - Numbering generated identifiers per file, so any inserted field renumbers the rest and the next sweep produces a diff full of no-op changes.
- Adding
htmlForto a label that already wraps its control, which is redundant and can produce a second name for the same field. - Associating a control that already has
aria-label, giving it two competing names and aform-field-multiple-labelsviolation where there was none. - Guessing an identifier for a control whose
idis a runtime expression, which pointshtmlForat a string that never appears in the DOM. - Discarding the refusal ledger, which hides the 46 controls that still have no name behind a headline number that fell by 168.
FAQ
Why prefer htmlFor and id over restructuring the markup into a wrapping label?
Both satisfy WCAG 2.2 SC 4.1.2, but only one is safe to automate. Adding two attributes cannot change layout, focus order or styling, and it reverts cleanly. Moving an <input> inside a <label> changes the element tree, which can break a CSS grid placement, a sibling selector or a component that expects a specific child order — and it produces a diff a reviewer has to reason about rather than scan.
What if the field has no name attribute to derive an identifier from?
The transform falls back to a slug of the label’s own static text, so <label>Email address</label> yields email-address-field. If the label text is a translation call rather than static text there is nothing to derive from, and the node is refused. That fallback is deliberately last, because a name is stable while visible copy is edited by anyone.
Should this transform ever run on the same package as an alt-text sweep?
Not in the same invocation. One rule id per run and one commit per rule id keeps the rescan comparison attributable: if two transforms touch one file and duplicate-id-aria rises, there is no way to tell which one did it. Run them in sequence, and see automating decorative alt text safely for the sibling sweep, which needs a much tighter ceiling because its mistakes are silent.
Related
- Codemod-Driven Accessibility Fixes — eligibility tests, the dry run, and the per-package apply loop this transform runs inside.
- Automating Decorative Alt Text Safely — the sibling sweep, and why its mistakes cannot be caught by a rescan.
- CI/CD Integration & Automated Quality Gating — where the rescan comparison becomes a status check on the generated branch.