diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 84ae4850e3a..2e0d996c660 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -77,6 +77,7 @@ 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 {useFocusWithin} from '../src/interactions/useFocusWithin'; export {useHover} from '../src/interactions/useHover'; export {useInteractOutside} from '../src/interactions/useInteractOutside'; diff --git a/packages/react-aria/exports/useShowFocusIndicator.ts b/packages/react-aria/exports/useShowFocusIndicator.ts new file mode 100644 index 00000000000..50e431f11a8 --- /dev/null +++ b/packages/react-aria/exports/useShowFocusIndicator.ts @@ -0,0 +1,13 @@ +/* + * 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 9e992f5334f..72b1d22fd25 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 {useEffect, useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import {useIsSSR} from '../ssr/SSRProvider'; export type Modality = 'keyboard' | 'pointer' | 'virtual'; @@ -303,6 +303,13 @@ 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 d469996ee35..72c798438d0 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -21,7 +21,8 @@ import { import { addWindowFocusTracking, useFocusVisible, - useFocusVisibleListener + useFocusVisibleListener, + useShowFocusIndicator } from '../../src/interactions/useFocusVisible'; import {changeHandlers, hasSetupGlobalListeners} from '../../src/interactions/useFocusVisible'; import {mergeProps} from '../../src/utils/mergeProps'; @@ -375,6 +376,40 @@ 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();