diff --git a/.changeset/five-windows-invent.md b/.changeset/five-windows-invent.md new file mode 100644 index 00000000000..a2bf85b1566 --- /dev/null +++ b/.changeset/five-windows-invent.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Adds `aria-describedby` for `LeadingVisual` and `TrailingVisual` in `TextInput`; adds new prop `loaderText` to convey loading state to screen readers diff --git a/packages/react/src/TextInput/TextInput.docs.json b/packages/react/src/TextInput/TextInput.docs.json index 630c92ac4c8..01e360eb133 100644 --- a/packages/react/src/TextInput/TextInput.docs.json +++ b/packages/react/src/TextInput/TextInput.docs.json @@ -48,6 +48,12 @@ "defaultValue": "", "description": "
Which position to render the loading indicator
" }, + { + "name": "loaderText", + "type": "string", + "defaultValue": "Loading", + "description": "Text for screen readers to convey the loading state, should be descriptive and explain what is loading. This prop should only be used if there is visible context explaining what is loading, to ensure that context is provided to all users." + }, { "name": "leadingVisual", "type": "string | React.ComponentType", diff --git a/packages/react/src/TextInput/TextInput.features.stories.tsx b/packages/react/src/TextInput/TextInput.features.stories.tsx index 345db209499..bda1ff99d3c 100644 --- a/packages/react/src/TextInput/TextInput.features.stories.tsx +++ b/packages/react/src/TextInput/TextInput.features.stories.tsx @@ -98,31 +98,39 @@ export const Required = () => ( ) -export const WithLeadingVisual = () => ( - - - Default label - - - - Enter monies - - - -) +export const WithLeadingVisual = () => { + const Checkmark = () => -export const WithTrailingIcon = () => ( - - - Default label - - - - Enter monies - - - -) + return ( + + + Default label + + + + Enter monies + + + + ) +} + +export const WithTrailingIcon = () => { + const Checkmark = () => + + return ( + + + Default label + + + + Enter monies + + + + ) +} export const WithTrailingAction = () => { const [value, setValue] = useState('sample text') diff --git a/packages/react/src/TextInput/TextInput.tsx b/packages/react/src/TextInput/TextInput.tsx index e2d2acd0736..e272f04887f 100644 --- a/packages/react/src/TextInput/TextInput.tsx +++ b/packages/react/src/TextInput/TextInput.tsx @@ -1,5 +1,5 @@ import type {MouseEventHandler} from 'react' -import React, {useCallback, useState} from 'react' +import React, {useCallback, useState, useId} from 'react' import {isValidElementType} from 'react-is' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {clsx} from 'clsx' @@ -11,6 +11,7 @@ import type {StyledWrapperProps} from '../internal/components/TextInputWrapper' import TextInputWrapper from '../internal/components/TextInputWrapper' import TextInputAction from '../internal/components/TextInputInnerAction' import UnstyledTextInput from '../internal/components/UnstyledTextInput' +import VisuallyHidden from '../_VisuallyHidden' export type TextInputNonPassthroughProps = { /** @deprecated Use `leadingVisual` or `trailingVisual` prop instead */ @@ -24,6 +25,8 @@ export type TextInputNonPassthroughProps = { * 'trailing': at the end of the input **/ loaderPosition?: 'auto' | 'leading' | 'trailing' + /** Text for screen readers to convey the loading state */ + loaderText?: string /** * A visual that renders inside the input before the typing area */ @@ -67,6 +70,7 @@ const TextInput = React.forwardRef( disabled, loading, loaderPosition = 'auto', + loaderText = 'Loading', monospace, validationStatus, sx: sxProp, @@ -96,6 +100,18 @@ const TextInput = React.forwardRef( const focusInput: MouseEventHandler = () => { inputRef.current?.focus() } + const leadingVisualId = useId() + const trailingVisualId = useId() + const loadingId = useId() + + const inputDescribedBy = + clsx( + inputProps['aria-describedby'], + LeadingVisual && leadingVisualId, + TrailingVisual && trailingVisualId, + loading && loadingId, + ) || undefined + const handleInputFocus = useCallback( (e: React.FocusEvent) => { setIsInputFocused(true) @@ -137,6 +153,8 @@ const TextInput = React.forwardRef( visualPosition="leading" showLoadingIndicator={showLeadingLoadingIndicator} hasLoadingIndicator={typeof loading === 'boolean'} + id={leadingVisualId} + data-testid="text-input-leading-visual" > {typeof LeadingVisual !== 'string' && isValidElementType(LeadingVisual) ? : LeadingVisual} @@ -149,12 +167,16 @@ const TextInput = React.forwardRef( aria-required={required} aria-invalid={validationStatus === 'error' ? 'true' : undefined} {...inputProps} + aria-describedby={inputDescribedBy} data-component="input" /> + {loading && {loaderText}} {typeof TrailingVisual !== 'string' && isValidElementType(TrailingVisual) ? ( diff --git a/packages/react/src/__tests__/TextInput.test.tsx b/packages/react/src/__tests__/TextInput.test.tsx index 866e00cb09f..8632fa18046 100644 --- a/packages/react/src/__tests__/TextInput.test.tsx +++ b/packages/react/src/__tests__/TextInput.test.tsx @@ -236,4 +236,72 @@ describe('TextInput', () => { const {getByRole} = HTMLRender() expect(getByRole('textbox')).toHaveAttribute('aria-invalid', 'true') }) + + it('should include the leadingVisual as part of the input accessible description', () => { + const {getByRole} = HTMLRender() + expect(getByRole('textbox')).toHaveAccessibleDescription('Search') + }) + + it('should include the leadingVisual icon as part of the input accessible description', () => { + const Icon = () => + + const {getByRole} = HTMLRender() + const icon = getByRole('img', {hidden: true}) + + expect(getByRole('textbox')).toHaveAttribute('aria-describedby', icon.parentElement?.id) + expect(icon).toHaveAccessibleName('Search') + }) + + it('should include the trailingVisual as part of the input accessible description', () => { + const {getByRole} = HTMLRender() + expect(getByRole('textbox')).toHaveAccessibleDescription('Search') + }) + + it('should include the trailingVisual icon as part of the input accessible description', () => { + const Icon = () => + + const {getByRole} = HTMLRender() + const icon = getByRole('img', {hidden: true}) + + expect(getByRole('textbox')).toHaveAttribute('aria-describedby', icon.parentElement?.id) + expect(icon).toHaveAccessibleName('Search') + }) + + it('should include both the leadingVisual and trailingVisual as part of the input accessible description', () => { + const {getByRole} = HTMLRender() + expect(getByRole('textbox')).toHaveAccessibleDescription('$ Currency') + }) + + it('should keep the passed aria-describedby value', () => { + const {getByRole} = HTMLRender( + <> + value + + , + ) + expect(getByRole('textbox').getAttribute('aria-describedby')).toContain('passedValue') + expect(getByRole('textbox')).toHaveAccessibleDescription('value leading trailing') + }) + + it('should include the loading indicator as part of the input accessible description', () => { + const {getByRole} = HTMLRender() + expect(getByRole('textbox')).toHaveAccessibleDescription('Loading') + }) + + it('should include the leadingVisual and loading indicator as part of the input accessible description', () => { + const {getByRole} = HTMLRender( + , + ) + expect(getByRole('textbox')).toHaveAccessibleDescription('Search Loading search items') + }) + + it('should include the trailingVisual and loading indicator as part of the input accessible description', () => { + const {getByRole} = HTMLRender() + expect(getByRole('textbox')).toHaveAccessibleDescription('Search Loading') + }) + + it('should not have an aria-describedby if there is no leadingVisual, trailingVisual, or loading indicator', () => { + const {getByRole} = HTMLRender() + expect(getByRole('textbox')).not.toHaveAttribute('aria-describedby') + }) }) diff --git a/packages/react/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap b/packages/react/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap index 860d10e595d..9637ecc100e 100644 --- a/packages/react/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap +++ b/packages/react/src/__tests__/__snapshots__/Autocomplete.test.tsx.snap @@ -378,7 +378,7 @@ exports[`snapshots renders a loading state 1`] = ` > Loading diff --git a/packages/react/src/__tests__/__snapshots__/TextInput.test.tsx.snap b/packages/react/src/__tests__/__snapshots__/TextInput.test.tsx.snap index e7d70033076..2c8c4fe8878 100644 --- a/packages/react/src/__tests__/__snapshots__/TextInput.test.tsx.snap +++ b/packages/react/src/__tests__/__snapshots__/TextInput.test.tsx.snap @@ -794,7 +794,9 @@ exports[`TextInput renders leadingVisual 1`] = ` onClick={[Function]} >