-
Notifications
You must be signed in to change notification settings - Fork 391
fix(clerk-js): Ensure input feedback messages are mapped to the input #6914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
898042a
63fc9dc
402fbf4
8192f55
db1c3e0
651f11b
48dada3
dde3fd5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
import { fireEvent, render } from '@testing-library/react'; | ||
import { fireEvent, render, waitFor } from '@testing-library/react'; | ||
import userEvent from '@testing-library/user-event'; | ||
import { describe, expect, it } from 'vitest'; | ||
|
||
|
@@ -21,6 +21,9 @@ const createField = (...params: Parameters<typeof useFormControl>) => { | |
{...props} | ||
/> | ||
<button onClick={() => field.setError('some error')}>set error</button> | ||
<button onClick={() => field.setSuccess('some success')}>set success</button> | ||
<button onClick={() => field.setWarning('some warning')}>set warning</button> | ||
<button onClick={() => field.setInfo('some info')}>set info</button> | ||
</> | ||
); | ||
}); | ||
|
@@ -132,15 +135,21 @@ describe('PlainInput', () => { | |
placeholder: 'some placeholder', | ||
}); | ||
|
||
const { getByRole, getByLabelText, findByText } = render(<Field />, { wrapper }); | ||
const { getByRole, getByLabelText, findByText, container } = render(<Field />, { 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'); | ||
// The input should have both error IDs in aria-describedby | ||
expect(input).toHaveAttribute('aria-describedby', 'error-firstname firstname-error-feedback'); | ||
|
||
// 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 +166,94 @@ 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(<Field />, { 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(<Field />, { 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'); | ||
// Error includes both IDs for backwards compatibility and new behavior | ||
expect(input).toHaveAttribute('aria-describedby', 'error-firstname firstname-error-feedback'); | ||
|
||
// 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(<Field />, { 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) | ||
// We can verify the currently visible element has aria-live="polite" | ||
const allAriaLivePolite = container.querySelectorAll('[aria-live="polite"]'); | ||
// At least the success message should have aria-live="polite" | ||
expect(allAriaLivePolite.length).toBeGreaterThanOrEqual(1); | ||
|
||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -63,11 +63,9 @@ export type InputProps = PrimitiveProps<'input'> & StyleVariants<typeof applyVar | |
|
||
export const Input = React.forwardRef<HTMLInputElement, InputProps>((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( | ||
fieldControl, | ||
['errorMessageId', 'ignorePasswordManager', 'feedbackType'], | ||
); | ||
// @ts-expect-error Typescript is complaining that `errorMessageId` and `feedbackMessageId` do not exist. We are clearly passing them from above. | ||
const { errorMessageId, feedbackMessageId, ignorePasswordManager, feedbackType, ...fieldControlProps } = | ||
sanitizeInputProps(fieldControl, ['errorMessageId', 'feedbackMessageId', 'ignorePasswordManager', 'feedbackType']); | ||
|
||
|
||
const propsWithoutVariants = filterProps({ | ||
...props, | ||
|
@@ -106,7 +104,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>((props, ref) | |
required={_required} | ||
id={props.id || fieldControlProps.id} | ||
aria-invalid={_hasError} | ||
aria-describedby={errorMessageId ? errorMessageId : undefined} | ||
aria-describedby={[errorMessageId, feedbackMessageId].filter(Boolean).join(' ') || undefined} | ||
aria-required={_required} | ||
aria-disabled={_disabled} | ||
data-feedback={feedbackType} | ||
|
Uh oh!
There was an error while loading. Please reload this page.