Validated form controls: stabilize as public API - #81306
Conversation
Adds an optional `getInteractiveTarget` resolver to `ControlWithError`, and uses it in `ValidatedFormTokenField`. Controls that validate through a hidden delegate element previously attached the validity message's `aria-describedby` to the delegate, so screen reader users never heard the error on the element they actually focus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ement The group container is the correct target for a group-level description, and unlike the active option it is present regardless of interaction state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Part of removing private API usage from the bundled @wordpress/dataviews package (WordPress#81230). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Type-checking these call sites for the first time surfaced two latent issues that `unlock()`'s `any` return had hidden: - `getCustomValidity` guarantees at runtime that it only returns the `required` entry when it carries a message, but the ternary tests a property and returns the whole object, so the optional `message` never narrowed. Narrowed explicitly, and the return type is now pinned to `ValidatedControlProps`. - `InputControl` reports `undefined` when a field is cleared, while the change handler declared `string`. Widened and normalised with `?? ''`, matching the `value ?? ''` the control already renders. Part of WordPress#81230. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the remaining validated-control unlock() calls, bringing the package from 26 unlock() sites to 13. As a bundled package, DataViews cannot rely on @wordpress/private-apis: two copies in one runtime cannot unlock each other's objects, which throws at module-eval time. Type-checking these call sites for the first time surfaced further latent issues that unlock()'s `any` had hidden: - `ValidatedSelectControl` explicitly excluded `multiple` from its props, but DataViews passes it for `type: 'array'` fields and multi-select works at runtime. Widened the component to mirror SelectControl's own single/multiple discriminated union, so the public type matches the behavior. - `ComboboxControl` reports `undefined` as well as `null` when cleared. - `min`/`max` constraints are typed `number | string` because the same rules describe dates; on a number field they are coerced. - The validation story carried its own copy of the `getCustomValidity` narrowing bug. Part of WordPress#81230. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers block-editor's url-input, the math block and format, and content-types' fields. These are wpScript packages where private APIs are legitimate; migrating them is what allows the private entries to be removed. Type-checking content-types surfaced two more instances of the latent issues already fixed in DataViews: the `getCustomValidity` narrowing failure, and a change handler declaring `string` where `InputControl` reports `undefined`. Part of WordPress#81230. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All in-repo consumers now import them publicly. Also retires the private Storybook badge for the eleven stabilized controls, and marks the layer's overview as stable. `ValidatedContentEditableControl` stays locked: its base component is itself private, and its only consumer is blocked on @wordpress/rich-text private APIs. `ValidatedCustomSelectControl` and `ValidatedRangeControl` were never locked and remain internal. Part of WordPress#81230. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning: Type of PR label mismatch To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.
Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task. |
|
Warning: Type of PR label mismatch To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.
Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task. |
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
Thanks for the PR! I don't think these components are ready to be stabilized and if they were, they should move to It seems the heavy consumer is DataViews package and probably copying the components there would make more sense for now (as part of #81230). Maybe we can copy them in DataViews but also preserve the private components for the rest packages that are not bundled? 🤔 @mirka any thoughts? |
|
Thanks @dhasilva for looking into this. This is a high-stakes change: it's a big API surface, there's multiple consumers (within core as well as outside). My preference would be having all these components in wordpress/ui: it's bundled and experimental for a reason. I understand doing it all at once may be hard. In the interest of finding what's the smallest step we can take and unblocking #81230 I looked at usage (both for core and some external repos I'm familiar with). @mirka and others, given this usage, would you think there's a hybrid approach other than the status quo?
|
|
@dhasilva as per this comment, would you be up to migrating the components to the wordpress/ui package? |
|
@oandregal I will be handling this, at least to figure out an initial strategy. |
|
Closing as @mirka will take this forward by moving the components to the wordpress/ui package. |
What
Promotes eleven
Validated*components from@wordpress/componentsprivate APIs to public exports, migrates all five in-repo consumers, and removes the private entries.Part of #81230 (the
Validated*item). Takes@wordpress/dataviewsfrom 26unlock()call sites to 13.Important
Depends on #81305, which fixes #76741. This branch contains those commits; review only the ones from
Components: Export the validated form controls publiclyonward. Will rebase once #81305 lands.Why now
@wordpress/dataviewsis a bundled package — it declares neitherwpScriptnorwpModuleExports. In@wordpress/private-apis,lockedDatais a module-scopedWeakMapand__privateis a plainSymbol()rather thanSymbol.for(). Two copies in one runtime therefore cannot unlock each other's objects, so a plugin that loadswp.componentsfrom the WordPress global alongside a bundled DataViews throwsCannot unlock an object that was not locked beforeat module-eval time — before anything renders.Why
@wordpress/componentsrather than@wordpress/uiThe obvious objection is that this commits public API in a package being superseded.
packages/eslint-plugin/rules/use-recommended-components.jssays otherwise: itsDENYLISTfor@wordpress/componentscontains no form controls at all (every entry is a layout primitive,Text/Heading,Card*,Tabs,Tooltip, orVisuallyHidden), and itsALLOWLISTfor@wordpress/uicontains none either.@wordpress/ui's form module hasField,InputandSelectprimitives but no validation layer of any kind, so migrating there is not currently an option.On the open issues
The layer was documented as "Status: Beta". Stabilization gates on API-shape finality, and the public surface is three props:
<form>, noonSubmitand noreportValidity()anywhere in the package;hooks/use-reveal-validity.tsdispatches syntheticinvalidevents instead. "Both sync and async errors on first blur" is behavioral, and DataViews already resolves that precedence indataform-controls/utils/get-custom-validity.ts.Latent bugs this surfaced
unlock()is typed<T = any>( object: unknown ): T, so every migrated call site was unchecked by TypeScript until now. Type-checking them turned up real problems, all fixed here:ValidatedSelectControlexcludedmultiplefrom its props while DataViews passes it fortype: 'array'fields — and multi-select works at runtime, becausemultiplefell into...restPropsand reached the innerSelectControl. The component now mirrorsSelectControl's own single/multiple discriminated union, so the public type matches the behavior. Doing this before stabilizing avoids a later type change to a stable API.onChangehandlers declared a non-optionalstringwhereInputControl/ComboboxControlreportundefinedon clear — three independent instances (dataform-controls/utils/validated-input.tsx,combobox.tsx,content-types/utils/fields.tsx). Widened and normalised to match thevalue ?? ''the controls already render, so behavior is unchanged.getCustomValiditynever narrowedrequired.message.FieldValidity.required.messageis optional, unlike every other rule; the guard tests a property but returns the whole object. Three copies fixed, and the return type is now pinned toValidatedControlProps[ 'customValidity' ].min/maxare typednumber | stringbecause the same rules describe dates; on a number field they are coerced.Scope
Public (11):
ValidatedCheckboxControl,ValidatedComboboxControl,ValidatedFormTokenField,ValidatedInputControl,ValidatedNumberControl,ValidatedRadioControl,ValidatedSelectControl,ValidatedTextControl,ValidatedTextareaControl,ValidatedToggleControl,ValidatedToggleGroupControl, plus theValidatedControlPropstype.Still private:
ValidatedContentEditableControl— its baseContentEditableControlis itself private, and its only consumer (dataform-controls/richtext/control.tsx) is blocked on five@wordpress/rich-textprivate APIs regardless.Still internal:
ValidatedCustomSelectControlandValidatedRangeControlwere never locked intoprivateApisand have no consumers.Notes for reviewers
export *, so the two internal controls above don't leak.InputControl,NumberControlandToggleGroupControlare only public as__experimental*. The coding guidelines forbid adding new__experimentalAPIs, and it is the wrapper's own three-prop surface being stabilized.status-privateStorybook tag is removed from the eleven stabilized controls and kept on the three that stay internal.storybook/badges.jshas no "stable" badge — stable is the absence of a tag.tools/docs/gen-components-docs/resolves stories at<manifest-dir>/stories/index.story.tsx, which this folder's layout (per-control stories one level deeper) doesn't match. Documentation lives in the Storybookoverview.mdx, whose status is updated from Beta to Stable. Happy to restructure if the components team would rather have a generated README./** WordPress dependencies */comment blocks, as theeslint.config.strict.cjsused by lint-staged requires (@wordpress/dependency-group: never).Testing instructions
npm run build— the substantive gate; type generation is what checks the previously-anycall sites. Passes with zero errors.npm run test:unit -- packages/components packages/dataviewsnpm run docs:build && git status— clean.