From 10f02f24aa6c1932b5dd61b734dbfbc79ac08c70 Mon Sep 17 00:00:00 2001 From: Richard Phillips Date: Tue, 29 Sep 2026 12:34:44 +0100 Subject: [PATCH 1/3] add keyboard nav to Search --- CHANGELOG.md | 2 + package.json | 2 +- src/components/Search.js | 111 ++++++++++++++++++++---- src/components/__tests__/Search.test.js | 91 ++++++++++++++++++- 4 files changed, 189 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d32c32..7ada448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ # Changelog +## [34.3.0] - 2026-09-29 +- Add keyboard navigation and escape to close to Search component. ## [34.2.0] - 2026-09-28 - Add optional showResultsList parameter to Search. Defaults to true (existing behaviour unchanged). Set to false to suppress Search's own results list/modal when a consumer (e.g. a DataGrid) renders the filtered results itself. ## [34.1.0] - 2026-09-09 diff --git a/package.json b/package.json index 6fac337..2872f41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@linn-it/linn-form-components-library", - "version": "34.2.0", + "version": "34.3.0", "private": false, "repository": { "type": "git", diff --git a/src/components/Search.js b/src/components/Search.js index 380d3b8..9aabcea 100644 --- a/src/components/Search.js +++ b/src/components/Search.js @@ -7,9 +7,9 @@ import Chip from '@mui/material/Chip'; import Stack from '@mui/material/Stack'; import List from '@mui/material/List'; import Box from '@mui/material/Box'; -import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; import Typography from '@mui/material/Typography'; -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Loading from './Loading.js'; import InputField from './InputField.js'; @@ -39,6 +39,70 @@ function Search({ }) { const [dialogOpen, setDialogOpen] = useState(false); const [hasSearched, setHasSearched] = useState(false); + const [resultsHidden, setResultsHidden] = useState(false); + const listRef = useRef(null); + + const focusInput = () => { + setTimeout(() => { + document.getElementById(propertyName)?.focus(); + }, 0); + }; + + const focusResultAt = index => { + const items = listRef.current?.querySelectorAll('[data-search-result]'); + if (items?.length) { + items[Math.max(0, Math.min(index, items.length - 1))].focus(); + } + }; + + // When the results modal opens (and results are ready), move focus straight + // onto the first result so it can be navigated and selected by keyboard. + useEffect(() => { + if (resultsInModal && dialogOpen && !loading) { + const timer = setTimeout(() => focusResultAt(0), 0); + return () => clearTimeout(timer); + } + return undefined; + }, [resultsInModal, dialogOpen, loading]); + + const hideResults = () => { + if (resultsInModal) { + setDialogOpen(false); + } else { + setResultsHidden(true); + } + focusInput(); + }; + + const handleListKeyDown = event => { + // The handler sits on both the results List and (for the modal) the + // Dialog. Stop propagation once handled so a keypress originating on a + // result isn't processed again as it bubbles up, which would skip items. + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + hideResults(); + return; + } + const items = listRef.current?.querySelectorAll('[data-search-result]'); + if (!items?.length) { + return; + } + const currentIndex = Array.from(items).indexOf(document.activeElement); + if (event.key === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + focusResultAt(currentIndex + 1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + if (currentIndex <= 0) { + focusInput(); + } else { + focusResultAt(currentIndex - 1); + } + } + }; const countMatchingCharacters = (item, searchTerm) => { let count = 0; @@ -52,17 +116,22 @@ function Search({ return count; }; + const selectResult = item => { + clearSearch(); + if (resultsInModal) { + setDialogOpen(false); + } + onResultSelect(item); + setHasSearched(false); + focusInput(); + }; + const resultItem = item => ( - theme.spacing(2) }} - onClick={() => { - clearSearch(); - if (resultsInModal) { - setDialogOpen(false); - } - onResultSelect(item); - setHasSearched(false); - }} + onClick={() => selectResult(item)} > )} - + ); const priority = (item, searchTerm) => { @@ -125,7 +194,7 @@ function Search({ if (result?.length > 0 || !hasSearched) { return ( - + {result.map(r => ( {resultItem(r)} @@ -157,8 +226,14 @@ function Search({ if (resultsInModal && showResultsList) { setDialogOpen(true); } + setResultsHidden(false); search(value); setHasSearched(true); + } else if (data.key === 'Escape') { + hideResults(); + } else if (data.key === 'ArrowDown' && showResultsList) { + data.preventDefault(); + focusResultAt(0); } onKeyPressFunctions.forEach(element => { if (data.keyCode === element.keyCode) { @@ -171,7 +246,13 @@ function Search({ /> {showResultsList && (resultsInModal ? ( - + ) : ( - results() + !resultsHidden && results() ))} ); diff --git a/src/components/__tests__/Search.test.js b/src/components/__tests__/Search.test.js index 38e5f26..71f1f48 100644 --- a/src/components/__tests__/Search.test.js +++ b/src/components/__tests__/Search.test.js @@ -1,5 +1,5 @@ import '@testing-library/jest-dom'; -import { fireEvent, screen } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import render from '../../test-utils'; import Search from '../Search'; @@ -143,6 +143,95 @@ describe('When chips', () => { }); }); +describe('Keyboard navigation of results', () => { + const searchResults = [ + { id: 'a', name: 'RESULT A' }, + { id: 'b', name: 'RESULT B' } + ]; + + test('results are out of the tab order but selectable once focused', () => { + render(); + const [firstResult] = screen.getAllByRole('button'); + expect(firstResult).toHaveAttribute('tabindex', '-1'); + firstResult.focus(); + expect(firstResult).toHaveFocus(); + fireEvent.click(firstResult); + expect(onResultSelect).toHaveBeenCalledWith(searchResults[0]); + }); + + test('ArrowDown from the input moves focus into the results list', () => { + render(); + const input = screen.getByLabelText('Label'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(screen.getAllByRole('button')[0]).toHaveFocus(); + }); + + test('Escape hides the results list without changing the value', () => { + render(); + expect(screen.getByText('RESULT A')).toBeInTheDocument(); + const input = screen.getByLabelText('Label'); + fireEvent.keyDown(input, { key: 'Escape' }); + expect(screen.queryByText('RESULT A')).not.toBeInTheDocument(); + expect(defaultProps.handleValueChange).not.toHaveBeenCalled(); + }); + + test('searching again after Escape re-shows the results', () => { + render(); + const input = screen.getByLabelText('Label'); + fireEvent.keyDown(input, { key: 'Escape' }); + expect(screen.queryByText('RESULT A')).not.toBeInTheDocument(); + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }); + expect(screen.getByText('RESULT A')).toBeInTheDocument(); + }); +}); + +describe('Keyboard navigation when results are shown in a modal', () => { + const searchResults = [ + { id: 'a', name: 'RESULT A' }, + { id: 'b', name: 'RESULT B' }, + { id: 'c', name: 'RESULT C' } + ]; + + const openModal = () => { + render( + + ); + const input = screen.getByLabelText('Label'); + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }); + return input; + }; + + // Fire from the currently focused result (a List descendant) so the event + // bubbles exactly as it does in a real browser and can be handled twice if + // wired up wrongly. + test('arrow keys move focus one result at a time inside the modal', () => { + openModal(); + const results = screen.getAllByRole('button', { name: /RESULT/ }); + results[0].focus(); + fireEvent.keyDown(results[0], { key: 'ArrowDown' }); + expect(results[1]).toHaveFocus(); + fireEvent.keyDown(results[1], { key: 'ArrowDown' }); + expect(results[2]).toHaveFocus(); + fireEvent.keyDown(results[2], { key: 'ArrowUp' }); + expect(results[1]).toHaveFocus(); + }); + + test('Enter selects the focused result', () => { + openModal(); + const results = screen.getAllByRole('button', { name: /RESULT/ }); + results[0].focus(); + fireEvent.keyDown(results[0], { key: 'Enter' }); + expect(onResultSelect).toHaveBeenCalledWith(searchResults[0]); + }); + + test('Escape closes the modal', async () => { + openModal(); + const modal = screen.getByTestId('modal'); + fireEvent.keyDown(modal, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument()); + }); +}); + describe('When showResultsList is false', () => { beforeEach(() => { render( From 29f14ed709d0f254e90330605af1888ef1779e42 Mon Sep 17 00:00:00 2001 From: Richard Phillips Date: Tue, 29 Sep 2026 12:35:27 +0100 Subject: [PATCH 2/3] story --- src/components/Search.stories.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/Search.stories.js b/src/components/Search.stories.js index 893afa4..e7bbde2 100644 --- a/src/components/Search.stories.js +++ b/src/components/Search.stories.js @@ -53,7 +53,8 @@ export const WithResults = { name: 'With search results', args: { value: 'widget', - searchResults: sampleResults + searchResults: sampleResults, + helperText: 'USE ARROW KEYS TO NAVIGATE RESULTS, ENTER TO SELECT, ESCAPE TO DISMISS' } }; @@ -88,3 +89,13 @@ export const WithChips = { })) } }; + +export const ResultsInModal = { + name: 'Results in modal', + args: { + resultsInModal: true, + value: 'widget', + searchResults: sampleResults, + helperText: 'PRESS ENTER TO SEARCH, THEN USE ARROW KEYS AND ENTER TO SELECT' + } +}; From 6beb83c234e4677389debb57793fa6ce63710c91 Mon Sep 17 00:00:00 2001 From: Richard Phillips Date: Tue, 29 Sep 2026 13:38:25 +0100 Subject: [PATCH 3/3] fb --- src/components/Search.js | 19 ++++++- src/components/__tests__/Search.test.js | 66 +++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/components/Search.js b/src/components/Search.js index 9aabcea..8905874 100644 --- a/src/components/Search.js +++ b/src/components/Search.js @@ -74,6 +74,14 @@ function Search({ focusInput(); }; + const handleQueryChange = (name, newValue) => { + // Starting a new query should bring back a list the user dismissed with + // Escape. This covers searchOnEnter={false}, where there is no Enter + // press to reset the hidden state. + setResultsHidden(false); + handleValueChange(name, newValue); + }; + const handleListKeyDown = event => { // The handler sits on both the results List and (for the modal) the // Dialog. Stop propagation once handled so a keypress originating on a @@ -97,7 +105,14 @@ function Search({ event.preventDefault(); event.stopPropagation(); if (currentIndex <= 0) { - focusInput(); + // In the modal the input sits outside the dialog's focus trap, + // so returning focus to it is unreliable — keep focus on the + // first result. Inline, hop back up to the input. + if (resultsInModal) { + focusResultAt(0); + } else { + focusInput(); + } } else { focusResultAt(currentIndex - 1); } @@ -216,7 +231,7 @@ function Search({ label={label} autoFocus={autoFocus} adornment={} - onChange={handleValueChange} + onChange={handleQueryChange} helperText={helperText} fullWidth={fullWidth} textFieldProps={{ diff --git a/src/components/__tests__/Search.test.js b/src/components/__tests__/Search.test.js index 71f1f48..5165706 100644 --- a/src/components/__tests__/Search.test.js +++ b/src/components/__tests__/Search.test.js @@ -183,6 +183,45 @@ describe('Keyboard navigation of results', () => { fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }); expect(screen.getByText('RESULT A')).toBeInTheDocument(); }); + + test('with searchOnEnter=false, changing the query after Escape re-shows results', () => { + render( + + ); + expect(screen.getByText('RESULT A')).toBeInTheDocument(); + const input = screen.getByLabelText('Label'); + fireEvent.keyDown(input, { key: 'Escape' }); + expect(screen.queryByText('RESULT A')).not.toBeInTheDocument(); + fireEvent.change(input, { target: { value: 'RESULT B' } }); + expect(screen.getByText('RESULT A')).toBeInTheDocument(); + }); + + test('ArrowUp on the first result returns focus to the input (inline)', () => { + jest.useFakeTimers(); + try { + render( + + ); + const input = screen.getByLabelText('Label'); + const [firstResult] = screen.getAllByRole('button', { name: /RESULT/ }); + firstResult.focus(); + fireEvent.keyDown(firstResult, { key: 'ArrowUp' }); + jest.runOnlyPendingTimers(); + expect(input).toHaveFocus(); + } finally { + jest.useRealTimers(); + } + }); }); describe('Keyboard navigation when results are shown in a modal', () => { @@ -224,6 +263,33 @@ describe('Keyboard navigation when results are shown in a modal', () => { expect(onResultSelect).toHaveBeenCalledWith(searchResults[0]); }); + // The input lives outside the dialog's focus trap, so ArrowUp on the first + // result must not try to jump to it — focus stays inside the modal. + test('ArrowUp on the first result keeps focus inside the modal', () => { + jest.useFakeTimers(); + try { + render( + + ); + const input = screen.getByLabelText('Label'); + fireEvent.keyDown(input, { key: 'Enter', keyCode: 13 }); + const results = screen.getAllByRole('button', { name: /RESULT/ }); + results[0].focus(); + fireEvent.keyDown(results[0], { key: 'ArrowUp' }); + jest.runOnlyPendingTimers(); + expect(input).not.toHaveFocus(); + expect(results[0]).toHaveFocus(); + } finally { + jest.useRealTimers(); + } + }); + test('Escape closes the modal', async () => { openModal(); const modal = screen.getByTestId('modal');