diff --git a/.changeset/timeinput-typed-invalid.md b/.changeset/timeinput-typed-invalid.md new file mode 100644 index 000000000000..70ba22b4cde1 --- /dev/null +++ b/.changeset/timeinput-typed-invalid.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] TimeInput: typed-invalid input (e.g. an out-of-range time that will be reverted on blur) now sets `aria-invalid` and announces "Invalid time" via an assertive live region, matching DateInput, NumberInput, and DateTimeInput. Previously only the visual red border signalled the invalid state. (#3718) +@bhamodi diff --git a/packages/core/src/TimeInput/TimeInput.test.tsx b/packages/core/src/TimeInput/TimeInput.test.tsx index 61f1b8baed59..e6c0d7e9c9e3 100644 --- a/packages/core/src/TimeInput/TimeInput.test.tsx +++ b/packages/core/src/TimeInput/TimeInput.test.tsx @@ -160,6 +160,43 @@ describe('TimeInput', () => { expect(onChange).not.toHaveBeenCalled(); }); + it('sets aria-invalid="true" when typed input is out of range', () => { + render( {}} />); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, {target: {value: '25:99'}}); + + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('does not set aria-invalid when typed input is a valid time', () => { + render( {}} />); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, {target: {value: '3:45 pm'}}); + + expect(input).not.toHaveAttribute('aria-invalid'); + }); + + it('announces an alert message when typed input is invalid', () => { + render( {}} />); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, {target: {value: '25:99'}}); + + expect(screen.getByRole('alert')).toHaveTextContent('Invalid time'); + }); + + it('does not announce an alert message when input is valid', () => { + render( {}} />); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, {target: {value: '3:45 pm'}}); + + expect(screen.getByRole('alert')).toHaveTextContent(''); + expect(screen.queryByText('Invalid time')).not.toBeInTheDocument(); + }); + it('calls onChange on blur when input is valid', async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/packages/core/src/TimeInput/TimeInput.tsx b/packages/core/src/TimeInput/TimeInput.tsx index 1939ede5853d..9de7ebc8ecbc 100644 --- a/packages/core/src/TimeInput/TimeInput.tsx +++ b/packages/core/src/TimeInput/TimeInput.tsx @@ -635,7 +635,9 @@ export function TimeInput({ data-autofocus={hasAutoFocus || undefined} aria-describedby={ariaDescribedBy} aria-required={isRequired === true ? 'true' : undefined} - aria-invalid={status?.type === 'error' ? 'true' : undefined} + aria-invalid={ + status?.type === 'error' || !isInputValid ? 'true' : undefined + } aria-busy={isBusy || undefined} aria-labelledby={ariaLabelledBy} {...stylex.props( @@ -644,6 +646,14 @@ export function TimeInput({ !isInputValid && styles.inputInvalid, )} /> + {/* + Live region announcing invalid typed input to assistive technology. + The value silently reverts on blur, so without this a screen-reader + user would get no feedback that their entry was rejected (WCAG 3.3.1). + */} + + {!isInputValid ? 'Invalid time' : ''} + {isBusy && } {hasClear && value && !isDisabled && (