From 9cf11f64840b826b65d4f493cb6a5bfd044031da Mon Sep 17 00:00:00 2001 From: Jason Colapietro Date: Tue, 4 Aug 2026 22:26:44 -0400 Subject: [PATCH 1/6] feat: add useShowFocusIndicator hook --- packages/react-aria/exports/index.ts | 1 + .../react-aria/exports/useShowFocusIndicator.ts | 13 +++++++++++++ .../src/interactions/useFocusVisible.ts | 9 ++++++++- .../test/interactions/useFocusVisible.test.js | 15 +++++++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 packages/react-aria/exports/useShowFocusIndicator.ts 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..f26a2507730 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -29,6 +29,7 @@ import React from 'react'; import {useButton} from '../../src/button/useButton'; import {useFocusRing} from '../../src/focus/useFocusRing'; import userEvent from '@testing-library/user-event'; +import {useShowFocusIndicator} from '../../exports/useShowFocusIndicator'; function Example(props) { const {isFocusVisible} = useFocusVisible(); @@ -375,6 +376,20 @@ describe('useFocusVisible', function () { }); }); +describe('useShowFocusIndicator', function () { + it('shows the focus indicator after pointer interaction', function () { + fireEvent.mouseDown(document.body); + let {result} = renderHook(() => ({ + isFocusVisible: useFocusVisible().isFocusVisible, + showFocusIndicator: useShowFocusIndicator() + })); + + expect(result.current.isFocusVisible).toBe(false); + act(() => result.current.showFocusIndicator()); + expect(result.current.isFocusVisible).toBe(true); + }); +}); + describe('useFocusVisibleListener', function () { it('emits on modality change (non-text input)', function () { let fnMock = jest.fn(); From db803d1f4e505ec7f3b72475bb57b42006209822 Mon Sep 17 00:00:00 2001 From: Jason Colapietro Date: Wed, 5 Aug 2026 23:45:32 -0400 Subject: [PATCH 2/6] test: simulate programmatic validation focus --- .../test/interactions/useFocusVisible.test.js | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index f26a2507730..57b5c00c3ab 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'; @@ -29,7 +30,6 @@ import React from 'react'; import {useButton} from '../../src/button/useButton'; import {useFocusRing} from '../../src/focus/useFocusRing'; import userEvent from '@testing-library/user-event'; -import {useShowFocusIndicator} from '../../exports/useShowFocusIndicator'; function Example(props) { const {isFocusVisible} = useFocusVisible(); @@ -377,16 +377,34 @@ describe('useFocusVisible', function () { }); describe('useShowFocusIndicator', function () { - it('shows the focus indicator after pointer interaction', function () { - fireEvent.mouseDown(document.body); - let {result} = renderHook(() => ({ - isFocusVisible: useFocusVisible().isFocusVisible, - showFocusIndicator: useShowFocusIndicator() - })); - - expect(result.current.isFocusVisible).toBe(false); - act(() => result.current.showFocusIndicator()); - expect(result.current.isFocusVisible).toBe(true); + it('shows the focus indicator when validation programmatically focuses an invalid field', async function () { + function Example() { + let ref = React.useRef(null); + let {focusProps, isFocusVisible} = useFocusRing(); + let showFocusIndicator = useShowFocusIndicator(); + + let onSubmit = e => { + e.preventDefault(); + // react-hook-form calls the invalid handler before focusing the first invalid field. + showFocusIndicator(); + ref.current.focus(); + }; + + return ( +
+ + +
+ ); + } + + 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', 'true'); }); }); From 6963f9e4db139b5ef1a46444cc835b705713c16b Mon Sep 17 00:00:00 2001 From: Jason Colapietro Date: Wed, 5 Aug 2026 23:59:52 -0400 Subject: [PATCH 3/6] test: wrap validation focus in act --- .../react-aria/test/interactions/useFocusVisible.test.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index 57b5c00c3ab..db5905ef4c0 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -379,20 +379,17 @@ describe('useFocusVisible', function () { describe('useShowFocusIndicator', function () { it('shows the focus indicator when validation programmatically focuses an invalid field', async function () { function Example() { - let ref = React.useRef(null); let {focusProps, isFocusVisible} = useFocusRing(); let showFocusIndicator = useShowFocusIndicator(); let onSubmit = e => { e.preventDefault(); - // react-hook-form calls the invalid handler before focusing the first invalid field. showFocusIndicator(); - ref.current.focus(); }; return (
- +
); @@ -403,6 +400,8 @@ describe('useShowFocusIndicator', function () { await user.click(screen.getByRole('button', {name: 'Submit'})); let input = screen.getByRole('textbox'); + // react-hook-form focuses the first invalid field after the invalid handler returns. + act(() => input.focus()); expect(input).toHaveFocus(); expect(input).toHaveAttribute('data-focus-visible', 'true'); }); From 6714940b6e239bb95850a64a0515de8609c7b180 Mon Sep 17 00:00:00 2001 From: Jason Colapietro Date: Fri, 7 Aug 2026 14:22:37 -0400 Subject: [PATCH 4/6] test: simulate the programmatic focus directly, drop library framing --- .../test/interactions/useFocusVisible.test.js | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index db5905ef4c0..e4acd8522be 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -377,31 +377,46 @@ describe('useFocusVisible', function () { }); describe('useShowFocusIndicator', function () { - it('shows the focus indicator when validation programmatically focuses an invalid field', async function () { - function Example() { - let {focusProps, isFocusVisible} = useFocusRing(); - let showFocusIndicator = useShowFocusIndicator(); - - let onSubmit = e => { - e.preventDefault(); + // 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(props) { + let {focusProps, isFocusVisible} = useFocusRing(); + let showFocusIndicator = useShowFocusIndicator(); + let ref = React.useRef(null); + + let onSubmit = e => { + e.preventDefault(); + if (props.showIndicator) { showFocusIndicator(); - }; + } + ref.current.focus(); + }; + + return ( +
+ + +
+ ); + } + + it('does not show 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'})); - return ( -
- - -
- ); - } + let input = screen.getByRole('textbox'); + expect(input).toHaveFocus(); + expect(input).not.toHaveAttribute('data-focus-visible'); + }); + it('shows the focus indicator on programmatic focus after a pointer interaction', async function () { let user = userEvent.setup({delay: null, pointerMap}); - render(); + render(); await user.click(screen.getByRole('button', {name: 'Submit'})); let input = screen.getByRole('textbox'); - // react-hook-form focuses the first invalid field after the invalid handler returns. - act(() => input.focus()); expect(input).toHaveFocus(); expect(input).toHaveAttribute('data-focus-visible', 'true'); }); From 73e13bae3c003205ba0d477a3ebde0d9733855c6 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 10 Aug 2026 10:13:18 +1000 Subject: [PATCH 5/6] simplify test --- .../test/interactions/useFocusVisible.test.js | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index e4acd8522be..3c08faec9a0 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -380,16 +380,14 @@ 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(props) { + function FormExample() { let {focusProps, isFocusVisible} = useFocusRing(); let showFocusIndicator = useShowFocusIndicator(); let ref = React.useRef(null); let onSubmit = e => { e.preventDefault(); - if (props.showIndicator) { - showFocusIndicator(); - } + showFocusIndicator(); ref.current.focus(); }; @@ -408,17 +406,7 @@ describe('useShowFocusIndicator', function () { let input = screen.getByRole('textbox'); expect(input).toHaveFocus(); - expect(input).not.toHaveAttribute('data-focus-visible'); - }); - - 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', 'true'); + expect(input).toHaveAttribute('data-focus-visible'); }); }); From 949608f19b48dce902e85703513e2b021fd5d968 Mon Sep 17 00:00:00 2001 From: Jason Colapietro Date: Sun, 9 Aug 2026 20:40:40 -0400 Subject: [PATCH 6/6] test: correct focus indicator test description --- packages/react-aria/test/interactions/useFocusVisible.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index 3c08faec9a0..72c798438d0 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -399,7 +399,7 @@ describe('useShowFocusIndicator', function () { ); } - it('does not show the focus indicator on programmatic focus after a pointer interaction', async function () { + 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'}));