Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@linn-it/linn-form-components-library",
"version": "34.2.0",
"version": "34.3.0",
"private": false,
"repository": {
"type": "git",
Expand Down
128 changes: 112 additions & 16 deletions src/components/Search.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand All @@ -52,17 +131,22 @@ function Search({
return count;
};

const selectResult = item => {
clearSearch();
if (resultsInModal) {
setDialogOpen(false);
}
onResultSelect(item);
setHasSearched(false);
focusInput();
};

const resultItem = item => (
<ListItem
<ListItemButton
data-search-result
tabIndex={-1}
sx={{ padding: theme => theme.spacing(2) }}
onClick={() => {
clearSearch();
if (resultsInModal) {
setDialogOpen(false);
}
onResultSelect(item);
setHasSearched(false);
}}
onClick={() => selectResult(item)}
>
<Stack spacing={3} direction="row">
<Typography
Expand Down Expand Up @@ -93,7 +177,7 @@ function Search({
</Stack>
)}
</Stack>
</ListItem>
</ListItemButton>
);

const priority = (item, searchTerm) => {
Expand Down Expand Up @@ -125,7 +209,7 @@ function Search({

if (result?.length > 0 || !hasSearched) {
return (
<List dense>
<List dense ref={listRef} onKeyDown={handleListKeyDown}>
{result.map(r => (
<Box key={r.id}>
{resultItem(r)}
Expand All @@ -147,7 +231,7 @@ function Search({
label={label}
autoFocus={autoFocus}
adornment={<SearchIcon />}
onChange={handleValueChange}
onChange={handleQueryChange}
helperText={helperText}
fullWidth={fullWidth}
textFieldProps={{
Expand All @@ -157,8 +241,14 @@ function Search({
if (resultsInModal && showResultsList) {
setDialogOpen(true);
}
setResultsHidden(false);
Comment thread
Copilot marked this conversation as resolved.
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) {
Expand All @@ -171,7 +261,13 @@ function Search({
/>
{showResultsList &&
(resultsInModal ? (
<Dialog data-testid="modal" open={dialogOpen} fullWidth maxWidth="md">
<Dialog
data-testid="modal"
open={dialogOpen}
onKeyDown={handleListKeyDown}
fullWidth
maxWidth="md"
>
<Box>
<IconButton
sx={{
Expand All @@ -194,7 +290,7 @@ function Search({
</Box>
</Dialog>
) : (
results()
!resultsHidden && results()
))}
</>
);
Expand Down
13 changes: 12 additions & 1 deletion src/components/Search.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
};

Expand Down Expand Up @@ -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'
}
};
Loading
Loading