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..8905874 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,85 @@ 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 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
+ // 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) {
+ // 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);
+ }
+ }
+ };
const countMatchingCharacters = (item, searchTerm) => {
let count = 0;
@@ -52,17 +131,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 +209,7 @@ function Search({
if (result?.length > 0 || !hasSearched) {
return (
-
+
{result.map(r => (
{resultItem(r)}
@@ -147,7 +231,7 @@ function Search({
label={label}
autoFocus={autoFocus}
adornment={}
- onChange={handleValueChange}
+ onChange={handleQueryChange}
helperText={helperText}
fullWidth={fullWidth}
textFieldProps={{
@@ -157,8 +241,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 +261,13 @@ function Search({
/>
{showResultsList &&
(resultsInModal ? (
-