@patternfly/react-core
Version:
This library provides a set of common React components for use with the PatternFly reference implementation.
532 lines (489 loc) • 17.8 kB
Markdown
---
id: Search input
section: components
---
import { useEffect, useRef, useState } from 'react';
import {
Button,
Card,
CardBody,
CardFooter,
DatePicker,
Form,
FormGroup,
Grid,
GridItem,
Menu,
MenuContent,
MenuItem,
MenuList,
MenuToggle,
Popper,
SearchInput,
TextInput
} from '@patternfly/react-core';
import { words } from './words.js';
## Demos
### Search with autocomplete
This demo handles building the advanced search form using the composable Menu, and the `SearchInput`'s `hint` prop.
It also demonstrates wiring up the appropriate keyboard interactions, focus management, and general event handling.
```js
import { useEffect, useRef, useState } from 'react';
import { Menu, MenuContent, MenuItem, MenuList, Popper, SearchInput } from '@patternfly/react-core';
import { words } from './words.js';
SearchAutocomplete = () => {
const [value, setValue] = useState('');
const [hint, setHint] = useState('');
const [autocompleteOptions, setAutocompleteOptions] = useState([]);
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
const searchInputRef = useRef(null);
const autocompleteRef = useRef(null);
const onClear = () => {
setValue('');
};
const onChange = (_event, newValue) => {
if (
newValue !== '' &&
searchInputRef &&
searchInputRef.current &&
searchInputRef.current.contains(document.activeElement)
) {
setIsAutocompleteOpen(true);
// When the value of the search input changes, build a list of no more than 10 autocomplete options.
// Options which start with the search input value are listed first, followed by options which contain
// the search input value.
let options = words
.filter((option) => option.startsWith(newValue.toLowerCase()))
.map((option) => (
<MenuItem itemId={option} key={option}>
{option}
</MenuItem>
));
if (options.length > 10) {
options = options.slice(0, 10);
} else {
options = [
...options,
...words
.filter((option) => !option.startsWith(newValue.toLowerCase()) && option.includes(newValue.toLowerCase()))
.map((option) => (
<MenuItem itemId={option} key={option}>
{option}
</MenuItem>
))
].slice(0, 10);
}
// The hint is set whenever there is only one autocomplete option left.
setHint(options.length === 1 ? options[0].props.itemId : '');
// The menu is hidden if there are no options
setIsAutocompleteOpen(options.length > 0);
setAutocompleteOptions(options);
} else {
setIsAutocompleteOpen(false);
}
setValue(newValue);
};
// Whenever an autocomplete option is selected, set the search input value, close the menu, and put the browser
// focus back on the search input
const onSelect = (e, itemId) => {
e.stopPropagation();
setValue(itemId);
setIsAutocompleteOpen(false);
searchInputRef.current.focus();
};
const handleMenuKeys = (event) => {
// If there is a hint while the browser focus is on the search input, tab or right arrow will 'accept' the hint value
// and set it as the search input value
if (hint && (event.key === 'Tab' || event.key === 'ArrowRight') && searchInputRef.current === event.target) {
setValue(hint);
setHint('');
setIsAutocompleteOpen(false);
if (event.key === 'ArrowRight') {
event.preventDefault();
}
// if the autocomplete is open and the browser focus is on the search input,
} else if (isAutocompleteOpen && searchInputRef.current && searchInputRef.current === event.target) {
// the escape key closes the autocomplete menu and keeps the focus on the search input.
if (event.key === 'Escape') {
setIsAutocompleteOpen(false);
searchInputRef.current.focus();
// the up and down arrow keys move browser focus into the autocomplete menu
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const firstElement = autocompleteRef.current.querySelector('li > button:not(:disabled)');
firstElement && firstElement.focus();
event.preventDefault(); // by default, the up and down arrow keys scroll the window
// the tab, enter, and space keys will close the menu, and the tab key will move browser
// focus forward one element (by default)
} else if (event.key === 'Tab' || event.key === 'Enter' || event.key === ' ') {
setIsAutocompleteOpen(false);
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
}
}
// If the autocomplete is open and the browser focus is in the autocomplete menu
// hitting tab will close the autocomplete and but browser focus back on the search input.
} else if (isAutocompleteOpen && autocompleteRef.current.contains(event.target) && event.key === 'Tab') {
event.preventDefault();
setIsAutocompleteOpen(false);
searchInputRef.current.focus();
}
};
// The autocomplete menu should close if the user clicks outside the menu.
const handleClickOutside = (event) => {
if (
isAutocompleteOpen &&
autocompleteRef &&
autocompleteRef.current &&
!autocompleteRef.current.contains(event.target)
) {
setIsAutocompleteOpen(false);
}
};
useEffect(() => {
window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClickOutside);
return () => {
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClickOutside);
};
}, [isAutocompleteOpen, hint, searchInputRef.current]);
const searchInput = (
<SearchInput
value={value}
onChange={onChange}
onClear={onClear}
ref={searchInputRef}
hint={hint}
id="autocomplete-search"
/>
);
const autocomplete = (
<Menu ref={autocompleteRef} onSelect={onSelect}>
<MenuContent>
<MenuList>{autocompleteOptions}</MenuList>
</MenuContent>
</Menu>
);
return (
<Popper
trigger={searchInput}
triggerRef={searchInputRef}
popper={autocomplete}
popperRef={autocompleteRef}
isVisible={isAutocompleteOpen}
enableFlip={false}
// append the autocomplete menu to the search input in the DOM for the sake of the keyboard navigation experience
appendTo={() => document.querySelector('#autocomplete-search')}
/>
);
};
```
### Composable advanced search
This demo handles building the advanced search form using the composable Menu, as well as wiring up a
select using the composable Menu and MenuToggle components. This demo also demonstrates wiring up the appropriate
keyboard interactions, focus management, and general event handling.
Note: This demo and its handling of 'date within' and a date picker is modeled after the gmail advanced search form.
```js
import { useEffect, useRef, useState } from 'react';
import {
ActionGroup,
Button,
DatePicker,
Form,
FormGroup,
Grid,
GridItem,
isValidDate,
Menu,
MenuContent,
MenuItem,
MenuList,
MenuToggle,
Panel,
PanelMain,
PanelMainBody,
Popper,
SearchInput,
TextInput,
yyyyMMddFormat
} from '@patternfly/react-core';
AdvancedComposableSearchInput = () => {
const [value, setValue] = useState('');
const [hasWords, setHasWords] = useState('');
const [dateWithin, setDateWithin] = useState('1 day');
const [date, setDate] = useState();
const [isAdvancedSearchOpen, setIsAdvancedSearchOpen] = useState(false);
const [isDateWithinOpen, setIsDateWithinOpen] = useState(false);
const isInitialMount = useRef(true);
const firstAttrRef = useRef(null);
const searchInputRef = useRef(null);
const advancedSearchPaneRef = useRef(null);
const dateWithinToggleRef = useRef(undefined);
const dateWithinMenuRef = useRef(undefined);
const onClear = () => {
setValue('');
setHasWords('');
setDateWithin('');
setDate('');
};
const onChange = (_event, value) => {
if (value.length <= hasWords.length + 1) {
setValue(value);
setHasWords(value);
} else {
setValue(hasWords);
}
};
// After initial page load, whenever the advanced search menu is opened, the browser focus should be placed on the
// first advanced search form input. Whenever the advanced search menu is closed, the browser focus should
// be returned to the search input.
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
} else {
if (isAdvancedSearchOpen && firstAttrRef && firstAttrRef.current) {
firstAttrRef.current.focus();
} else if (!isAdvancedSearchOpen && searchInputRef) {
searchInputRef.current.focus();
}
}
}, [isAdvancedSearchOpen]);
// If a menu is open and has browser focus, then the escape key closes them and puts the browser focus onto their
// respective toggle. The 'date within' menu also needs to close when the 'tab' key is hit. However, hitting tab while
// focus is in the advanced search form should move the focus to the next form input, not close the advanced search
// menu.
const handleMenuKeys = (event) => {
if (isDateWithinOpen && dateWithinMenuRef.current && dateWithinMenuRef.current.contains(event.target)) {
if (event.key === 'Escape' || event.key === 'Tab') {
setIsDateWithinOpen(!isDateWithinOpen);
dateWithinToggleRef.current.focus();
}
}
if (isAdvancedSearchOpen && advancedSearchPaneRef.current && advancedSearchPaneRef.current.contains(event.target)) {
if (
event.key === 'Escape' ||
(event.key === 'Tab' &&
!event.shiftKey &&
advancedSearchPaneRef.current.querySelector('button[type=reset]') === event.target)
) {
setIsAdvancedSearchOpen(!isAdvancedSearchOpen);
searchInputRef.current.focus();
}
}
};
// If a menu is open and has browser focus, then clicking outside the menu should close it.
const handleClickOutside = (event) => {
if (
isDateWithinOpen &&
dateWithinMenuRef &&
dateWithinMenuRef.current &&
!dateWithinMenuRef.current.contains(event.target)
) {
setIsDateWithinOpen(false);
}
if (
isAdvancedSearchOpen &&
advancedSearchPaneRef &&
advancedSearchPaneRef.current &&
!advancedSearchPaneRef.current.contains(event.target)
) {
setIsAdvancedSearchOpen(false);
}
};
useEffect(() => {
window.addEventListener('keydown', handleMenuKeys);
window.addEventListener('click', handleClickOutside);
return () => {
window.removeEventListener('keydown', handleMenuKeys);
window.removeEventListener('click', handleClickOutside);
};
}, [dateWithinMenuRef.current, advancedSearchPaneRef.current, isAdvancedSearchOpen, isDateWithinOpen]);
// This demo and its handling of 'date within' and a date picker is modeled after the gmail advanced search form.
const onSubmit = (event, value) => {
event.preventDefault();
if (isValidDate(new Date(date)) && dateWithin) {
let afterDate = new Date(date);
let toDate = new Date(date);
switch (dateWithin) {
case '1 day':
afterDate.setDate(afterDate.getDate());
toDate.setDate(toDate.getDate() + 2);
break;
case '3 days':
afterDate.setDate(afterDate.getDate() - 2);
toDate.setDate(toDate.getDate() + 4);
break;
case '1 week':
afterDate.setDate(afterDate.getDate() - 6);
toDate.setDate(toDate.getDate() + 8);
break;
case '2 weeks':
afterDate.setDate(afterDate.getDate() - 13);
toDate.setDate(toDate.getDate() + 15);
break;
case '1 month':
afterDate.setMonth(afterDate.getMonth() - 1);
afterDate.setDate(afterDate.getDate() + 1);
toDate.setMonth(toDate.getMonth() + 1);
toDate.setDate(toDate.getDate() + 1);
break;
case '2 months':
afterDate.setMonth(afterDate.getMonth() - 2);
afterDate.setDate(afterDate.getDate() + 1);
toDate.setMonth(toDate.getMonth() + 2);
toDate.setDate(toDate.getDate() + 1);
break;
case '6 months':
afterDate.setMonth(afterDate.getMonth() - 6);
afterDate.setDate(afterDate.getDate() + 1);
toDate.setMonth(toDate.getMonth() + 6);
toDate.setDate(toDate.getDate() + 1);
break;
case '1 year':
afterDate.setFullYear(afterDate.getFullYear() - 1);
afterDate.setDate(afterDate.getDate() + 1);
toDate.setFullYear(toDate.getFullYear() + 1);
toDate.setDate(toDate.getDate() + 1);
break;
}
setValue(`${hasWords && hasWords + ' '}after:${yyyyMMddFormat(afterDate)} to:${yyyyMMddFormat(toDate)}`);
} else {
setValue(hasWords);
}
setIsAdvancedSearchOpen(false);
};
const searchInput = (
<SearchInput
value={value}
onChange={onChange}
onToggleAdvancedSearch={(e, isOpen) => {
e.stopPropagation();
setIsAdvancedSearchOpen(isOpen);
}}
isAdvancedSearchOpen={isAdvancedSearchOpen}
onClear={onClear}
onSearch={onSubmit}
ref={searchInputRef}
id="custom-advanced-search"
/>
);
// Clicking the 'date within' toggle should open its associated menu and then place the browser
// focus on the first menu item.
const toggleDateWithinMenu = (ev) => {
ev.stopPropagation(); // Stop handleClickOutside from handling
setTimeout(() => {
if (dateWithinMenuRef.current) {
const firstElement = dateWithinMenuRef.current.querySelector('li > button:not(:disabled)');
firstElement && firstElement.focus();
}
}, 0);
setIsDateWithinOpen(!isDateWithinOpen);
};
// Selecting a date within option closes the menu, sets the value of date within, and puts browser focus back
// on the date within toggle.
const onDateWithinSelect = (e, itemId) => {
e.stopPropagation();
setIsDateWithinOpen(false);
setDateWithin(itemId);
if (dateWithinToggleRef && dateWithinToggleRef.current) {
dateWithinToggleRef.current.focus();
}
};
const dateWithinOptions = (
<Menu ref={dateWithinMenuRef} selected={dateWithin} onSelect={onDateWithinSelect}>
<MenuContent>
<MenuList>
<MenuItem itemId="1 day">1 day</MenuItem>
<MenuItem itemId="3 days">3 days</MenuItem>
<MenuItem itemId="1 week">1 week</MenuItem>
<MenuItem itemId="2 weeks">2 weeks</MenuItem>
<MenuItem itemId="1 month">1 month</MenuItem>
<MenuItem itemId="2 months">2 months</MenuItem>
<MenuItem itemId="6 months">6 months</MenuItem>
<MenuItem itemId="1 year">1 year</MenuItem>
</MenuList>
</MenuContent>
</Menu>
);
const dateWithinToggle = (
<MenuToggle
ref={dateWithinToggleRef}
onClick={toggleDateWithinMenu}
isExpanded={isDateWithinOpen}
style={{ width: '100%' }}
>
{dateWithin}
</MenuToggle>
);
const advancedForm = (
<div ref={advancedSearchPaneRef} role="dialog" aria-label="Advanced search form">
<Panel variant="raised">
<PanelMain>
<PanelMainBody>
<Form>
<FormGroup label="Has the words" fieldId="has-words" key="has-words">
<TextInput
type="text"
id="has-words"
value={hasWords}
onChange={(_event, value) => {
setHasWords(value);
setValue(value);
}}
ref={firstAttrRef}
/>
</FormGroup>
<Grid hasGutter md={6}>
<GridItem>
<FormGroup label="Date within" fieldId="date-within" key="date-within">
<Popper
trigger={dateWithinToggle}
triggerRef={dateWithinToggleRef}
popper={dateWithinOptions}
popperRef={dateWithinMenuRef}
isVisible={isDateWithinOpen}
/>
</FormGroup>
</GridItem>
<GridItem>
<FormGroup label="Of date" fieldId="date" key="date">
<DatePicker
id="datePicker"
style={{ width: '100%' }}
value={date}
onChange={(_e, newValue) => setDate(newValue)}
appendTo={() => document.querySelector('#datePicker')}
/>
</FormGroup>
</GridItem>
</Grid>
<ActionGroup>
<Button variant="primary" type="submit" onClick={(e) => onSubmit(null, e)}>
Submit
</Button>
{!!onClear && (
<Button variant="link" type="reset" onClick={onClear}>
Reset
</Button>
)}
</ActionGroup>
</Form>
</PanelMainBody>
</PanelMain>
</Panel>
</div>
);
// Popper is just one way to build a relationship between a toggle and a menu.
return (
<Popper
trigger={searchInput}
triggerRef={searchInputRef}
popper={advancedForm}
popperRef={advancedSearchPaneRef}
isVisible={isAdvancedSearchOpen}
enableFlip={false}
appendTo={() => document.querySelector('#custom-advanced-search')}
/>
);
};
```