Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/react-aria/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
13 changes: 13 additions & 0 deletions packages/react-aria/exports/useShowFocusIndicator.ts
Original file line number Diff line number Diff line change
@@ -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';
9 changes: 8 additions & 1 deletion packages/react-aria/src/interactions/useFocusVisible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
37 changes: 36 additions & 1 deletion packages/react-aria/test/interactions/useFocusVisible.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -375,6 +376,40 @@ describe('useFocusVisible', function () {
});
});

describe('useShowFocusIndicator', function () {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see my comment about the test.

We don't have react-hook-form currently installed anywhere, it might be better to determine what about the library was causing it to not work (might've been mentioned above), then simulate that in the test, rather than installing the library.

If this is not feasible, please explain why and then move it up to the RAC level where we expect some state changes on the component.

// 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 (
<form onSubmit={onSubmit}>
<input {...focusProps} ref={ref} data-focus-visible={isFocusVisible || undefined} />
<button type="submit">Submit</button>
</form>
);
}

it('shows the focus indicator on programmatic focus after a pointer interaction', async function () {
let user = userEvent.setup({delay: null, pointerMap});
render(<FormExample />);
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();
Expand Down