From 9fb00acbdd3374f7de74414ec05648e1e938f58e Mon Sep 17 00:00:00 2001 From: Taylor Jones Date: Wed, 7 Oct 2026 17:42:22 -0500 Subject: [PATCH] fix TreeView typeahead when nested controls have focus Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../treeview-typeahead-nested-controls.md | 5 ++ packages/react/src/TreeView/TreeView.test.tsx | 89 +++++++++++++++++++ packages/react/src/TreeView/useTypeahead.ts | 3 + 3 files changed, 97 insertions(+) create mode 100644 .changeset/treeview-typeahead-nested-controls.md diff --git a/.changeset/treeview-typeahead-nested-controls.md b/.changeset/treeview-typeahead-nested-controls.md new file mode 100644 index 00000000000..f10abb09a7d --- /dev/null +++ b/.changeset/treeview-typeahead-nested-controls.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +TreeView: Prevent typeahead errors and focus changes when typing in nested controls. diff --git a/packages/react/src/TreeView/TreeView.test.tsx b/packages/react/src/TreeView/TreeView.test.tsx index 045e6d6757f..aa9de62940d 100644 --- a/packages/react/src/TreeView/TreeView.test.tsx +++ b/packages/react/src/TreeView/TreeView.test.tsx @@ -1,4 +1,5 @@ import {fireEvent, render, act, screen, waitFor} from '@testing-library/react' +import userEvent from '@testing-library/user-event' import {afterEach, describe, it, expect, vi} from 'vitest' import React from 'react' import type {SubTreeState} from './TreeView' @@ -1282,6 +1283,94 @@ describe('Keyboard interactions', () => { }) describe('Typeahead', () => { + it.each(['z', 'c'])('ignores rapid "%s" key presses from a nested button', key => { + vi.useFakeTimers() + const onKeyDown = vi.fn() + render( + + + Apple + + + Banana + Cherry + , + ) + + const apple = screen.getByRole('treeitem', {name: 'Apple'}) + const button = screen.getByRole('button', {name: 'Nested action'}) + const cherry = screen.getByRole('treeitem', {name: 'Cherry'}) + + act(() => apple.focus()) + act(() => button.focus()) + expect(button).toHaveFocus() + + expect(fireEvent.keyDown(button, {key})).toBe(true) + expect(fireEvent.keyDown(button, {key})).toBe(true) + expect(button).toHaveFocus() + expect(onKeyDown).toHaveBeenCalledTimes(2) + + act(() => apple.focus()) + fireEvent.keyDown(apple, {key: 'c'}) + expect(cherry).toHaveFocus() + }) + + it('allows typing in a nested input without moving focus', async () => { + const user = userEvent.setup() + render( + + + Apple + + + Banana + Cherry + , + ) + + const input = screen.getByRole('textbox', {name: 'Filter'}) + + await user.type(input, 'ch') + + expect(input).toHaveValue('ch') + expect(input).toHaveFocus() + }) + + it('does not add nested-control keystrokes to an ongoing search', () => { + vi.useFakeTimers() + render( + + + Apple + + + Cherry + Cantalope + , + ) + + const apple = screen.getByRole('treeitem', {name: 'Apple'}) + const button = screen.getByRole('button', {name: 'Nested action'}) + const cherry = screen.getByRole('treeitem', {name: 'Cherry'}) + const cantalope = screen.getByRole('treeitem', {name: 'Cantalope'}) + + act(() => apple.focus()) + fireEvent.keyDown(apple, {key: 'c'}) + expect(cherry).toHaveFocus() + + act(() => button.focus()) + expect(fireEvent.keyDown(button, {key: 'z'})).toBe(true) + expect(button).toHaveFocus() + + act(() => cherry.focus()) + fireEvent.keyDown(cherry, {key: 'a'}) + expect(cantalope).toHaveFocus() + }) + it('moves focus to the next item that matches the typed character', () => { render( diff --git a/packages/react/src/TreeView/useTypeahead.ts b/packages/react/src/TreeView/useTypeahead.ts index 128b6f967eb..dba62025059 100644 --- a/packages/react/src/TreeView/useTypeahead.ts +++ b/packages/react/src/TreeView/useTypeahead.ts @@ -63,6 +63,9 @@ export function useTypeahead({containerRef, onFocusChange}: TypeaheadOptions) { const container = containerRef.current function onKeyDown(event: KeyboardEvent) { + // Leave keyboard interaction on nested controls to the controls themselves. + if (!(event.target instanceof HTMLElement) || event.target.getAttribute('role') !== 'treeitem') return + // Ignore key presses that don't produce a character value if (!event.key || event.key.length > 1 || event.key === ' ') return