diff --git a/packages/@react-aria/autocomplete/src/useAutocomplete.ts b/packages/@react-aria/autocomplete/src/useAutocomplete.ts index 6f700db89f3..03d24b8c3f7 100644 --- a/packages/@react-aria/autocomplete/src/useAutocomplete.ts +++ b/packages/@react-aria/autocomplete/src/useAutocomplete.ts @@ -13,8 +13,8 @@ import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared'; import {AriaTextFieldProps} from '@react-aria/textfield'; import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete'; -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; -import {dispatchVirtualBlur, dispatchVirtualFocus, moveVirtualFocus} from '@react-aria/focus'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useEvent, useId, useLabels, useObjectRef} from '@react-aria/utils'; +import {dispatchVirtualBlur, dispatchVirtualFocus, getVirtuallyFocusedElement, moveVirtualFocus} from '@react-aria/focus'; import {getInteractionModality} from '@react-aria/interactions'; // @ts-ignore import intlMessages from '../intl/*.json'; @@ -163,15 +163,25 @@ export function useAutocomplete(props: AriaAutocompleteOptions, state: Autocompl collectionRef.current?.dispatchEvent(clearFocusEvent); }); - // TODO: update to see if we can tell what kind of event (paste vs backspace vs typing) is happening instead + let lastInputType = useRef(''); + useEvent(inputRef, 'input', e => { + let {inputType} = e as InputEvent; + lastInputType.current = inputType; + }); + let onChange = (value: string) => { - // Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when deleting text - // for screen reader announcements - if (state.inputValue !== value && state.inputValue.length <= value.length && !disableAutoFocusFirst) { + // Tell wrapped collection to focus the first element in the list when typing forward and to clear focused key when modifying the text via + // copy paste/backspacing/undo/redo for screen reader announcements + if (lastInputType.current === 'insertText' && !disableAutoFocusFirst) { focusFirstItem(); - } else { - // Fully clear focused key when backspacing since the list may change and thus we'd want to start fresh again + } else if (lastInputType.current.includes('insert') || lastInputType.current.includes('delete') || lastInputType.current.includes('history')) { clearVirtualFocus(true); + + // If onChange was triggered before the timeout actually updated the activedescendant, we need to fire + // our own dispatchVirtualFocus so focusVisible gets reapplied on the input + if (getVirtuallyFocusedElement(document) === inputRef.current) { + dispatchVirtualFocus(inputRef.current!, null); + } } state.setInputValue(value); diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 317d6e48873..825888ffea6 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -10,12 +10,12 @@ * governing permissions and limitations under the License. */ -import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, focusWithoutScrolling, getActiveElement, isCtrlKeyPressed, mergeProps, scrollIntoView, scrollIntoViewport, useEffectEvent, useEvent, useRouter, useUpdateLayoutEffect} from '@react-aria/utils'; +import {dispatchVirtualFocus, getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {DOMAttributes, FocusableElement, FocusStrategy, Key, KeyboardDelegate, RefObject} from '@react-types/shared'; import {flushSync} from 'react-dom'; import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react'; import {focusSafely, getInteractionModality} from '@react-aria/interactions'; -import {getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus'; import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils'; import {MultipleSelectionManager} from '@react-stately/selection'; import {useLocale} from '@react-aria/i18n'; @@ -407,7 +407,6 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let {detail} = e; e.stopPropagation(); manager.setFocused(true); - // If the user is typing forwards, autofocus the first option in the list. if (detail?.focusStrategy === 'first') { shouldVirtualFocusFirst.current = true; @@ -417,9 +416,12 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions let updateActiveDescendant = useEffectEvent(() => { let keyToFocus = delegate.getFirstKey?.() ?? null; - // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist + // If no focusable items exist in the list, make sure to clear any activedescendant that may still exist and move focus back to + // the original active element (e.g. the autocomplete input) if (keyToFocus == null) { + let previousActiveElement = getActiveElement(); moveVirtualFocus(ref.current); + dispatchVirtualFocus(previousActiveElement!, null); // If there wasn't a focusable key but the collection had items, then that means we aren't in an intermediate load state and all keys are disabled. // Reset shouldVirtualFocusFirst so that we don't erronously autofocus an item when the collection is filtered again. diff --git a/packages/@react-spectrum/s2/chromatic/Autocomplete.stories.tsx b/packages/@react-spectrum/s2/chromatic/Autocomplete.stories.tsx new file mode 100644 index 00000000000..28da4cafa0b --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/Autocomplete.stories.tsx @@ -0,0 +1,74 @@ +/* + * Copyright 2025 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. + */ + +import {expect} from '@storybook/jest'; +import {Menu, MenuItem, SearchField} from '../src'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Autocomplete as RACAutocomplete, useFilter} from 'react-aria-components'; +import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import {userEvent, waitFor, within} from '@storybook/testing-library'; + +const meta: Meta> = { + component: Menu, + parameters: { + chromaticProvider: {colorSchemes: ['light'], backgrounds: ['base'], locales: ['en-US'], disableAnimations: true} + }, + tags: ['autodocs'], + title: 'S2 Chromatic/Autocomplete' +}; + +export default meta; + +function Autocomplete(props) { + let {contains} = useFilter({sensitivity: 'base'}); + return ( + + ); +} + +function AutocompleteStory() { + return ( + + + + Foo + Bar + Baz + + + ); +} + +export const CutPaste: StoryObj = { + render: () => , + play: async ({canvasElement}) => { + await userEvent.tab(); + await userEvent.keyboard('Foo'); + let body = canvasElement.ownerDocument.body; + let seachfield = await within(body).findByRole('searchbox'); + await waitFor(() => { + expect(seachfield).not.toHaveAttribute('data-focus-visible'); + }, {timeout: 5000}); + + await userEvent.keyboard('{Control>}a{/Control}'); + await userEvent.cut(); + await userEvent.keyboard('{ArrowDown}'); + await waitFor(() => { + expect(seachfield).not.toHaveAttribute('data-focus-visible'); + }, {timeout: 5000}); + + await userEvent.paste(); + await waitFor(() => { + expect(seachfield).toHaveAttribute('data-focus-visible'); + }, {timeout: 5000}); + } +}; diff --git a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx index 9fb6ca08ce5..79dc28c6ab4 100644 --- a/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx +++ b/packages/react-aria-components/test/AriaAutocomplete.test-util.tsx @@ -178,6 +178,32 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' expect(options[0]).toHaveTextContent('Foo'); }); + it('should completely clear the focused key when pasting', async function () { + let {getByRole} = renderers.standard(); + let input = getByRole('searchbox'); + let menu = getByRole(collectionNodeRole); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + await user.tab(); + expect(document.activeElement).toBe(input); + + await user.keyboard('B'); + act(() => jest.runAllTimers()); + let options = within(menu).getAllByRole(collectionItemRole); + let firstActiveDescendant = options[0].id; + expect(input).toHaveAttribute('aria-activedescendant', firstActiveDescendant); + + await user.paste('az'); + act(() => jest.runAllTimers()); + expect(input).not.toHaveAttribute('aria-activedescendant'); + + options = within(menu).getAllByRole(collectionItemRole); + await user.keyboard('{ArrowDown}'); + expect(input).toHaveAttribute('aria-activedescendant', options[0].id); + expect(firstActiveDescendant).not.toEqual(options[0].id); + expect(options[0]).toHaveTextContent('Baz'); + }); + it('should delay the aria-activedescendant being set when autofocusing the first option', async function () { let {getByRole} = renderers.standard(); let input = getByRole('searchbox'); @@ -706,7 +732,7 @@ export const AriaAutocompleteTests = ({renderers, setup, prefix, ariaPattern = ' describe('pointer events', function () { installPointerEvent(); - + it('should close the menu when hovering an adjacent menu item in the virtual focus list', async function () { let {getByRole, getAllByRole} = (renderers.submenus!)(); let menu = getByRole('menu'); diff --git a/packages/react-aria-components/test/Autocomplete.test.tsx b/packages/react-aria-components/test/Autocomplete.test.tsx index eaa2c274347..550507b0edb 100644 --- a/packages/react-aria-components/test/Autocomplete.test.tsx +++ b/packages/react-aria-components/test/Autocomplete.test.tsx @@ -427,6 +427,97 @@ describe('Autocomplete', () => { expect(input).not.toHaveAttribute('data-focused'); }); + it('should restore focus visible styles back to the input when typing forward results in only disabled items', async function () { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + await user.tab(); + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + + await user.keyboard('Ba'); + act(() => jest.runAllTimers()); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + let baz = options[1]; + expect(baz).toHaveTextContent('Baz'); + expect(input).toHaveAttribute('aria-activedescendant', baz.id); + expect(baz).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focus-visible'); + + await user.keyboard('r'); + act(() => jest.runAllTimers()); + options = within(menu).getAllByRole('menuitem'); + let bar = options[0]; + expect(bar).toHaveTextContent('Bar'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(bar).not.toHaveAttribute('data-focus-visible'); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + }); + + it('should maintain focus styles on the input if typing forward results in an completely empty collection', async function () { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + await user.tab(); + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + + await user.keyboard('Q'); + act(() => jest.runAllTimers()); + let menu = getByRole('menu'); + let options = within(menu).queryAllByRole('menuitem'); + expect(options).toHaveLength(0); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + }); + + it('should restore focus visible styles back to the input if the user types forward and backspaces in quick succession', async function () { + let {getByRole} = render( + + + + ); + + let input = getByRole('searchbox'); + await user.tab(); + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + + await user.keyboard('F'); + // If 500ms hasn't elapsed the aria-activedecendant hasn't been updated + act(() => jest.advanceTimersByTime(300)); + let menu = getByRole('menu'); + let options = within(menu).getAllByRole('menuitem'); + let foo = options[0]; + expect(foo).toHaveTextContent('Foo'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(foo).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('data-focused'); + expect(input).not.toHaveAttribute('data-focus-visible'); + + await user.keyboard('{Backspace}'); + act(() => jest.runAllTimers()); + expect(input).toHaveAttribute('data-focused'); + expect(input).toHaveAttribute('data-focus-visible'); + expect(input).not.toHaveAttribute('aria-activedescendant'); + expect(foo).not.toHaveAttribute('data-focus-visible'); + }); + it('should work inside a Select', async function () { let {getByRole} = render(