diff --git a/packages/@adobe/react-spectrum/test/checkbox/CheckboxGroup.test.js b/packages/@adobe/react-spectrum/test/checkbox/CheckboxGroup.test.js
index 318b0cdf710..0201e86fbf2 100644
--- a/packages/@adobe/react-spectrum/test/checkbox/CheckboxGroup.test.js
+++ b/packages/@adobe/react-spectrum/test/checkbox/CheckboxGroup.test.js
@@ -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(
diff --git a/packages/@adobe/react-spectrum/test/radio/Radio.test.js b/packages/@adobe/react-spectrum/test/radio/Radio.test.js
index 6a668365f13..e2d6ce649a7 100644
--- a/packages/@adobe/react-spectrum/test/radio/Radio.test.js
+++ b/packages/@adobe/react-spectrum/test/radio/Radio.test.js
@@ -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(
diff --git a/packages/@adobe/react-spectrum/test/textfield/TextField.test.js b/packages/@adobe/react-spectrum/test/textfield/TextField.test.js
index a63420f4122..ae7c9b23673 100644
--- a/packages/@adobe/react-spectrum/test/textfield/TextField.test.js
+++ b/packages/@adobe/react-spectrum/test/textfield/TextField.test.js
@@ -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(
diff --git a/packages/dev/s2-docs/pages/react-aria/useFocusVisible.mdx b/packages/dev/s2-docs/pages/react-aria/useFocusVisible.mdx
index 27fb5cbf28b..20c785c72e2 100644
--- a/packages/dev/s2-docs/pages/react-aria/useFocusVisible.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/useFocusVisible.mdx
@@ -53,6 +53,20 @@ in CSS.
To determine whether a focus ring should be visible for an individual component rather than
globally, see [useFocusRing](useFocusRing).
+### Making focus visible
+
+For the most part, focus visibility is handled automatically by `useFocusVisible`. However, if you need to
+manually set the interaction modality, such as moving focus programmatically to the first item
+of an invalid form, you can use `setInteractionModality`.
+
+```tsx
+import {setInteractionModality} from 'react-aria/useFocusVisible';
+
+setInteractionModality('keyboard');
+```
+
+
+
## API
diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts
index b9c568ddb9f..9773c0a8ef0 100644
--- a/packages/react-aria-components/exports/index.ts
+++ b/packages/react-aria-components/exports/index.ts
@@ -276,6 +276,7 @@ export {Focusable} from 'react-aria/Focusable';
export {VisuallyHidden} from 'react-aria/VisuallyHidden';
export {FormValidationContext} from 'react-stately/private/form/useFormValidationState';
export {parseColor, getColorChannels} from 'react-stately/Color';
+export {setInteractionModality} from 'react-aria/useFocusVisible';
export {ToastQueue as UNSTABLE_ToastQueue} from 'react-stately/useToastState';
export {useListData} from 'react-stately/useListData';
export {useTreeData} from 'react-stately/useTreeData';
@@ -608,6 +609,7 @@ export type {DateRangePickerState} from 'react-stately/useDateRangePickerState';
export type {DisclosureState} from 'react-stately/useDisclosureState';
export type {DisclosureGroupState} from 'react-stately/useDisclosureGroupState';
export type {ListState} from 'react-stately/useListState';
+export type {Modality} from 'react-aria/useFocusVisible';
export type {NumberFieldState} from 'react-stately/useNumberFieldState';
export type {OverlayTriggerState} from 'react-stately/useOverlayTriggerState';
export type {QueuedToast, ToastOptions, ToastState} from 'react-stately/useToastState';
diff --git a/packages/react-aria-components/test/Form.test.js b/packages/react-aria-components/test/Form.test.js
index 7be04208d54..891fd74cef1 100644
--- a/packages/react-aria-components/test/Form.test.js
+++ b/packages/react-aria-components/test/Form.test.js
@@ -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 (
+
+ );
+ }
+
+ let {getByRole} = render();
+ 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 (
diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts
index 2e0d996c660..2c8a2d20d1f 100644
--- a/packages/react-aria/exports/index.ts
+++ b/packages/react-aria/exports/index.ts
@@ -76,8 +76,7 @@ export {
export {useNumberFormatter} from '../src/i18n/useNumberFormatter';
export {useListFormatter} from '../src/i18n/useListFormatter';
export {useFocus} from '../src/interactions/useFocus';
-export {useFocusVisible} from '../src/interactions/useFocusVisible';
-export {useShowFocusIndicator} from '../src/interactions/useFocusVisible';
+export {setInteractionModality, useFocusVisible} from '../src/interactions/useFocusVisible';
export {useFocusWithin} from '../src/interactions/useFocusWithin';
export {useHover} from '../src/interactions/useHover';
export {useInteractOutside} from '../src/interactions/useInteractOutside';
@@ -331,7 +330,11 @@ export type {
} from '../src/dnd/useDroppableCollection';
export type {DroppableItemOptions, DroppableItemResult} from '../src/dnd/useDroppableItem';
export type {FocusProps, FocusResult} from '../src/interactions/useFocus';
-export type {FocusVisibleProps, FocusVisibleResult} from '../src/interactions/useFocusVisible';
+export type {
+ Modality,
+ FocusVisibleProps,
+ FocusVisibleResult
+} from '../src/interactions/useFocusVisible';
export type {FocusWithinProps, FocusWithinResult} from '../src/interactions/useFocusWithin';
export type {HoverProps, HoverResult} from '../src/interactions/useHover';
export type {InteractOutsideProps} from '../src/interactions/useInteractOutside';
diff --git a/packages/react-aria/exports/useFocusVisible.ts b/packages/react-aria/exports/useFocusVisible.ts
index fcaad89237f..7a468303552 100644
--- a/packages/react-aria/exports/useFocusVisible.ts
+++ b/packages/react-aria/exports/useFocusVisible.ts
@@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/
-export {useFocusVisible} from '../src/interactions/useFocusVisible';
+export {setInteractionModality, useFocusVisible} from '../src/interactions/useFocusVisible';
export type {FocusVisibleProps, FocusVisibleResult} from '../src/interactions/useFocusVisible';
export type {FocusEvents} from '@react-types/shared';
+export type {Modality} from '../src/interactions/useFocusVisible';
diff --git a/packages/react-aria/exports/useShowFocusIndicator.ts b/packages/react-aria/exports/useShowFocusIndicator.ts
deleted file mode 100644
index 50e431f11a8..00000000000
--- a/packages/react-aria/exports/useShowFocusIndicator.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-/*
- * Copyright 2026 Adobe. All rights reserved.
- * This file is licensed to you under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License. You may obtain a copy
- * of the License at http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software distributed under
- * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
- * OF ANY KIND, either express or implied. See the License for the specific language
- * governing permissions and limitations under the License.
- */
-
-export {useShowFocusIndicator} from '../src/interactions/useFocusVisible';
diff --git a/packages/react-aria/src/interactions/useFocusVisible.ts b/packages/react-aria/src/interactions/useFocusVisible.ts
index 72b1d22fd25..85734a4590c 100644
--- a/packages/react-aria/src/interactions/useFocusVisible.ts
+++ b/packages/react-aria/src/interactions/useFocusVisible.ts
@@ -23,7 +23,7 @@ import {isMac} from '../utils/platform';
import {isVirtualClick} from '../utils/isVirtualEvent';
import {openLink} from '../utils/openLink';
import {PointerType} from '@react-types/shared';
-import {useCallback, useEffect, useState} from 'react';
+import {useEffect, useState} from 'react';
import {useIsSSR} from '../ssr/SSRProvider';
export type Modality = 'keyboard' | 'pointer' | 'virtual';
@@ -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.
*/
@@ -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);
@@ -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);
@@ -303,13 +319,6 @@ export function setInteractionModality(modality: Modality): void {
triggerChangeHandlers(modality, null);
}
-/**
- * Returns a callback that makes the focus indicator visible.
- */
-export function useShowFocusIndicator(): () => void {
- return useCallback(() => setInteractionModality('keyboard'), []);
-}
-
/** @private */
export function getPointerType(): PointerType {
return currentPointerType;
diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js
index 72c798438d0..d469996ee35 100644
--- a/packages/react-aria/test/interactions/useFocusVisible.test.js
+++ b/packages/react-aria/test/interactions/useFocusVisible.test.js
@@ -21,8 +21,7 @@ import {
import {
addWindowFocusTracking,
useFocusVisible,
- useFocusVisibleListener,
- useShowFocusIndicator
+ useFocusVisibleListener
} from '../../src/interactions/useFocusVisible';
import {changeHandlers, hasSetupGlobalListeners} from '../../src/interactions/useFocusVisible';
import {mergeProps} from '../../src/utils/mergeProps';
@@ -376,40 +375,6 @@ describe('useFocusVisible', function () {
});
});
-describe('useShowFocusIndicator', function () {
- // A form library that moves focus to the first invalid field does so by calling
- // element.focus() from the submit handler. Clicking submit leaves the modality on
- // 'pointer', so the programmatic focus lands without a visible indicator.
- function FormExample() {
- let {focusProps, isFocusVisible} = useFocusRing();
- let showFocusIndicator = useShowFocusIndicator();
- let ref = React.useRef(null);
-
- let onSubmit = e => {
- e.preventDefault();
- showFocusIndicator();
- ref.current.focus();
- };
-
- return (
-
- );
- }
-
- it('shows the focus indicator on programmatic focus after a pointer interaction', async function () {
- let user = userEvent.setup({delay: null, pointerMap});
- render();
- await user.click(screen.getByRole('button', {name: 'Submit'}));
-
- let input = screen.getByRole('textbox');
- expect(input).toHaveFocus();
- expect(input).toHaveAttribute('data-focus-visible');
- });
-});
-
describe('useFocusVisibleListener', function () {
it('emits on modality change (non-text input)', function () {
let fnMock = jest.fn();