Skip to content

Validated form controls: stabilize as public API - #81306

Closed
dhasilva wants to merge 10 commits into
WordPress:trunkfrom
dhasilva:update/stabilize-validated-form-controls
Closed

Validated form controls: stabilize as public API#81306
dhasilva wants to merge 10 commits into
WordPress:trunkfrom
dhasilva:update/stabilize-validated-form-controls

Conversation

@dhasilva

@dhasilva dhasilva commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

What

Promotes eleven Validated* components from @wordpress/components private APIs to public exports, migrates all five in-repo consumers, and removes the private entries.

Part of #81230 (the Validated* item). Takes @wordpress/dataviews from 26 unlock() 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 publicly onward. Will rebase once #81305 lands.

Why now

@wordpress/dataviews is a bundled package — it declares neither wpScript nor wpModuleExports. In @wordpress/private-apis, lockedData is a module-scoped WeakMap and __private is a plain Symbol() rather than Symbol.for(). Two copies in one runtime therefore cannot unlock each other's objects, so a plugin that loads wp.components from the WordPress global alongside a bundled DataViews throws Cannot unlock an object that was not locked before at module-eval time — before anything renders.

Why @wordpress/components rather than @wordpress/ui

The obvious objection is that this commits public API in a package being superseded. packages/eslint-plugin/rules/use-recommended-components.js says otherwise: its DENYLIST for @wordpress/components contains no form controls at all (every entry is a layout primitive, Text/Heading, Card*, Tabs, Tooltip, or VisuallyHidden), and its ALLOWLIST for @wordpress/ui contains none either. @wordpress/ui's form module has Field, Input and Select primitives 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:

required?: boolean;
markWhenOptional?: boolean;
customValidity?: { type: 'validating' | 'valid' | 'invalid'; message: string };

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:

  • ValidatedSelectControl excluded multiple from its props while DataViews passes it for type: 'array' fields — and multi-select works at runtime, because multiple fell into ...restProps and reached the inner SelectControl. The component now mirrors SelectControl'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.
  • onChange handlers declared a non-optional string where InputControl/ComboboxControl report undefined on clear — three independent instances (dataform-controls/utils/validated-input.tsx, combobox.tsx, content-types/utils/fields.tsx). Widened and normalised to match the value ?? '' the controls already render, so behavior is unchanged.
  • getCustomValidity never narrowed required.message. FieldValidity.required.message is 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 to ValidatedControlProps[ 'customValidity' ].
  • min/max are typed number | string because 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 the ValidatedControlProps type.

Still private: ValidatedContentEditableControl — its base ContentEditableControl is itself private, and its only consumer (dataform-controls/richtext/control.tsx) is blocked on five @wordpress/rich-text private APIs regardless.

Still internal: ValidatedCustomSelectControl and ValidatedRangeControl were never locked into privateApis and have no consumers.

Notes for reviewers

  • The exports are an explicit named list rather than export *, so the two internal controls above don't leak.
  • Names go out unprefixed even though InputControl, NumberControl and ToggleGroupControl are only public as __experimental*. The coding guidelines forbid adding new __experimental APIs, and it is the wrapper's own three-prop surface being stabilized.
  • The status-private Storybook tag is removed from the eleven stabilized controls and kept on the three that stay internal. storybook/badges.js has no "stable" badge — stable is the absence of a tag.
  • No generated README. 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 Storybook overview.mdx, whose status is updated from Beta to Stable. Happy to restructure if the components team would rather have a generated README.
  • Files touched by this PR also lose their /** WordPress dependencies */ comment blocks, as the eslint.config.strict.cjs used by lint-staged requires (@wordpress/dependency-group: never).

Testing instructions

  1. npm run build — the substantive gate; type generation is what checks the previously-any call sites. Passes with zero errors.
  2. npm run test:unit -- packages/components packages/dataviews
  3. npm run docs:build && git status — clean.
  4. In Storybook, exercise the DataForm validation story and the validated-control stories.

dhasilva and others added 10 commits August 6, 2026 20:21
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>
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

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.

  • Required label: Any label starting with [Type].
  • Labels found: [Package] Components, [Package] Block library, [Package] Format library, [Package] Block editor, [Package] DataViews.

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.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

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.

  • Required label: Any label starting with [Type].
  • Labels found: .

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.

@github-actions github-actions Bot added [Package] Components /packages/components [Package] Block library /packages/block-library [Package] Format library /packages/format-library [Package] Block editor /packages/block-editor [Package] DataViews /packages/dataviews labels Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

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 props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: dhasilva <thehenridev@git.wordpress.org>
Co-authored-by: ntsekouras <ntsekouras@git.wordpress.org>
Co-authored-by: oandregal <oandregal@git.wordpress.org>
Co-authored-by: mirka <0mirka00@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@ntsekouras

Copy link
Copy Markdown
Contributor

Thanks for the PR!

I don't think these components are ready to be stabilized and if they were, they should move to ui package.

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?

@oandregal

Copy link
Copy Markdown
Member

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?

Control Consumer Usage
ValidatedInputControl DataViews utils/validated-input.tsx:60
DataViews color.tsx:95
DataViews datetime.tsx:177
DataViews time.tsx:96, time.tsx:107
block-editor url-input/index.js:448-L454
content-types fields.tsx:264
wp-calypso suffix-input-control.tsx:20
wp-calypso phone-number-input/index.tsx:73
WooCommerce currency-input.tsx:75
WooCommerce currency-input.tsx:57
ValidatedTextareaControl DataViews textarea.tsx:37
block-library math/edit.js:85
ValidatedToggleControl DataViews toggle.tsx:34
content-types fields.tsx:186
ValidatedTextControl DataViews dataform/stories/validation.tsx:62
format-library math/index.js:95
ValidatedNumberControl DataViews utils/validated-number.tsx:150
ValidatedCheckboxControl DataViews checkbox.tsx:34
ValidatedComboboxControl DataViews combobox.tsx:44
ValidatedSelectControl DataViews select.tsx:47
ValidatedRadioControl DataViews radio.tsx:44
ValidatedToggleGroupControl DataViews toggle-group.tsx:54, toggle-group.tsx:73
ValidatedFormTokenField DataViews array.tsx:69
ValidatedContentEditableControl DataViews richtext/control.tsx:45
ValidatedRangeControl None, unexported via private-apis.ts
ValidatedCustomSelectControl None,unexported

@oandregal

Copy link
Copy Markdown
Member

@dhasilva as per this comment, would you be up to migrating the components to the wordpress/ui package?

@mirka

mirka commented Aug 7, 2026

Copy link
Copy Markdown
Member

@oandregal I will be handling this, at least to figure out an initial strategy.

@dhasilva

dhasilva commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Closing as @mirka will take this forward by moving the components to the wordpress/ui package.

@dhasilva dhasilva closed this Aug 7, 2026
@dhasilva
dhasilva deleted the update/stabilize-validated-form-controls branch August 7, 2026 22:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Block editor /packages/block-editor [Package] Block library /packages/block-library [Package] Components /packages/components [Package] DataViews /packages/dataviews [Package] Format library /packages/format-library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validated form controls: Error messages don't reach the interactive element on delegate-based controls

4 participants