diff --git a/.changeset/empty-laws-boil.md b/.changeset/empty-laws-boil.md new file mode 100644 index 00000000000..508f1b4d3ce --- /dev/null +++ b/.changeset/empty-laws-boil.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': patch +--- + +Ensure inputs are properly connected to feedback messages via `aria-describedby` usage. diff --git a/packages/clerk-js/src/ui/elements/FieldControl.tsx b/packages/clerk-js/src/ui/elements/FieldControl.tsx index 8675181485a..f6166cad5ce 100644 --- a/packages/clerk-js/src/ui/elements/FieldControl.tsx +++ b/packages/clerk-js/src/ui/elements/FieldControl.tsx @@ -154,12 +154,11 @@ const FieldLabelRow = (props: PropsWithChildren) => { }; const FieldFeedback = (props: Pick) => { - const { fieldId, debouncedFeedback, errorMessageId } = useFormField(); + const { fieldId, debouncedFeedback } = useFormField(); return ( ['debounced'] & { id: FieldId }> & { - errorMessageId?: string; elementDescriptors?: Partial>; center?: boolean; sx?: ThemableCssProp; }; export const FormFeedback = (props: FormFeedbackProps) => { - const { id, elementDescriptors, sx, feedback, feedbackType = 'info', center = false, errorMessageId } = props; + const { id, elementDescriptors, sx, feedback, feedbackType = 'info', center = false } = props; const feedbacksRef = useRef<{ a?: Feedback; b?: Feedback; @@ -143,10 +142,8 @@ export const FormFeedback = (props: FormFeedbackProps) => { return { elementDescriptor: descriptor, elementId: id ? descriptor?.setId?.(id) : undefined, - // We only want the id applied when the feedback type is an error - // to avoid having multiple elements in the dom with the same id attribute. - // We also only have aria-describedby applied to the input when it is an error. - id: type === 'error' ? errorMessageId : undefined, + // Use legacy pattern for errors (backwards compatible), new pattern for other types + id: type === 'error' ? `error-${id}` : `${id}-${type}-feedback`, }; }; @@ -182,6 +179,7 @@ export const FormFeedback = (props: FormFeedbackProps) => { getFormTextAnimation(!!feedbacks.a?.shouldEnter, { inDelay: true }), ]} localizationKey={titleize(feedbacks.a?.feedback)} + aria-live={feedbacks.a?.shouldEnter ? 'polite' : 'off'} /> { getFormTextAnimation(!!feedbacks.b?.shouldEnter, { inDelay: true }), ]} localizationKey={titleize(feedbacks.b?.feedback)} + aria-live={feedbacks.b?.shouldEnter ? 'polite' : 'off'} /> ); diff --git a/packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx b/packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx index f068914d784..6adaec9b8f2 100644 --- a/packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx +++ b/packages/clerk-js/src/ui/elements/__tests__/PlainInput.test.tsx @@ -21,6 +21,9 @@ const createField = (...params: Parameters) => { {...props} /> + + + ); }); @@ -132,15 +135,20 @@ describe('PlainInput', () => { placeholder: 'some placeholder', }); - const { getByRole, getByLabelText, findByText } = render(, { wrapper }); + const { getByRole, getByLabelText, findByText, container } = render(, { wrapper }); await userEvent.click(getByRole('button', { name: /set error/i })); expect(await findByText(/Some Error/i)).toBeInTheDocument(); - const label = getByLabelText(/some label/i); - expect(label).toHaveAttribute('aria-invalid', 'true'); - expect(label).toHaveAttribute('aria-describedby', 'error-firstname'); + const input = getByLabelText(/some label/i); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAttribute('aria-describedby', 'error-firstname'); + + // Verify the error message element has the correct ID + const errorElement = container.querySelector('#error-firstname'); + expect(errorElement).toBeInTheDocument(); + expect(errorElement).toHaveTextContent(/Some Error/i); }); it('with info', async () => { @@ -157,4 +165,92 @@ describe('PlainInput', () => { fireEvent.focus(await findByLabelText(/some label/i)); expect(await findByText(/some info/i)).toBeInTheDocument(); }); + + it('with success feedback and aria-describedby', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByRole, getByLabelText, findByText, container } = render(, { wrapper }); + + await userEvent.click(getByRole('button', { name: /set success/i })); + + expect(await findByText(/Some Success/i)).toBeInTheDocument(); + + const input = getByLabelText(/some label/i); + expect(input).toHaveAttribute('aria-invalid', 'false'); + expect(input).toHaveAttribute('aria-describedby', 'firstname-success-feedback'); + + // Verify the success message element has the correct ID + const successElement = container.querySelector('#firstname-success-feedback'); + expect(successElement).toBeInTheDocument(); + expect(successElement).toHaveTextContent(/Some Success/i); + }); + + it('transitions between error and success feedback types', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByRole, getByLabelText, findByText, container } = render(, { wrapper }); + + // Start with error + await userEvent.click(getByRole('button', { name: /set error/i })); + expect(await findByText(/Some Error/i)).toBeInTheDocument(); + + let input = getByLabelText(/some label/i); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAttribute('aria-describedby', 'error-firstname'); + + // Transition to success + await userEvent.click(getByRole('button', { name: /set success/i })); + expect(await findByText(/Some Success/i)).toBeInTheDocument(); + + input = getByLabelText(/some label/i); + expect(input).toHaveAttribute('aria-invalid', 'false'); + expect(input).toHaveAttribute('aria-describedby', 'firstname-success-feedback'); + + // Verify success element exists with proper ID + const successElement = container.querySelector('#firstname-success-feedback'); + expect(successElement).toBeInTheDocument(); + expect(successElement).toHaveTextContent(/Some Success/i); + }); + + it('aria-live attribute is correctly applied', async () => { + const { wrapper } = await createFixtures(); + const { Field } = createField('firstname', 'init value', { + type: 'text', + label: 'some label', + placeholder: 'some placeholder', + }); + + const { getByRole, findByText, container } = render(, { wrapper }); + + // Set error feedback + await userEvent.click(getByRole('button', { name: /set error/i })); + expect(await findByText(/Some Error/i)).toBeInTheDocument(); + + // Verify the visible error message has aria-live="polite" + const errorElement = container.querySelector('#error-firstname'); + expect(errorElement).toHaveAttribute('aria-live', 'polite'); + + // Transition to success + await userEvent.click(getByRole('button', { name: /set success/i })); + expect(await findByText(/Some Success/i)).toBeInTheDocument(); + + // Verify the visible success message has aria-live="polite" + const successElement = container.querySelector('#firstname-success-feedback'); + expect(successElement).toHaveAttribute('aria-live', 'polite'); + + // The previous error message should now have aria-live="off" (though it might still exist in DOM but hidden) + // Verify exactly one element has aria-live="polite" at a time + const allAriaLivePolite = container.querySelectorAll('[aria-live="polite"]'); + expect(allAriaLivePolite.length).toBe(1); + }); }); diff --git a/packages/clerk-js/src/ui/primitives/FormErrorText.tsx b/packages/clerk-js/src/ui/primitives/FormErrorText.tsx index 4850365f4a5..d00b7acdfdc 100644 --- a/packages/clerk-js/src/ui/primitives/FormErrorText.tsx +++ b/packages/clerk-js/src/ui/primitives/FormErrorText.tsx @@ -23,7 +23,7 @@ const { applyVariants } = createVariants(theme => ({ type FormErrorTextProps = React.PropsWithChildren>; export const FormErrorText = forwardRef((props, ref) => { - const { hasError, errorMessageId } = useFormField() || {}; + const { hasError } = useFormField() || {}; if (!hasError && !props.children) { return null; @@ -35,8 +35,6 @@ export const FormErrorText = forwardRef((props, diff --git a/packages/clerk-js/src/ui/primitives/FormInfoText.tsx b/packages/clerk-js/src/ui/primitives/FormInfoText.tsx index 36c4fc0e75c..9ee7365d5b3 100644 --- a/packages/clerk-js/src/ui/primitives/FormInfoText.tsx +++ b/packages/clerk-js/src/ui/primitives/FormInfoText.tsx @@ -6,7 +6,7 @@ import { useFormField } from './hooks/useFormField'; import { Text } from './Text'; export const FormInfoText = forwardRef((props, ref) => { - const { hasError, errorMessageId } = useFormField() || {}; + const { hasError } = useFormField() || {}; if (!hasError && !props.children) { return null; @@ -16,8 +16,6 @@ export const FormInfoText = forwardRef((props, ref) diff --git a/packages/clerk-js/src/ui/primitives/FormSuccessText.tsx b/packages/clerk-js/src/ui/primitives/FormSuccessText.tsx index d46f9454946..cb85a621e25 100644 --- a/packages/clerk-js/src/ui/primitives/FormSuccessText.tsx +++ b/packages/clerk-js/src/ui/primitives/FormSuccessText.tsx @@ -28,7 +28,6 @@ export const FormSuccessText = forwardRef((props, re diff --git a/packages/clerk-js/src/ui/primitives/FormWarningText.tsx b/packages/clerk-js/src/ui/primitives/FormWarningText.tsx index 381d356fbb8..9132881b954 100644 --- a/packages/clerk-js/src/ui/primitives/FormWarningText.tsx +++ b/packages/clerk-js/src/ui/primitives/FormWarningText.tsx @@ -13,7 +13,6 @@ export const FormWarningText = forwardRef((props, re diff --git a/packages/clerk-js/src/ui/primitives/Input.tsx b/packages/clerk-js/src/ui/primitives/Input.tsx index 5defcaa5579..ad1395c2143 100644 --- a/packages/clerk-js/src/ui/primitives/Input.tsx +++ b/packages/clerk-js/src/ui/primitives/Input.tsx @@ -63,10 +63,10 @@ export type InputProps = PrimitiveProps<'input'> & StyleVariants((props, ref) => { const fieldControl = useFormField() || {}; - // @ts-expect-error Typescript is complaining that `errorMessageId` does not exist. We are clearly passing them from above. - const { errorMessageId, ignorePasswordManager, feedbackType, ...fieldControlProps } = sanitizeInputProps( + // @ts-expect-error Typescript is complaining that `feedbackMessageId` does not exist. We are clearly passing them from above. + const { feedbackMessageId, ignorePasswordManager, feedbackType, ...fieldControlProps } = sanitizeInputProps( fieldControl, - ['errorMessageId', 'ignorePasswordManager', 'feedbackType'], + ['feedbackMessageId', 'ignorePasswordManager', 'feedbackType'], ); const propsWithoutVariants = filterProps({ @@ -106,7 +106,7 @@ export const Input = React.forwardRef((props, ref) required={_required} id={props.id || fieldControlProps.id} aria-invalid={_hasError} - aria-describedby={errorMessageId ? errorMessageId : undefined} + aria-describedby={feedbackMessageId || undefined} aria-required={_required} aria-disabled={_disabled} data-feedback={feedbackType} diff --git a/packages/clerk-js/src/ui/primitives/hooks/useFormField.tsx b/packages/clerk-js/src/ui/primitives/hooks/useFormField.tsx index 662fa6a75ee..03856d2473f 100644 --- a/packages/clerk-js/src/ui/primitives/hooks/useFormField.tsx +++ b/packages/clerk-js/src/ui/primitives/hooks/useFormField.tsx @@ -10,7 +10,7 @@ type FormFieldProviderProps = ReturnType>['pr }; type FormFieldContextValue = Omit & { - errorMessageId?: string; + feedbackMessageId?: string; id?: string; fieldId?: FieldId; hasError: boolean; @@ -49,9 +49,14 @@ export const FormFieldContextProvider = (props: React.PropsWithChildren ({ isRequired, @@ -59,7 +64,7 @@ export const FormFieldContextProvider = (props: React.PropsWithChildren