ProvidersStep search: tighten icons, commit-to-chip, primary search button

Sticky search now uses Autocomplete (multiple+freeSolo capped to 1)
instead of a plain TextField:
- Pin icon tightened to the left edge and to the placeholder
- Committed location renders inside the input as an FA Chip with an
  X delete (clears the committed filter)
- Primary-coloured magnifying-glass IconButton on the right commits
  the draft; disabled while the draft is empty
- Typing no longer filters live — Enter or the search button promotes
  the draft to a chip, matching the chip mental model

The FilterPanel dialog's Location autocomplete already read from the
same searchQuery state, so it continues to display the committed chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 10:30:30 +10:00
parent 952bdaea72
commit 4d77d42876

View File

@@ -10,6 +10,7 @@ import Menu from '@mui/material/Menu';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import ToggleButton from '@mui/material/ToggleButton'; import ToggleButton from '@mui/material/ToggleButton';
import SwapVertIcon from '@mui/icons-material/SwapVert'; import SwapVertIcon from '@mui/icons-material/SwapVert';
import SearchIcon from '@mui/icons-material/Search';
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined'; import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
@@ -19,6 +20,7 @@ import { ProviderCard } from '../../molecules/ProviderCard';
import { FilterPanel } from '../../molecules/FilterPanel'; import { FilterPanel } from '../../molecules/FilterPanel';
import { Button } from '../../atoms/Button'; import { Button } from '../../atoms/Button';
import { Chip } from '../../atoms/Chip'; import { Chip } from '../../atoms/Chip';
import { IconButton } from '../../atoms/IconButton';
import { Switch } from '../../atoms/Switch'; import { Switch } from '../../atoms/Switch';
import { Typography } from '../../atoms/Typography'; import { Typography } from '../../atoms/Typography';
import { Divider } from '../../atoms/Divider'; import { Divider } from '../../atoms/Divider';
@@ -246,6 +248,18 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
// ─── Local state ─── // ─── Local state ───
const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null); const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null);
// Draft value for the sticky search input — only committed (promoted to a
// chip) on Enter or when the search button is clicked. searchQuery is the
// committed filter value; the draft lives here until the user confirms.
const [searchDraft, setSearchDraft] = React.useState('');
const commitSearch = (next: string) => {
const trimmed = next.trim();
if (!trimmed) return;
onSearchChange(trimmed);
onSearch?.(trimmed);
setSearchDraft('');
};
// ─── Price input local state (commits on blur / Enter) ─── // ─── Price input local state (commits on blur / Enter) ───
const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0])); const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0]));
@@ -395,27 +409,82 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
borderColor: 'divider', borderColor: 'divider',
}} }}
> >
{/* Location search */} {/* Location search — committed location renders as a chip inside the
<TextField input. Typing produces a draft; Enter or the search button commit
placeholder="Search a town or suburb..." it. Deleting the chip clears the committed filter. */}
aria-label="Search providers by town or suburb" <Autocomplete
value={searchQuery} multiple
onChange={(e) => onSearchChange(e.target.value)} freeSolo
onKeyDown={(e) => { options={[]}
if (e.key === 'Enter' && onSearch) { value={searchQuery.trim() ? [searchQuery.trim()] : []}
e.preventDefault(); inputValue={searchDraft}
onSearch(searchQuery); onInputChange={(_, newDraft, reason) => {
// Ignore the 'reset' input-change Autocomplete fires after a value
// commit (it echoes the committed value back into the input and
// would otherwise re-populate the draft we just cleared).
if (reason === 'reset') return;
setSearchDraft(newDraft);
}}
onChange={(_, newValue) => {
if (newValue.length === 0) {
// Chip removed — clear the committed filter
onSearchChange('');
return;
} }
// Commit the most-recent entry (cap at 1 location)
const last = newValue[newValue.length - 1];
if (typeof last === 'string') commitSearch(last);
}} }}
fullWidth renderTags={(value, getTagProps) =>
size="small" value.map((option, index) => {
InputProps={{ const { key, ...chipProps } = getTagProps({ index });
startAdornment: ( return (
<InputAdornment position="start"> <Chip
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} /> key={key}
</InputAdornment> label={option}
), size="small"
}} selected
aria-label={`Current location: ${option}. Press delete to clear.`}
{...chipProps}
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
placeholder={searchQuery.trim() ? '' : 'Search a town or suburb...'}
size="small"
inputProps={{
...params.inputProps,
'aria-label': 'Search providers by town or suburb',
}}
InputProps={{
...params.InputProps,
startAdornment: (
<>
<InputAdornment position="start" sx={{ ml: 0.5, mr: 0.5 }}>
<LocationOnOutlinedIcon sx={{ color: 'text.secondary', fontSize: 20 }} />
</InputAdornment>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<InputAdornment position="end" sx={{ mr: 0.25 }}>
<IconButton
aria-label="Search"
color="primary"
size="small"
onClick={() => commitSearch(searchDraft)}
disabled={!searchDraft.trim()}
>
<SearchIcon fontSize="small" />
</IconButton>
</InputAdornment>
),
}}
/>
)}
sx={{ mb: 1.5 }} sx={{ mb: 1.5 }}
/> />