Skip to content

Commit 9bb6ed9

Browse files
authored
fix: add isFocusVisible useMenuItem and fix focusRing when typing in Autocomplete SearchField (#7625)
* add isFocusVisible support to useMenuItem * Adding tests and style in storybook * fix: (WIP) Fix autocomplete searchfield focus ring (#7626) * for discussion * fix searchfield focus ring appearing on keypress when wrapped in a autocomplete * add test * remove extranous controls
1 parent 2a1c28b commit 9bb6ed9

File tree

8 files changed

+120
-12
lines changed

8 files changed

+120
-12
lines changed

packages/@react-aria/interactions/src/useFocusVisible.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,15 +289,18 @@ const nonTextInputTypes = new Set([
289289
* focus visible style can be properly set.
290290
*/
291291
function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: HandlerEvent) {
292+
let document = getOwnerDocument(e?.target as Element);
292293
const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLInputElement : HTMLInputElement;
293294
const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLTextAreaElement : HTMLTextAreaElement;
294295
const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLElement : HTMLElement;
295296
const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).KeyboardEvent : KeyboardEvent;
296297

298+
// For keyboard events that occur on a non-input element that will move focus into input element (aka ArrowLeft going from Datepicker button to the main input group)
299+
// we need to rely on the user passing isTextInput into here. This way we can skip toggling focus visiblity for said input element
297300
isTextInput = isTextInput ||
298-
(e?.target instanceof IHTMLInputElement && !nonTextInputTypes.has(e?.target?.type)) ||
299-
e?.target instanceof IHTMLTextAreaElement ||
300-
(e?.target instanceof IHTMLElement && e?.target.isContentEditable);
301+
(document.activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(document.activeElement.type)) ||
302+
document.activeElement instanceof IHTMLTextAreaElement ||
303+
(document.activeElement instanceof IHTMLElement && document.activeElement.isContentEditable);
301304
return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]);
302305
}
303306

@@ -322,6 +325,7 @@ export function useFocusVisibleListener(fn: FocusVisibleHandler, deps: ReadonlyA
322325

323326
useEffect(() => {
324327
let handler = (modality: Modality, e: HandlerEvent) => {
328+
// We want to early return for any keyboard events that occur inside text inputs EXCEPT for Tab and Escape
325329
if (!isKeyboardFocusEvent(!!(opts?.isTextInput), modality, e)) {
326330
return;
327331
}

packages/@react-aria/menu/src/useMenuItem.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface MenuItemAria {
3434

3535
/** Whether the item is currently focused. */
3636
isFocused: boolean,
37+
/** Whether the item is keyboard focused. */
38+
isFocusVisible: boolean,
3739
/** Whether the item is currently selected. */
3840
isSelected: boolean,
3941
/** Whether the item is currently in a pressed state. */
@@ -316,6 +318,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
316318
id: keyboardId
317319
},
318320
isFocused,
321+
isFocusVisible: isFocused && isFocusVisible(),
319322
isSelected,
320323
isPressed,
321324
isDisabled

packages/@react-spectrum/s2/stories/Popover.stories.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {Meta} from '@storybook/react';
2020
import Org from '../s2wf-icons/S2_Icon_Buildings_20_N.svg';
2121
import Settings from '../s2wf-icons/S2_Icon_Settings_20_N.svg';
2222
import {style} from '../style/spectrum-theme' with {type: 'macro'};
23+
import {UNSTABLE_Autocomplete, useFilter} from 'react-aria-components';
2324
import User from '../s2wf-icons/S2_Icon_User_20_N.svg';
2425
import Users from '../s2wf-icons/S2_Icon_UserGroup_20_N.svg';
2526

@@ -47,7 +48,7 @@ export const HelpCenter = (args: any) => (
4748
<Tab id="feedback">Feedback</Tab>
4849
</TabList>
4950
<TabPanel id="help">
50-
<SearchField label="Search" styles={style({marginTop: 12, marginX: 12})} />
51+
<SearchField label="Search" styles={style({marginTop: 12, marginX: 12})} />
5152
<Menu aria-label="Help" styles={style({marginTop: 12})}>
5253
<MenuSection>
5354
<MenuItem href="#">
@@ -163,3 +164,31 @@ AccountMenu.argTypes = {
163164
hideArrow: {table: {disable: true}},
164165
placement: {table: {disable: true}}
165166
};
167+
168+
169+
function Autocomplete(props) {
170+
let {contains} = useFilter({sensitivity: 'base'});
171+
return (
172+
<UNSTABLE_Autocomplete filter={contains} {...props} />
173+
);
174+
}
175+
176+
export const AutocompletePopover = (args: any) => (
177+
<>
178+
<DialogTrigger {...args}>
179+
<ActionButton aria-label="Help" styles={style({marginX: 'auto'})}>
180+
<Help />
181+
</ActionButton>
182+
<Popover {...args}>
183+
<Autocomplete>
184+
<SearchField autoFocus label="Search" styles={style({marginTop: 12, marginX: 12})} />
185+
<Menu aria-label="test menu" styles={style({marginTop: 12})}>
186+
<MenuItem>Foo</MenuItem>
187+
<MenuItem>Bar</MenuItem>
188+
<MenuItem>Baz</MenuItem>
189+
</Menu>
190+
</Autocomplete>
191+
</Popover>
192+
</DialogTrigger>
193+
</>
194+
);
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {Menu, MenuItem, SearchField} from '../src';
14+
import {pointerMap, render} from '@react-spectrum/test-utils-internal';
15+
import React from 'react';
16+
import {UNSTABLE_Autocomplete} from 'react-aria-components';
17+
import userEvent from '@testing-library/user-event';
18+
19+
describe('SearchField', () => {
20+
let user;
21+
beforeAll(() => {
22+
user = userEvent.setup({delay: null, pointerMap});
23+
});
24+
25+
it('should not apply the focus visible styles on the group when typing in the Autocomplete wrapped SearchField', async () => {
26+
let {getByRole} = render(
27+
<UNSTABLE_Autocomplete>
28+
<SearchField autoFocus label="Search" />
29+
<Menu aria-label="test menu">
30+
<MenuItem>Foo</MenuItem>
31+
<MenuItem>Bar</MenuItem>
32+
<MenuItem>Baz</MenuItem>
33+
</Menu>
34+
</UNSTABLE_Autocomplete>
35+
);
36+
37+
let input = getByRole('searchbox');
38+
await user.click(input);
39+
let group = getByRole('group');
40+
expect(group).not.toHaveAttribute('data-focus-visible');
41+
await user.keyboard('Foo');
42+
expect(group).not.toHaveAttribute('data-focus-visible');
43+
});
44+
});

packages/react-aria-components/example/index.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ html {
105105
color: white;
106106
}
107107

108+
.item.focusVisible{
109+
outline: 3px solid black;
110+
outline-offset: -2px;
111+
}
112+
108113
.item.open:not(.focused) {
109114
background: lightslategray;
110115
color: white;

packages/react-aria-components/src/Menu.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {AriaMenuProps, FocusScope, mergeProps, useFocusRing, useMenu, useMenuItem, useMenuSection, useMenuTrigger} from 'react-aria';
13+
import {AriaMenuProps, FocusScope, mergeProps, useMenu, useMenuItem, useMenuSection, useMenuTrigger} from 'react-aria';
1414
import {BaseCollection, Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections';
1515
import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Node, TreeState, useMenuTriggerState, useTreeState} from 'react-stately';
1616
import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection';
@@ -343,7 +343,6 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent('item', function MenuI
343343
selectionManager
344344
}, state, ref);
345345

346-
let {isFocusVisible, focusProps} = useFocusRing();
347346
let {hoverProps, isHovered} = useHover({
348347
isDisabled: states.isDisabled
349348
});
@@ -355,7 +354,7 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent('item', function MenuI
355354
values: {
356355
...states,
357356
isHovered,
358-
isFocusVisible,
357+
isFocusVisible: states.isFocusVisible,
359358
selectionMode: selectionManager.selectionMode,
360359
selectionBehavior: selectionManager.selectionBehavior,
361360
hasSubmenu: !!props['aria-haspopup'],
@@ -367,13 +366,13 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent('item', function MenuI
367366

368367
return (
369368
<ElementType
370-
{...mergeProps(menuItemProps, focusProps, hoverProps)}
369+
{...mergeProps(menuItemProps, hoverProps)}
371370
{...renderProps}
372371
ref={ref}
373372
data-disabled={states.isDisabled || undefined}
374373
data-hovered={isHovered || undefined}
375374
data-focused={states.isFocused || undefined}
376-
data-focus-visible={isFocusVisible || undefined}
375+
data-focus-visible={states.isFocusVisible || undefined}
377376
data-pressed={states.isPressed || undefined}
378377
data-selected={states.isSelected || undefined}
379378
data-selection-mode={selectionManager.selectionMode === 'none' ? undefined : selectionManager.selectionMode}

packages/react-aria-components/stories/utils.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ export const MyMenuItem = (props: MenuItemProps) => {
2020
return (
2121
<MenuItem
2222
{...props}
23-
className={({isFocused, isSelected, isOpen}) => classNames(styles, 'item', {
23+
className={({isFocused, isSelected, isOpen, isFocusVisible}) => classNames(styles, 'item', {
2424
focused: isFocused,
2525
selected: isSelected,
26-
open: isOpen
26+
open: isOpen,
27+
focusVisible: isFocusVisible
2728
})} />
2829
);
2930
};

packages/react-aria-components/test/Autocomplete.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import {AriaAutocompleteTests} from './AriaAutocomplete.test-util';
1414
import {Button, Header, Input, Label, ListBox, ListBoxItem, ListBoxSection, Menu, MenuItem, MenuSection, SearchField, Separator, Text, UNSTABLE_Autocomplete} from '..';
15-
import {pointerMap, render} from '@react-spectrum/test-utils-internal';
15+
import {pointerMap, render, within} from '@react-spectrum/test-utils-internal';
1616
import React, {ReactNode} from 'react';
1717
import {useAsyncList} from 'react-stately';
1818
import {useFilter} from '@react-aria/i18n';
@@ -221,6 +221,29 @@ describe('Autocomplete', () => {
221221

222222
expect(input).toHaveValue('');
223223
});
224+
225+
it('should apply focusVisible/focused to virtually focused menu items when keyboard navigating', async () => {
226+
let {getByRole} = render(
227+
<AutocompleteWrapper>
228+
<StaticMenu />
229+
</AutocompleteWrapper>
230+
);
231+
232+
let input = getByRole('searchbox');
233+
await user.tab();
234+
expect(document.activeElement).toBe(input);
235+
await user.keyboard('{ArrowDown}');
236+
let menu = getByRole('menu');
237+
let options = within(menu).getAllByRole('menuitem');
238+
expect(input).toHaveAttribute('aria-activedescendant', options[0].id);
239+
expect(options[0]).toHaveAttribute('data-focus-visible');
240+
241+
await user.click(input);
242+
await user.hover(options[1]);
243+
options = within(menu).getAllByRole('menuitem');
244+
expect(options[1]).toHaveAttribute('data-focused');
245+
expect(options[1]).not.toHaveAttribute('data-focus-visible');
246+
});
224247
});
225248

226249
AriaAutocompleteTests({

0 commit comments

Comments
 (0)