diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 74369682dcdb1a..480c09c1f64631 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -5,6 +5,7 @@ ### Bug Fixes - `InputControl`: Vertically center the value of date and time inputs in Safari ([#81361](https://github.com/WordPress/gutenberg/pull/81361)). +- `ControlWithError`: Re-read the target's validation message when an `invalid` event is received, so a message that changed without a re-render in between (e.g. a programmatic value change followed by a synthetic `invalid` event) is not revealed stale or empty. While a `validating` custom validity is pending, the message is left untouched so the pending indicator keeps showing ([#81440](https://github.com/WordPress/gutenberg/pull/81440)). ### Internal diff --git a/packages/components/src/validated-form-controls/control-with-error.tsx b/packages/components/src/validated-form-controls/control-with-error.tsx index 7fe598d7038e56..57a769e3fa54f9 100644 --- a/packages/components/src/validated-form-controls/control-with-error.tsx +++ b/packages/components/src/validated-form-controls/control-with-error.tsx @@ -102,13 +102,20 @@ function UnforwardedControlWithError< C extends React.ReactElement >( useEffect( () => { const validityTarget = getValidityTarget(); const handler = () => { + // Re-read the message: the target's validity may have changed + // since it was last sampled, without a re-render in between. + // While async validation is pending, keep its indicator instead + // of showing a message its result may supersede. + if ( customValidity?.type !== 'validating' ) { + setErrorMessage( validityTarget?.validationMessage ); + } setShowMessage( true ); validityTarget?.setAttribute( VALIDITY_VISIBLE_ATTRIBUTE, '' ); }; validityTarget?.addEventListener( 'invalid', handler ); return () => validityTarget?.removeEventListener( 'invalid', handler ); - }, [ getValidityTarget ] ); + }, [ customValidity?.type, getValidityTarget ] ); // Suppress the native error popover, while keeping the focus behavior intact. useEffect( () => { diff --git a/packages/components/src/validated-form-controls/test/control-with-error.tsx b/packages/components/src/validated-form-controls/test/control-with-error.tsx index dd31e53143dfe3..953b19c92841c5 100644 --- a/packages/components/src/validated-form-controls/test/control-with-error.tsx +++ b/packages/components/src/validated-form-controls/test/control-with-error.tsx @@ -210,6 +210,56 @@ describe( 'ControlWithError', () => { } ); } ); + describe( 'Reveal during pending validation', () => { + it( 'should keep the pending indicator instead of a native error on a synthetic `invalid` event', async () => { + const user = userEvent.setup(); + + function PendingValidatedInputControl() { + const ref = useRef< HTMLInputElement >( null ); + return ( + <> + {} } + customValidity={ { + type: 'validating', + message: 'Validating...', + } } + /> + + + ); + } + + render( ); + + await user.click( + screen.getByRole( 'button', { name: 'Show errors' } ) + ); + + await waitFor( () => { + expect( screen.getByText( 'Validating...' ) ).toBeVisible(); + } ); + expect( + screen.queryByText( 'Constraints not satisfied' ) + ).not.toBeInTheDocument(); + } ); + } ); + describe( 'Form submission', () => { const CustomValidatedInputControl = ( { ...restProps diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 400f3ee0b8f2ac..2a766a4baad381 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Bug Fix + +- DataForm: Send a single update per calendar interaction in the `datetime` control instead of two identical ones. Selecting or clearing a date now reveals the validation message by firing a synthetic `invalid` event on the input, rather than briefly moving focus into it and re-sending the value, and announces it to screen readers since focus stays on the calendar. [#81440](https://github.com/WordPress/gutenberg/pull/81440) + ### Internal - DataForm: Internalize `ValidatedComboboxControl` instead of unlocking it from the `@wordpress/components` private APIs. [#81449](https://github.com/WordPress/gutenberg/pull/81449) diff --git a/packages/dataviews/src/components/dataform-controls/datetime.tsx b/packages/dataviews/src/components/dataform-controls/datetime.tsx index a408b50b9eeb3d..45b41780a99dfc 100644 --- a/packages/dataviews/src/components/dataform-controls/datetime.tsx +++ b/packages/dataviews/src/components/dataform-controls/datetime.tsx @@ -4,6 +4,7 @@ import { } from '@wordpress/components'; import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; import { dateI18n, getDate, getSettings } from '@wordpress/date'; import { Calendar, Stack } from '@wordpress/ui'; import type { DataFormControlProps, FormatDatetime } from '../../types'; @@ -47,7 +48,6 @@ function CalendarDateTimeControl< Item >( { const inputControlRef = useRef< HTMLInputElement >( null ); const validationTimeoutRef = useRef< ReturnType< typeof setTimeout > >( undefined ); - const previousFocusRef = useRef< Element | null >( null ); const { minConstraint, maxConstraint, disabledMatchers } = useDisabledDateMatchers( isValid, parseDateTime ); @@ -60,16 +60,11 @@ function CalendarDateTimeControl< Item >( { // Cleanup timeout on unmount useEffect( () => { - return () => { - if ( validationTimeoutRef.current ) { - clearTimeout( validationTimeoutRef.current ); - } - }; + return () => clearTimeout( validationTimeoutRef.current ); }, [] ); const onSelectDate = useCallback( ( newDate: Date | null ) => { - let dateTimeValue: string | undefined; if ( newDate ) { // Extract the date part in WP timezone from the calendar selection const wpDate = dateI18n( 'Y-m-d', newDate ); @@ -84,36 +79,33 @@ function CalendarDateTimeControl< Item >( { // Combine date and time in WP timezone and convert to ISO const finalDateTime = getDate( `${ wpDate }T${ wpTime }` ); - dateTimeValue = finalDateTime.toISOString(); - onChangeCallback( dateTimeValue ); - - // Clear any existing timeout - if ( validationTimeoutRef.current ) { - clearTimeout( validationTimeoutRef.current ); - } + onChangeCallback( finalDateTime.toISOString() ); } else { onChangeCallback( undefined ); } - // Save the currently focused element - previousFocusRef.current = - inputControlRef.current && - inputControlRef.current.ownerDocument.activeElement; - // Trigger validation display by simulating focus, blur, and changes. - // Use a timeout to ensure it runs after the value update. + // A calendar interaction counts as touching the field: reveal the + // input's validity state by firing a synthetic `invalid` event, + // which the validated control listens to in order to display its + // error message without moving focus (unlike `reportValidity()`). + // The control re-reads the message on this event, so dispatching + // unconditionally is also what clears a stale error once a valid + // date is selected. + // The timeout ensures the input has re-rendered with the new + // value before its validity is sampled. + clearTimeout( validationTimeoutRef.current ); validationTimeoutRef.current = setTimeout( () => { - if ( inputControlRef.current ) { - inputControlRef.current.focus(); - inputControlRef.current.blur(); - onChangeCallback( dateTimeValue ); - - // Restore focus to the previously focused element - if ( - previousFocusRef.current && - previousFocusRef.current instanceof HTMLElement - ) { - previousFocusRef.current.focus(); - } + const input = inputControlRef.current; + if ( ! input ) { + return; + } + input.dispatchEvent( + new Event( 'invalid', { cancelable: true } ) + ); + // Focus stays on the calendar, so announce the message; + // revealing it alone would go unnoticed by screen readers. + if ( input.validationMessage ) { + speak( input.validationMessage ); } }, 0 ); }, diff --git a/packages/dataviews/src/components/validated-form-controls/control-with-error.tsx b/packages/dataviews/src/components/validated-form-controls/control-with-error.tsx index 1737d66207fcd5..292b2641a50edd 100644 --- a/packages/dataviews/src/components/validated-form-controls/control-with-error.tsx +++ b/packages/dataviews/src/components/validated-form-controls/control-with-error.tsx @@ -108,13 +108,20 @@ function UnforwardedControlWithError< C extends React.ReactElement >( useEffect( () => { const validityTarget = getValidityTarget(); const handler = () => { + // Re-read the message: the target's validity may have changed + // since it was last sampled, without a re-render in between. + // While async validation is pending, keep its indicator instead + // of showing a message its result may supersede. + if ( customValidity?.type !== 'validating' ) { + setErrorMessage( validityTarget?.validationMessage ); + } setShowMessage( true ); validityTarget?.setAttribute( VALIDITY_VISIBLE_ATTRIBUTE, '' ); }; validityTarget?.addEventListener( 'invalid', handler ); return () => validityTarget?.removeEventListener( 'invalid', handler ); - }, [ getValidityTarget ] ); + }, [ customValidity?.type, getValidityTarget ] ); // Suppress the native error popover, while keeping the focus behavior intact. useEffect( () => { diff --git a/packages/dataviews/src/components/validated-form-controls/test/control-with-error.tsx b/packages/dataviews/src/components/validated-form-controls/test/control-with-error.tsx new file mode 100644 index 00000000000000..96d808a25c00b4 --- /dev/null +++ b/packages/dataviews/src/components/validated-form-controls/test/control-with-error.tsx @@ -0,0 +1,60 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useRef } from '@wordpress/element'; +import { ValidatedSelectControl } from '../select-control'; + +describe( 'ControlWithError', () => { + describe( 'Reveal during pending validation', () => { + it( 'should keep the pending indicator instead of a native error on a synthetic `invalid` event', async () => { + const user = userEvent.setup(); + + function PendingValidatedSelectControl() { + const ref = useRef< HTMLSelectElement >( null ); + return ( + <> + {} } + customValidity={ { + type: 'validating', + message: 'Validating...', + } } + /> + + + ); + } + + render( ); + + await user.click( + screen.getByRole( 'button', { name: 'Show errors' } ) + ); + + await waitFor( () => { + expect( screen.getByText( 'Validating...' ) ).toBeVisible(); + } ); + expect( + screen.queryByText( 'Constraints not satisfied' ) + ).not.toBeInTheDocument(); + } ); + } ); +} ); diff --git a/packages/dataviews/src/dataform/test/dataform.tsx b/packages/dataviews/src/dataform/test/dataform.tsx index 63b40ef7ada82c..f6f7e1bb1c7f4f 100644 --- a/packages/dataviews/src/dataform/test/dataform.tsx +++ b/packages/dataviews/src/dataform/test/dataform.tsx @@ -1000,4 +1000,123 @@ describe( 'DataForm component', () => { expect( speak ).not.toHaveBeenCalled(); } ); } ); + + describe( 'datetime fields', () => { + const datetimeFields = [ + { + id: 'date', + label: 'Date', + type: 'datetime' as const, + isValid: { required: true }, + }, + ]; + + const datetimeForm = { + fields: [ 'date' ], + }; + + const dayButton = ( date: Date ) => + screen.getByRole( 'button', { + name: new RegExp( + new Intl.DateTimeFormat( 'en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + } ).format( date ) + ), + } ); + + // Waits out any timeouts scheduled by the control, so that a + // duplicate update scheduled for a later tick is not missed. + const flushTimeouts = () => + act( + () => new Promise( ( resolve ) => setTimeout( resolve, 10 ) ) + ); + + function ControlledForm( { + onChange: onChangeProp = noop, + }: { + onChange?: ( edits: { date?: string } ) => void; + } ) { + const [ item, setItem ] = useState< { + date: string | undefined; + } >( { date: '2026-01-10T10:00:00.000Z' } ); + return ( + { + onChangeProp( edits ); + setItem( ( prev ) => ( { ...prev, ...edits } ) ); + } } + fields={ datetimeFields } + form={ datetimeForm } + data={ item } + /> + ); + } + + it( 'should call onChange once when a date is selected in the calendar', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + render( + + ); + + await user.click( dayButton( new Date( 2026, 0, 15 ) ) ); + await flushTimeouts(); + + expect( onChange ).toHaveBeenCalledTimes( 1 ); + // The time is preserved from the previous value. + expect( onChange ).toHaveBeenCalledWith( { + date: '2026-01-15T10:00:00.000Z', + } ); + expect( speak ).not.toHaveBeenCalled(); + } ); + + it( 'should call onChange once and show the required error when the date is cleared, keeping focus on the day button', async () => { + const onChange = jest.fn(); + const user = userEvent.setup(); + + render( ); + + // Clicking the selected day deselects it. + await user.click( dayButton( new Date( 2026, 0, 10 ) ) ); + await flushTimeouts(); + + expect( onChange ).toHaveBeenCalledTimes( 1 ); + expect( onChange ).toHaveBeenCalledWith( { date: undefined } ); + expect( + await screen.findByText( 'Constraints not satisfied' ) + ).toBeVisible(); + // Focus does not move, so the error is announced instead. + expect( speak ).toHaveBeenCalledWith( 'Constraints not satisfied' ); + expect( dayButton( new Date( 2026, 0, 10 ) ) ).toHaveFocus(); + } ); + + it( 'should clear the revealed error when a valid date is selected in the calendar', async () => { + const user = userEvent.setup(); + + render( ); + + // Clicking the selected day deselects it, making the field invalid. + await user.click( dayButton( new Date( 2026, 0, 10 ) ) ); + await flushTimeouts(); + + expect( + await screen.findByText( 'Constraints not satisfied' ) + ).toBeVisible(); + + await user.click( dayButton( new Date( 2026, 0, 15 ) ) ); + await flushTimeouts(); + + expect( + screen.queryByText( 'Constraints not satisfied' ) + ).not.toBeInTheDocument(); + } ); + } ); } );