From bf42bd830de4329bd3e5dfd5e4520f0d1e96236e Mon Sep 17 00:00:00 2001 From: William Ivy Date: Fri, 11 Sep 2026 19:04:03 -0500 Subject: [PATCH 1/2] fix autocomplete losing the last typed character --- .../autocomplete-completed-suggestion.md | 5 + .../src/Autocomplete/Autocomplete.test.tsx | 104 +++++++++++++++++- .../src/Autocomplete/AutocompleteInput.tsx | 3 +- 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 .changeset/autocomplete-completed-suggestion.md diff --git a/.changeset/autocomplete-completed-suggestion.md b/.changeset/autocomplete-completed-suggestion.md new file mode 100644 index 00000000000..3386303c303 --- /dev/null +++ b/.changeset/autocomplete-completed-suggestion.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Autocomplete: Preserve the final typed character when completing an inline suggestion. diff --git a/packages/react/src/Autocomplete/Autocomplete.test.tsx b/packages/react/src/Autocomplete/Autocomplete.test.tsx index da069dbb231..6a763d6b916 100644 --- a/packages/react/src/Autocomplete/Autocomplete.test.tsx +++ b/packages/react/src/Autocomplete/Autocomplete.test.tsx @@ -1,11 +1,13 @@ -import {render, fireEvent, screen, waitFor} from '@testing-library/react' +import {act, render, fireEvent, screen, waitFor} from '@testing-library/react' import userEvent from '@testing-library/user-event' -import {createRef} from 'react' +import {userEvent as browserUserEvent} from 'vitest/browser' +import {createRef, useState} from 'react' import {describe, expect, it, vi} from 'vitest' import type {AutocompleteInputProps} from '../Autocomplete' import Autocomplete from '../Autocomplete' import type {AutocompleteMenuInternalProps, AutocompleteMenuItem} from '../Autocomplete/AutocompleteMenu' import BaseStyles from '../BaseStyles' +import TextInputWithTokens from '../TextInputWithTokens' import {implementsClassName} from '../utils/testing' import classes from './AutocompleteOverlay.module.css' import {AutocompleteContext} from './AutocompleteContext' @@ -23,6 +25,13 @@ const mockItems = [ {text: 'twentyone', id: '21'}, ] +// Use native keyboard events so React's input value tracking behaves as it does for real typing. +async function typeWithNativeKeyboard(text: string) { + for (const character of text) { + await act(() => browserUserEvent.keyboard(character)) + } +} + const AUTOCOMPLETE_LABEL = 'Autocomplete field' const LabelledAutocomplete = ({ inputProps = {}, @@ -256,6 +265,97 @@ describe('Autocomplete', () => { await waitFor(() => expect(inputNode.value).toBe('ze')) }) + it.each([ + {prefix: 'zer', suffix: 'o', expected: 'zero'}, + {prefix: 'ze', suffix: 'ro', expected: 'zero'}, + {prefix: 'zer', suffix: 'x', expected: 'zerx'}, + {prefix: 'zer', suffix: 'O', expected: 'zerO'}, + {prefix: 'topi', suffix: 'c', expected: 'topic'}, + ])( + 'reports and retains $expected after typing $prefix + $suffix and blurring', + async ({prefix, suffix, expected}) => { + const onChange = vi.fn() + render( + <> + onChange(event.currentTarget.value)}} + menuProps={{items: mockItems, selectedItemIds: [], ['aria-labelledby']: 'autocompleteLabel'}} + /> + + , + ) + const input = screen.getByRole('combobox') as HTMLInputElement + + await act(() => browserUserEvent.click(input)) + await typeWithNativeKeyboard(prefix) + if (prefix.startsWith('ze')) { + expect(input).toHaveValue('zero') + expect(input.selectionStart).toBe(prefix.length) + expect(input.selectionEnd).toBe(4) + } + onChange.mockClear() + await typeWithNativeKeyboard(suffix) + expect.soft(onChange).toHaveBeenLastCalledWith(expected) + + await act(() => browserUserEvent.click(screen.getByRole('button', {name: 'outside'}))) + await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'false')) + expect(input).toHaveValue(expected) + }, + ) + + it('commits an exactly completed suggestion from controlled state on Space and clears the token input', async () => { + function TokenAutocomplete() { + const [value, setValue] = useState('') + const [tokens, setTokens] = useState>([]) + return ( + + + + setTokens(tokens.filter(token => token.id !== id))} + onChange={event => setValue(event.currentTarget.value)} + onKeyDown={event => { + if (event.key === ' ' && value) { + event.preventDefault() + setTokens([...tokens, {id: value, text: value}]) + setValue('') + } + }} + /> + + token.id)} + selectionVariant="multiple" + aria-labelledby="topics-label" + /> + + + + ) + } + render() + const input = screen.getByRole('combobox') as HTMLInputElement + + await act(() => browserUserEvent.click(input)) + await typeWithNativeKeyboard('niko') + expect(input).toHaveValue('nikon') + expect(input.selectionStart).toBe(4) + expect(input.selectionEnd).toBe(5) + await typeWithNativeKeyboard('n ') + + expect( + screen.getByText('nikon', {selector: '[data-component="TextInputWithTokens.Token"] *'}), + ).toBeInTheDocument() + expect(input).toHaveValue('') + }) + it('allows the value to be 0', () => { const {getByDisplayValue} = render( Date: Wed, 16 Sep 2026 11:32:24 -0500 Subject: [PATCH 2/2] expanded autocomplete regression tests --- .../src/Autocomplete/Autocomplete.test.tsx | 158 +++++++++++++----- 1 file changed, 115 insertions(+), 43 deletions(-) diff --git a/packages/react/src/Autocomplete/Autocomplete.test.tsx b/packages/react/src/Autocomplete/Autocomplete.test.tsx index 6a763d6b916..1a7380b6532 100644 --- a/packages/react/src/Autocomplete/Autocomplete.test.tsx +++ b/packages/react/src/Autocomplete/Autocomplete.test.tsx @@ -303,59 +303,131 @@ describe('Autocomplete', () => { }, ) - it('commits an exactly completed suggestion from controlled state on Space and clears the token input', async () => { - function TokenAutocomplete() { - const [value, setValue] = useState('') - const [tokens, setTokens] = useState>([]) - return ( - - - - setTokens(tokens.filter(token => token.id !== id))} - onChange={event => setValue(event.currentTarget.value)} - onKeyDown={event => { - if (event.key === ' ' && value) { - event.preventDefault() - setTokens([...tokens, {id: value, text: value}]) - setValue('') - } - }} - /> - - token.id)} - selectionVariant="multiple" - aria-labelledby="topics-label" - /> - - - - ) - } - render() + it.each([ + { + name: 'identical suggestion text with distinct IDs', + items: [ + {id: 'nikon-1', text: 'nikon'}, + {id: 'nikon-2', text: 'nikon'}, + ], + }, + { + name: 'overlapping suggestions', + items: [ + {id: 'nikon', text: 'nikon'}, + {id: 'nikon-camera', text: 'nikon-camera'}, + ], + }, + ])('reports exact completion once with $name', async ({items}) => { + const onChange = vi.fn() + render( + onChange(event.currentTarget.value)}} + menuProps={{items, selectedItemIds: [], ['aria-labelledby']: 'autocompleteLabel'}} + />, + ) const input = screen.getByRole('combobox') as HTMLInputElement await act(() => browserUserEvent.click(input)) await typeWithNativeKeyboard('niko') + expect(screen.getAllByRole('option')).toHaveLength(2) expect(input).toHaveValue('nikon') expect(input.selectionStart).toBe(4) expect(input.selectionEnd).toBe(5) - await typeWithNativeKeyboard('n ') + onChange.mockClear() - expect( - screen.getByText('nikon', {selector: '[data-component="TextInputWithTokens.Token"] *'}), - ).toBeInTheDocument() - expect(input).toHaveValue('') + await typeWithNativeKeyboard('n') + + expect(onChange).toHaveBeenCalledExactlyOnceWith('nikon') + expect(input).toHaveValue('nikon') + expect(input.selectionStart).toBe(5) + expect(input.selectionEnd).toBe(5) }) + it.each(['nikon', `${'topic-'.repeat(8)}ab`])( + 'commits the first token %s from controlled state on Space and keeps the input cleared', + async topic => { + const onChange = vi.fn() + const onCommit = vi.fn() + function TokenAutocomplete() { + const [value, setValue] = useState('') + const [tokens, setTokens] = useState>([]) + return ( + +
+ +
+ + + setTokens(tokens.filter(token => token.id !== id))} + onChange={event => { + onChange(event.currentTarget.value) + setValue(event.currentTarget.value) + }} + onKeyDown={event => { + if (event.key === ' ' && value) { + event.preventDefault() + onCommit(value, event.currentTarget.value) + setTokens([...tokens, {id: value, text: value}]) + setValue('') + } + }} + /> + + token.id)} + selectionVariant="multiple" + aria-labelledby="topics-label" + /> + + +
+ ) + } + render() + const input = screen.getByRole('combobox') as HTMLInputElement + + await act(() => browserUserEvent.click(input)) + await typeWithNativeKeyboard(topic.slice(0, -1)) + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith(topic.slice(0, -1)) + expect(input).toHaveFocus() + expect(input).toHaveValue(topic) + expect(input.selectionStart).toBe(topic.length - 1) + expect(input.selectionEnd).toBe(topic.length) + }) + await typeWithNativeKeyboard(`${topic.slice(-1)} `) + + expect(onChange).toHaveBeenLastCalledWith(topic) + expect(onCommit).toHaveBeenCalledExactlyOnceWith(topic, topic) + expect(onChange.mock.invocationCallOrder.at(-1)).toBeLessThan(onCommit.mock.invocationCallOrder[0]) + expect(screen.getAllByText(topic, {selector: '[data-component="TextInputWithTokens.Token"] *'})).toHaveLength(1) + expect(input).toHaveValue('') + + await act(() => browserUserEvent.click(screen.getByRole('button', {name: 'outside'}))) + await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'false')) + expect(input).toHaveValue('') + // Closing the overlay restores input focus; move focus outside again before refocusing. + await act(() => browserUserEvent.click(screen.getByRole('button', {name: 'outside'}))) + expect(screen.getByRole('button', {name: 'outside'})).toHaveFocus() + await act(() => browserUserEvent.click(input)) + expect(input).toHaveValue('') + await typeWithNativeKeyboard('next') + expect(input).toHaveValue('next') + expect(onChange).toHaveBeenLastCalledWith('next') + expect(onCommit).toHaveBeenCalledTimes(1) + }, + ) + it('allows the value to be 0', () => { const {getByDisplayValue} = render(