Skip to content

Feat/mui9 - #9

Merged
steps371 merged 22 commits into
mainfrom
feat/mui9
Sep 29, 2026
Merged

steps371 merged 22 commits into
mainfrom
feat/mui9

Conversation

@steps371

@steps371 steps371 commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Rebuilds the bindings for MUI 9 and MUI X 9 on React 18 or 19, and releases all three packages as 0.1.0. It's 0.1.0 rather than 1.0.0 while the API meets its first real forms.

What changed

  • Toolchain: React 19, MUI 9, MUI X 9, Vite 8 and ESLint 10. The packages are ESM only, and the tarballs ship dist only.
  • Peers: react ^18.0.0 || ^19.0.0, react-hook-form >=7.62.0 <8 and @mui/material ^9.0.0, plus MUI X 9 for the pickers.
  • Every component is rebuilt on a shared field controller:
    • Only React Hook Form's props go to useController.
    • disabled reaches React Hook Form.
    • A rule without a message falls back to FormErrorMessagesProvider.
  • Your handlers are handleChange and handleBlur, on every component. MUI's own onChange and onBlur are no longer accepted.
    • handleChange runs after the form stores a change, and takes the MUI component's onChange arguments. The option groups pass the typed option value, and NumberField passes Base UI's (value, eventDetails).
    • handleBlur runs after the form's blur handler marks the field touched.
    • With suppressFormChange, the form stores nothing. handleChange stores the changes it keeps with setValue, so it can refuse one.
  • Option-driven Select, RadioGroup, CheckboxGroup and ToggleButtonGroup, with typed values.
  • Autocomplete and AsyncAutocomplete:
    • They store the selected option by default, as MUI does.
    • getOptionValue stores something else instead, such as the id.
    • Options follow MUI's conventions: strings and numbers need neither getOptionKey nor getOptionLabel, and an object's label defaults to its label.
    • Object options need getOptionKey, which can return a string or a number. Stored options are matched by it, not by reference.
    • The form field's type decides whether getOptionValue is needed, and the option type whether getOptionKey and getOptionLabel are.
  • New components:
    • NumberField (Base UI).
    • AsyncAutocomplete: server-backed options, a label cache and a load-more footer.
    • DateField, TimeField and DateTimeField.
    • Pro: DateTimeRangePicker and SingleInputDateRangeField.
  • Pickers:
    • They no longer use MUI X internals.
    • An error appears on the change that causes it.
    • messages sets the error text per field, and every picker takes a transform.
    • The DateRangePicker recursion is fixed.
  • Helper line: an empty helper line keeps its space, as helperText=' ' did, so an error appearing doesn't move the form.
    • reserveHelperText turns this off for a field, and HelperTextProvider for a form or section.
    • The picker packages now take the core package as a peer, so the provider reaches them too.
  • Storybook:
    • Every story has Controls: the form (mode, reserved line, disabled), the field (label, helper text, disabled, required message) and the component's own props.
    • Every component has a Docs page.
    • The pickers have fuller stories, Autocomplete has stories for plain strings and for objects with a label, and a "Book a trip" example uses every field.
  • Releases: npm run release:version (Lerna) bumps the versions in a PR. Publishing a GitHub release runs release.yaml:
    • It publishes each new version, in dependency order, through npm trusted publishing.
    • It tags each one <package>@<version>.
    • This is the same approach as relay-network.
  • CI: one check workflow on Node 24 and 26 replaces the five per-package ones. A second job runs the packages' typecheck and tests on React 18.

See CHANGELOG.md for every breaking change since 0.0.x, and MIGRATION.md for how to update, with before
and after code.

Before the first release

@cliedeman: trusted publishing needs a trusted publisher on each package on npmjs.com, set to organisation stackworx, repository react-hook-form-mui and workflow release.yaml. The packages are:

  • @stackworx/react-hook-form-mui
  • @stackworx/react-hook-form-mui-x-date-pickers
  • @stackworx/react-hook-form-mui-x-date-pickers-pro

Once that's done, publishing a GitHub release from main publishes 0.1.0.

Reviewing

Each commit is one idea and passes npm run check on its own, so it reads well commit by commit.

Testing

  • npm run check: dprint, ESLint, TypeScript, 127 Vitest tests and the library builds.
  • The packages' typecheck and tests also pass on React 18.3.
  • The Storybook build passes. The stories were checked in a browser before the last three commits.

Follow-ups

  • The pickers should read FormErrorMessagesProvider too. Today they only use their own messages.
  • required on the range pickers never fires, because an empty range is [null, null]. We need to decide whether it should mean both dates.
  • With suppressFormChange, a date field keeps typed text the form refused, because MUI X keeps its own text for each part of the date. NumberField shows it until the field loses focus.
  • With suppressFormChange, a picker's own error, such as minDate, only appears once the field is touched or the form is submitted.
  • suppressFormBlur, if a form ever needs to skip the form's blur handler.

🤖 Generated with Claude Code

steps371 and others added 17 commits September 28, 2026 14:18
- React 19.3, MUI 9.4, MUI X 9.14, react-hook-form 7.89, TypeScript 6.0,
  Vite 8.3 (library mode via a shared vite.library.ts), Vitest 5 + jsdom 30,
  ESLint 10 flat config (typescript-eslint strict, @eslint-react,
  react-hooks 7, jsx-a11y-x), dprint (Stackworx TS profile from
  chore/dprint), Storybook 10, Lerna 10.
- Drop Prettier, eslint-plugin-jest/react/prettier, babel-loader,
  @types/eslint__js, date-fns 2 and dayjs.
- Packages: ESM exports map, files: [dist], sideEffects: false,
  engines, peer ranges for React 19 / MUI 9 / RHF >=7.62; MIT LICENSE.
- Shared test harness (test/renderWithForm.tsx) and a smoke test.
- 0.0.x components move to src/legacy (excluded from the gate) until
  each is rewritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useFieldController wraps useController and resolves the error text: a
rule's own message wins, a known type (required, min, max, minLength,
maxLength, pattern, validate) uses FormErrorMessagesProvider (English
defaults), anything else shows the type name. splitControllerProps
forwards name/control/rules/defaultValue/shouldUnregister/disabled to
RHF so they no longer spill onto MUI. FieldControllerProps accepts a
control whose transformed values differ from its input (resolvers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bound through useFieldController: value falls back to '' (no
uncontrolled warnings), consumer onChange/onBlur compose with the RHF
binding, inputRef merges with field.ref so setFocus works, disabled
reaches RHF, and helperText shows the error text or the consumer's
helper. An optional transform maps the form value to text and back.

Shipped source uses .js relative specifiers (lint-enforced) so the
emitted declarations resolve under node16/nodenext too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Built from MUI's documented NumberField recipe (Base UI NumberField in
FormControl/InputLabel/OutlinedInput/FormHelperText). onValueChange
stores number | null, field.ref reaches the visible input so setFocus
works, min/max/step/format pass through, and the error text drives the
helper text, aria-invalid and aria-describedby. The arrow icons are
inlined so the package needs no @mui/icons-material peer.

It ships as the @stackworx/react-hook-form-mui/number-field subpath so
@base-ui/react stays an optional peer; library entries now come from
each package's exports map. setFocus tests wait for RHF's deferred
focus instead of passing on focus left over from typing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Options drive the menu items; the form value is the chosen option's
value (TValue | null, or TValue[] with multiple), mapped back through
the options so numbers stay numbers even for a comma-joined autofill
string. Error text replaces the helper text, the ref reaches the
select for setFocus, and consumer slotProps.select (object or function
form) keeps working with multiple forced on top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both take a label and render FormControl > FormControlLabel > control
plus a FormHelperText with the error text or the consumer's helper
(linked through aria-describedby, with aria-invalid on error). The ref
travels through slotProps.input (MUI 9 has no inputRef) and merges
with a consumer's slotProps.input ref, object or function form. The
name must point at a boolean field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All three take options ({value, label, disabled}), label, helperText,
row and required, use one controller per group and name/describe the
group for assistive tech (fieldset legend or aria-labelledby, helper
or error text via aria-describedby).

- CheckboxGroup stores TValue[], adding/removing by Object.is; an
  undefined value starts empty instead of crashing.
- RadioGroup stores TValue | null, mapping MUI's string value back to
  the typed option by index (numbers and booleans survive).
- ToggleButtonGroup stores TValue | null (exclusive, default) or
  TValue[]; enforceValue ignores a click that would clear it.

setFocus lands on the selected option, else the first enabled one.
Select now shares the FieldOption type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without getOptionValue the form stores the selected option, as MUI does; with it, what it returns,
such as the id. Stored options are matched by getOptionKey, not by reference, and the form field's
type decides whether getOptionValue is needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alues

It stores the option or getOptionValue's result, like Autocomplete. Selected options keep their
labels across pages and searches; only stored ids need knownOptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DatePicker, TimePicker, DateTimePicker, DateField, TimeField and
DateTimeField bind through one usePickerController: value (with an
optional transform to and from the form value), onChange, onError,
blur, ref (the hidden input, which forwards focus to the sections),
name and disabled. Errors go to the text field (slotProps.textField
for pickers, object or function form, merged with the consumer's).

MUI's validation error becomes a validate rule that reads a ref set
from onChange's validationError before RHF validates, so the message
appears on the change that causes it. A function-form rules.validate
still runs. Default English messages cover every MUI X validation code
and never interpolate dates; messages overrides them per field.

No adapter or /internals imports. Tests run on AdapterLuxon (dev only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DateRangePicker, DateTimeRangePicker and SingleInputDateRangeField
reuse the pickers package's controller with a [start, end] value that
defaults to [null, null]; transform maps the tuple to the app's shape
(e.g. {from, to}). MUI's components are imported under aliases, so a
wrapper can no longer render itself, and the value is passed through.
Range validation errors (including invalidRange) resolve to messages.

The Pro package depends on the pickers wrapper package, which now
exports its binding helpers. Tests filter MUI X's missing-licence
console error without setting a key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0.1.0 rather than 1.0.0 while the API meets its first real forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One CI workflow (checkout@v5, setup-node@v5, npm ci, npm run check) on
a Node 24/26 matrix, with a Storybook build on Node 24 so the Pages
deploy cannot break silently. It replaces the lint and per-package
build workflows, which ran Node 18/20 with the old toolchain.

The Pages deploy (docs plus Storybook under /storybook on gh-pages) is
kept on the same actions and Node 24; the Docusaurus build was checked
locally on a current Node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An empty helper line keeps its space, as helperText=' ' did, so an error appearing doesn't move the
form. reserveHelperText turns it off for a field and HelperTextProvider for a form or section. The
picker packages take the core package as a peer so the provider reaches them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One form with every component: a searched destination stored as the option, an airport stored as
its code, linked dates, a field that appears when switched on, and values in the ISO formats an
API takes. Its city search and the location stories share one in-memory source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every story renders from args: shared Form controls (mode, HelperTextProvider, disabled), Field
controls (label, helper text, disabled, reserved line, required message) and the component's own.
Control types and Docs tables come from react-docgen-typescript, limited to our props and a short
list of MUI's. Each picker gets required, min and max, custom messages, disabled and ISO-string
stories; the ranges add both-or-neither and end-before-start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
release:version bumps the changed packages with Lerna without committing, so the bump reaches main
in a PR. Publishing a GitHub release, or running the workflow by hand, runs the checks, publishes
each version npm doesn't have yet in dependency order through npm trusted publishing, and tags it
<package>@<version>. The workflow follows stackworx/relay-network's release.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@netlify

netlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for aquamarine-choux-75c766 ready!

Name Link
🔨 Latest commit 4cf400e
🔍 Latest deploy log https://app.netlify.com/projects/aquamarine-choux-75c766/deploys/6abbca1b6a80d93916854804
😎 Deploy Preview https://deploy-preview-9--aquamarine-choux-75c766.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

steps371 and others added 5 commits September 29, 2026 14:34
Strings and numbers need no getOptionKey or getOptionLabel, and an object's
label defaults to its label, as in MUI. Object options still need
getOptionKey. The prop and selection types now come from MUI's own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Context providers use Provider and useContext, and the listbox forwards its
ref, so the packages run on React 18 as well as 19. Lint targets React 18,
and CI runs the packages' typecheck and tests on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every component takes handleChange, which runs after the form stores a
change, and handleBlur, which runs after the form's blur handler. With
suppressFormChange the form stores nothing and handleChange decides.
MUI's onChange and onBlur are no longer accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One MuiHandlerProps helper replaces the per-component handler-argument
aliases, and changeHandler infers the arguments from handleChange.
TextField and the pickers share FieldTransform, the pickers use core's
FieldControllerProps and ReserveHelperTextProps, and the unused
AutocompleteFieldValue is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before and after code for the changes most forms need, linked from the
changelog, the README and each package's README, which is what npm shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@steps371
steps371 merged commit 05fb481 into main Sep 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants