Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -743,8 +743,11 @@ describe('CheckboxGroup', () => {
let group = getByRole('group');
expect(group).not.toHaveAttribute('aria-describedby');

act(() => {
await act(async () => {
getByTestId('form').checkValidity();
// Flush the microtask queued by the global invalid event handler,
// which moves focus to the first invalid field and updates modality.
await Promise.resolve();
});
expect(group).toHaveAttribute('aria-describedby');
expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent(
Expand Down
5 changes: 4 additions & 1 deletion packages/@adobe/react-spectrum/test/radio/Radio.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1051,8 +1051,11 @@ describe('Radios', function () {
let group = getByRole('radiogroup');
expect(group).not.toHaveAttribute('aria-describedby');

act(() => {
await act(async () => {
getByTestId('form').checkValidity();
// Flush the microtask queued by the global invalid event handler,
// which moves focus to the first invalid field and updates modality.
await Promise.resolve();
});
expect(group).toHaveAttribute('aria-describedby');
expect(document.getElementById(group.getAttribute('aria-describedby'))).toHaveTextContent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -768,8 +768,11 @@ describe('Shared TextField behavior', () => {
let input = getByTestId('input');
expect(input).not.toHaveAttribute('aria-describedby');

act(() => {
await act(async () => {
getByTestId('form').checkValidity();
// Flush the microtask queued by the global invalid event handler,
// which moves focus to the first invalid field and updates modality.
await Promise.resolve();
});
expect(input).toHaveAttribute('aria-describedby');
expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent(
Expand Down
32 changes: 32 additions & 0 deletions packages/react-aria-components/test/Form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,38 @@ describe('Form', () => {
expect(form).toHaveAttribute('data-custom', 'true');
});

(parseInt(React.version, 10) >= 19 ? it : it.skip)('shows focus-visible when a form library moves focus to the first invalid field on submit', async () => {
function Test() {
return (
<form data-testid="form">
<Input aria-label="Name" name="name" required />
<Button type="submit">Submit</Button>
</form>
);
}

let {getByRole} = render(<Test />);
let input = getByRole('textbox');
let button = getByRole('button');

await user.click(button);
expect(input).not.toHaveAttribute('data-focus-visible');

// On submit the form is invalid, so the browser fires an invalid event on
// the required field. react-hook-form (shouldFocusError) then moves focus
// to the first invalid field with a plain ref.focus().
act(() => {
input.checkValidity();
input.focus();
});
await act(async () => {
await Promise.resolve();
});

expect(document.activeElement).toBe(input);
expect(input).toHaveAttribute('data-focus-visible');
});

it('should not throw when form contains elements without validity property', async () => {
function Test() {
return (
Expand Down
16 changes: 16 additions & 0 deletions packages/react-aria/src/interactions/useFocusVisible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,18 @@ function handleWindowBlur() {
hasBlurredWindowRecently = true;
}

function handleInvalidEvent(e: Event) {
let startingActiveElement = getActiveElement(getOwnerDocument(getEventTarget(e)));
queueMicrotask(() => {
// If focus was moved to a different element after the form became invalid,
// then it was likely a forms library that moved focus to the first invalid field.
// In this case, we want to set the modality to keyboard.
if (getActiveElement(getOwnerDocument(getEventTarget(e))) !== startingActiveElement) {
setInteractionModality('keyboard');
}
});
}

/**
* Setup global event listeners to control when keyboard focus style should be visible.
*/
Expand Down Expand Up @@ -184,6 +196,8 @@ function setupGlobalFocusEvents(element?: HTMLElement | null) {
documentObject.addEventListener('keyup', handleKeyboardEvent, true);
documentObject.addEventListener('click', handleClickEvent, true);

documentObject.addEventListener('invalid', handleInvalidEvent, true);

// Register focus events on the window so they are sure to happen
// before React's event listeners (registered on the document).
windowObject.addEventListener('focus', handleFocusEvent, true);
Expand Down Expand Up @@ -230,6 +244,8 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => {
documentObject.removeEventListener('keyup', handleKeyboardEvent, true);
documentObject.removeEventListener('click', handleClickEvent, true);

documentObject.removeEventListener('invalid', handleInvalidEvent, true);

windowObject.removeEventListener('focus', handleFocusEvent, true);
windowObject.removeEventListener('blur', handleWindowBlur, false);

Expand Down