Skip to content

Conversation

@mirka
Copy link
Member

@mirka mirka commented Nov 20, 2025

What?

Closes #73015

Try suppressing the native error popover that appears when a form is validated, while keeping the focus behavior as is.

Why?

The popover can obscure our custom error messages, and are redundant.

How?

preventDefault() on invalid events on the validity target, and then add back the focus logic.

The concern here is whether we're overlooking any other "default behavior" that would be bad to prevent.

One that I noticed so far is, at least in VoiceOver on Chrome and Safari, hitting the submit button on an invalid form plays a special sound effect in addition to announcing the first error again. This behavior is lost by doing preventDefault(). We can mimic the announcement programmatically, but not the sound effect.

Testing Instructions

Try the Validated Form Control stories in the three major browsers.

Screenshots or screencast

Before

CleanShot.2025-11-21.at.05-26-35.mp4

After

CleanShot.2025-11-21.at.05-25-55.mp4

@mirka mirka self-assigned this Nov 20, 2025
@mirka mirka added [Type] Enhancement A suggestion for improvement. [Focus] Accessibility (a11y) Changes that impact accessibility and need corresponding review (e.g. markup changes). [Package] Components /packages/components labels Nov 20, 2025
Comment on lines 134 to 139
// Radio inputs need special handling because all radio inputs with the
// same `name` will be marked as invalid.
const radioSibilings =
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the topics discussed in the original issue was whether this would be "worth the extra trouble".

So far, from the set of use cases that we currently have, radio inputs were the only thing that needed special handling like this. There may be more, and I have some qualms about the screen reader behavior, but perhaps not too bad to count as hard blockers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having a hard time understanding what happens if we just... didn't have this code for radio inputs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad. Here's what happens when you click the submit button on an untouched radio group. The "Before" version has the radio handling in this hook commented out.

Before After
Radio group, before Radio group, after

I expanded on the code comment a bit (793ace0). Ideally we have a unit test, but unfortunately this is not accurately testable in jsdom.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha 👍

Thinking out loud a bit: Rather than dealing with getValidityTarget as assuming there's some "one element" to be validated (which your comment seems to suggest is not actually the case), could we bind to the wrapper and watch for the event in capture phase?

Something like...

wrapperRef.current?.addEventListener( 'invalid', suppressNativePopover, true );

Or maybe it should be getValidityTargets, which is usually an array of one, but could be multiple?

Definitely not a blocker.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment I'm not considering supporting multiple validity targets for a control, because there's only one slot to show an error message.

But listening to all invalid events within a control wrapper might work… 🤔 That might be preferable if we encounter additional cases that need a similar workaround.

@mirka mirka marked this pull request as ready for review November 20, 2025 20:57
@mirka mirka requested a review from ajitbohra as a code owner November 20, 2025 20:57
@github-actions
Copy link

github-actions bot commented Nov 20, 2025

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: mirka <0mirka00@git.wordpress.org>
Co-authored-by: aduth <aduth@git.wordpress.org>
Co-authored-by: jasmussen <joen@git.wordpress.org>
Co-authored-by: juanfra <juanfra@git.wordpress.org>

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

@mirka
Copy link
Member Author

mirka commented Nov 20, 2025

Not sure if we'll merge, but I'll open this up to feedback.

@mirka mirka requested review from a team November 20, 2025 20:58
@jasmussen
Copy link
Contributor

At a very high level glance at just the before and after, purely conceptually the path forward is clear: it should be one or the other, not both.

@mirka mirka force-pushed the validation-suppress-popover branch from 115df92 to 4e9608c Compare December 8, 2025 20:46
@mirka
Copy link
Member Author

mirka commented Dec 8, 2025

I think we're all aligned that suppressing the native popovers is better for general UX. Since this is trivial to revert if it causes complications down the line, I would be comfortable trying it out to see how it goes.

I've rebased this with the latest validation logic, so the PR is ready for review (with the assumption that it will actually be merged) 🙂

);

expect(
screen.getByRole( 'textbox', { name: /^Text1/ } )
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Should we expect that the name would be exactly Text1 ?

Suggested change
screen.getByRole( 'textbox', { name: /^Text1/ } )
screen.getByRole( 'textbox', { name: 'Text1' } )

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just side-stepping the detail that the required prop adds a (Required) suffix to the labels. Kind of wanted to exclude that detail from these tests.

Comment on lines 123 to 134
if ( ! target.form ) {
target.focus();
return;
}

const firstErrorInForm = Array.from( target.form.elements ).find(
( el ) => ! ( el as ValidityTarget ).validity.valid
);

if ( firstErrorInForm === target ) {
target.focus();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we use :invalid query selector to simplify this?

Suggested change
if ( ! target.form ) {
target.focus();
return;
}
const firstErrorInForm = Array.from( target.form.elements ).find(
( el ) => ! ( el as ValidityTarget ).validity.valid
);
if ( firstErrorInForm === target ) {
target.focus();
}
if ( ! target.form || target.form.querySelector( ':invalid' ) === target ) {
target.focus();
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh, this is a great idea I didn't think of, but unfortunately this querySelector( ':invalid' ) seems to trigger a recursive validation check in jsdom and makes every test hang 😭 I reshuffled the original code to make it a bit simpler though (41d019f).

Comment on lines 134 to 139
// Radio inputs need special handling because all radio inputs with the
// same `name` will be marked as invalid.
const radioSibilings =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm having a hard time understanding what happens if we just... didn't have this code for radio inputs.

Comment on lines 134 to 139
// Radio inputs need special handling because all radio inputs with the
// same `name` will be marked as invalid.
const radioSibilings =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha 👍

Thinking out loud a bit: Rather than dealing with getValidityTarget as assuming there's some "one element" to be validated (which your comment seems to suggest is not actually the case), could we bind to the wrapper and watch for the event in capture phase?

Something like...

wrapperRef.current?.addEventListener( 'invalid', suppressNativePopover, true );

Or maybe it should be getValidityTargets, which is usually an array of one, but could be multiple?

Definitely not a blocker.

@mirka mirka merged commit 0c606a3 into trunk Dec 11, 2025
46 of 47 checks passed
@mirka mirka deleted the validation-suppress-popover branch December 11, 2025 13:56
@github-actions github-actions bot added this to the Gutenberg 22.4 milestone Dec 11, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Focus] Accessibility (a11y) Changes that impact accessibility and need corresponding review (e.g. markup changes). [Package] Components /packages/components [Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Components: Validated form components should cancel browser invalid handling

4 participants