Mantle Field.Item Control Id Migration Guide
@ngrok/mantle's Field.Item now owns the DOM id of its focusable control. Its id prop names the control, not the wrapping <div>. Field.Control splats that id onto the control and Field.Label points its htmlFor at it. Two props are gone: Field.Label no longer takes htmlFor, and Field.Control no longer takes id. When Field.Item has no id, it generates a stable one, as before.
In the same release the PasswordInput visibility toggle became a keyboard-reachable toggle button. Its accessible name is now the fixed string "Show value". It was "Turn password visibility on" or "Turn password visibility off".
TL;DR: Move any id from a control inside Field.Control to the surrounding Field.Item. Delete every htmlFor on Field.Label. Delete every id on Field.Control. In tests, select the PasswordInput toggle by the name "Show value" and use an exact match for the input's label.
Codemod
From a mantle checkout:
In a project that installs @ngrok/mantle:
The rules apply the deletions in steps 1 and 2: htmlFor leaves Field.Label, and id leaves Field.Control. Steps 3 to 6 stay manual. A control's id moves up to Field.Item only when a test or an anchor depends on it, and a useId() call or a test selector is not a JSX attribute a rule can judge. The rules read a part through its import from @ngrok/mantle/field, under its own name or an alias. A part that reaches the file through a local re-export or a destructured namespace stays manual. A function that binds Field to a parameter or a local shadows the import, so the rules leave that function alone. Pass --dry-run to print the rewrites first, and run your formatter after the rewrite.
You are an agent migrating a codebase. Do this:
- Find every
Field.Labelwith anhtmlForprop. Delete the prop. If the value was a literal id that must stay stable, move it toidon the surroundingField.Item. - Find every
Field.Controlwith anidprop. Delete the prop. Move a literal id toidon the surroundingField.Item. - Find every control rendered as the direct child of a
Field.Controlthat has anidprop (Input,PasswordInput,TextArea,Checkbox,Switch,Select.Trigger, a native<input>, and so on). Delete theid. If a test or an anchor link depends on that id, move it toidon the surroundingField.Item. - Find every
Field.Itemthat already has anidprop. That id used to land on the wrapping<div>. It now lands on the control. If CSS or a test selected the wrapper by that id, change the selector to[data-slot="field-item"]or adata-*attribute. - Find every
useId()whose only purpose was aLabel/ control pair inside aField.Item. Delete theuseId()call and theid/htmlForpair.Field.Itemwires them. - In test code, replace a selector for the
PasswordInputtoggle that matches "Turn password visibility" with the exact name "Show value". Where a test selects the password input by label, use an exact match (getByLabel("Password", { exact: true })in Playwright). - After editing, run the project's formatter, linter, type checker, and tests.
Why one owner?
Field.Labelis a sibling ofField.Control, so it cannot read anidfrom the control element.Field.Itemis the only common ancestor. When both the label and the control read the id fromField.Item, they cannot drift apart.
Before → after
A control with a hand-wired id
A checkbox row wired with useId
A control that needs no stable id
A Playwright page object for a login form
With <Field.Item name="password" id="password">, page.locator("#password") also resolves to the input.
Edge cases & gotchas
- A control rendered outside
Field.Control.Field.Labelpoints atField.Item's control id, and nothing puts that id on a control outsideField.Control. Move the control insideField.Control, or use the render-prop form ofField.Controlto place the generated props on the focusable element by hand. - A compound control (
Select,MultiSelect,Slider,OtpInput,RadioGroup,Choice). Wrap it as its docs page shows. The trigger reads the id fromFieldControlContext, soidonField.Itemreaches it too. Field.Labelused as a plainLabeloutside aField.Item. It renders a<label>with noforattribute. UseLabelfrom@ngrok/mantle/labelwhen you needhtmlForwithout aField.Item.- The toggle's
aria-controls.PasswordInputpoints the toggle at the input's id. Inside aField.Item, that is theField.Itemid, or the generated one. Nothing to set by hand. - Type errors after the upgrade (
htmlForonField.Label,idonField.Control) are the migration's checklist. Fix each one by the steps above.
How to verify
- Type check. A leftover
htmlForonField.LabeloridonField.Controlfails the build. - For each migrated field, click the label and confirm the control takes focus.
- Where a test selects a control by id, confirm the id is on the control element, not on the
Field.Itemwrapper. - Tab into a
PasswordInputand confirm the next Tab lands on the toggle. Press Space and confirm the value shows and focus stays on the toggle. - Run the project's formatter, linter, type checker, and tests.