Extract LocationSearchInput molecule from ProvidersStep
Lifts the committed-chip location search pattern out of ProvidersStep (two identical inline call sites, ~60 lines each) into a reusable molecule. Behaviour unchanged: draft-typing → commit on Enter or the primary-filled search button → chip render → tap X to clear. The molecule owns the non-obvious correctness CSS (endAdornment absolute-anchoring + right-side padding lane) internally so future callers don't have to rediscover it. Chrome (bgcolor, shadow, border, radius) stays caller-controlled via the `sx` prop — selector keys for internal vs caller rules are kept distinct (.MuiAutocomplete-inputRoot vs .MuiOutlinedInput-root) to avoid sx-merge collisions. API: value (committed, chip-rendered) + onChange (fires on commit OR chip-delete) + optional onCommit (fires only on explicit commit, for side effects beyond state). ProvidersStep trims ~160 lines net, drops searchDraft/commitSearch/the SearchIcon/LocationOnOutlinedIcon/IconButton imports that only existed to power the two inline instances. Four Storybook stories: Empty, WithCommittedValue, Unstyled, WithOnCommit — enough to iterate the molecule without a live page. Verified: delta=0px on the search button position (empty→draft→chip) at both mobile and desktop widths — matches pre-extraction behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import { LocationSearchInput } from './LocationSearchInput';
|
||||||
|
|
||||||
|
const meta: Meta<typeof LocationSearchInput> = {
|
||||||
|
title: 'Molecules/LocationSearchInput',
|
||||||
|
component: LocationSearchInput,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: { layout: 'centered' },
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box sx={{ width: 360, p: 2, bgcolor: 'background.default' }}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof LocationSearchInput>;
|
||||||
|
|
||||||
|
// Caller-provided chrome mirroring the ProvidersStep chip strip — useful
|
||||||
|
// for visualising the molecule in its real context. Users of the molecule
|
||||||
|
// on other surfaces would pass their own (or none).
|
||||||
|
const providerChromeSx = {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
|
borderRadius: 'var(--fa-button-border-radius-default)',
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'var(--fa-color-neutral-300)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-root.Mui-focused': {
|
||||||
|
boxShadow: 'var(--fa-shadow-sm)',
|
||||||
|
'& .MuiOutlinedInput-notchedOutline': {
|
||||||
|
borderColor: 'var(--fa-color-neutral-300)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Stories ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Empty state — no committed value, no draft. The primary magnifying-glass
|
||||||
|
* stays anchored to the right edge. */
|
||||||
|
export const Empty: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
args: { sx: providerChromeSx },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Committed-chip state — the value renders as a chip with an X to clear. */
|
||||||
|
export const WithCommittedValue: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('Wollongong, 2500');
|
||||||
|
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
args: { sx: providerChromeSx },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Unstyled — no caller chrome. Shows the raw molecule output (just the
|
||||||
|
* correctness CSS kicks in; the rest is MUI defaults). */
|
||||||
|
export const Unstyled: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
return <LocationSearchInput {...args} value={value} onChange={setValue} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** With onCommit side-effect — logs when the user explicitly commits
|
||||||
|
* (separate from the always-fired onChange). */
|
||||||
|
export const WithOnCommit: Story = {
|
||||||
|
render: (args) => {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
return (
|
||||||
|
<LocationSearchInput
|
||||||
|
{...args}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
onCommit={(v) => {
|
||||||
|
console.log('committed:', v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
args: { sx: providerChromeSx, placeholder: 'Type a suburb and press Enter' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
|
import { Chip } from '../../atoms/Chip';
|
||||||
|
import { IconButton } from '../../atoms/IconButton';
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the FA LocationSearchInput molecule */
|
||||||
|
export interface LocationSearchInputProps {
|
||||||
|
/** Committed location value. When non-empty, rendered as a chip inside
|
||||||
|
* the input; when empty, placeholder shows and the input accepts typing. */
|
||||||
|
value: string;
|
||||||
|
/** Fires whenever the committed value changes — on explicit commit (Enter
|
||||||
|
* or search button) with the new value, or on chip delete with ''. */
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
/** Optional extra callback fired *only* on explicit commit (not on chip
|
||||||
|
* delete). Useful for triggering search side-effects beyond the value
|
||||||
|
* update (analytics, external fetch, etc.). */
|
||||||
|
onCommit?: (value: string) => void;
|
||||||
|
/** Placeholder text shown when no value is committed and no draft typed. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Accessible label for the input. */
|
||||||
|
'aria-label'?: string;
|
||||||
|
/** MUI sx prop — merged after the molecule's internal correctness CSS.
|
||||||
|
* Use this to style the outlined input's chrome (bgcolor, shadow, border,
|
||||||
|
* radius). Internal CSS targets `.MuiAutocomplete-inputRoot` whereas most
|
||||||
|
* chrome sx uses `.MuiOutlinedInput-root`, so collisions are avoided. */
|
||||||
|
sx?: SxProps<Theme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal correctness CSS ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute-anchors the commit button (end adornment) to the right edge of
|
||||||
|
* the input — stock MUI Autocomplete does this on `.MuiAutocomplete-endAdornment`,
|
||||||
|
* but overriding `InputProps.endAdornment` puts our button inside a
|
||||||
|
* `.MuiInputAdornment-positionEnd` that defaults to `position: static` and
|
||||||
|
* would slide left as chips / draft text fill the input.
|
||||||
|
*
|
||||||
|
* `pr: 5` on the input root reserves the right-edge lane so input content
|
||||||
|
* can't run under the button. Selectors use `.MuiAutocomplete-inputRoot`
|
||||||
|
* (not `.MuiOutlinedInput-root`) so caller sx for chrome can sit alongside
|
||||||
|
* these rules without colliding on the same key.
|
||||||
|
*/
|
||||||
|
const INTERNAL_SX = {
|
||||||
|
'& .MuiAutocomplete-inputRoot': {
|
||||||
|
position: 'relative',
|
||||||
|
pr: 5,
|
||||||
|
},
|
||||||
|
'& .MuiAutocomplete-inputRoot .MuiInputAdornment-positionEnd': {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 8,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
height: 'auto',
|
||||||
|
maxHeight: 'none',
|
||||||
|
m: 0,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location search input with committed-chip semantics.
|
||||||
|
*
|
||||||
|
* - **Typing produces a draft** (local state, not propagated).
|
||||||
|
* - **Pressing Enter or the primary-filled magnifying-glass button commits**
|
||||||
|
* the draft: fires `onChange(draft)` and `onCommit?.(draft)`, clears the
|
||||||
|
* draft, renders the committed value as a chip inside the input.
|
||||||
|
* - **Tapping the chip's X** clears the committed value (`onChange('')`).
|
||||||
|
*
|
||||||
|
* Capped to one chip at a time — if the user commits a new value while a
|
||||||
|
* chip exists, the new value replaces it. This matches the product intent
|
||||||
|
* (one active location per search) and keeps the UX obvious.
|
||||||
|
*
|
||||||
|
* The molecule owns the endAdornment absolute-anchoring + right-side
|
||||||
|
* padding so the commit button never drifts as chips / draft fill the input.
|
||||||
|
* Chrome (bgcolor, shadow, border, radius) is caller-controlled via `sx`.
|
||||||
|
*
|
||||||
|
* Originally extracted from ProvidersStep (D046) where the same pattern
|
||||||
|
* lived inline in both the mobile-map floating strip and the desktop/mobile
|
||||||
|
* sticky search bar.
|
||||||
|
*/
|
||||||
|
export const LocationSearchInput = React.forwardRef<HTMLDivElement, LocationSearchInputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onCommit,
|
||||||
|
placeholder = 'Search a town or suburb...',
|
||||||
|
'aria-label': ariaLabel = 'Search location',
|
||||||
|
sx,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [draft, setDraft] = React.useState('');
|
||||||
|
|
||||||
|
const commit = (next: string) => {
|
||||||
|
const trimmed = next.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
onChange(trimmed);
|
||||||
|
onCommit?.(trimmed);
|
||||||
|
setDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
ref={ref}
|
||||||
|
multiple
|
||||||
|
freeSolo
|
||||||
|
options={[]}
|
||||||
|
forcePopupIcon={false}
|
||||||
|
clearIcon={null}
|
||||||
|
value={value.trim() ? [value.trim()] : []}
|
||||||
|
inputValue={draft}
|
||||||
|
onInputChange={(_, newDraft, reason) => {
|
||||||
|
// Autocomplete fires a 'reset' input-change after a commit that
|
||||||
|
// would echo the committed value back into our draft — ignore it.
|
||||||
|
if (reason === 'reset') return;
|
||||||
|
setDraft(newDraft);
|
||||||
|
}}
|
||||||
|
onChange={(_, newValue) => {
|
||||||
|
if (newValue.length === 0) {
|
||||||
|
// Chip deleted
|
||||||
|
onChange('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cap at 1: take the most-recent entry as the new committed value.
|
||||||
|
const last = newValue[newValue.length - 1];
|
||||||
|
if (typeof last === 'string') commit(last);
|
||||||
|
}}
|
||||||
|
renderTags={(val, getTagProps) =>
|
||||||
|
val.map((option, index) => {
|
||||||
|
const { key, ...chipProps } = getTagProps({ index });
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={key}
|
||||||
|
label={option}
|
||||||
|
size="small"
|
||||||
|
aria-label={`Current location: ${option}. Press delete to clear.`}
|
||||||
|
{...chipProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
placeholder={value.trim() ? '' : placeholder}
|
||||||
|
size="small"
|
||||||
|
inputProps={{
|
||||||
|
...params.inputProps,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}}
|
||||||
|
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">
|
||||||
|
<IconButton
|
||||||
|
aria-label="Search"
|
||||||
|
onClick={() => commit(draft)}
|
||||||
|
sx={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
color: 'primary.contrastText',
|
||||||
|
'&:hover': { bgcolor: 'primary.dark' },
|
||||||
|
'&:focus-visible': { outline: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
sx={[INTERNAL_SX, ...(Array.isArray(sx) ? sx : [sx])]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
LocationSearchInput.displayName = 'LocationSearchInput';
|
||||||
|
export default LocationSearchInput;
|
||||||
1
src/components/molecules/LocationSearchInput/index.ts
Normal file
1
src/components/molecules/LocationSearchInput/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LocationSearchInput, type LocationSearchInputProps } from './LocationSearchInput';
|
||||||
@@ -11,10 +11,8 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
|||||||
import ToggleButton from '@mui/material/ToggleButton';
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||||
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 PhoneIcon from '@mui/icons-material/Phone';
|
import PhoneIcon from '@mui/icons-material/Phone';
|
||||||
import type { SxProps, Theme } from '@mui/material/styles';
|
import type { SxProps, Theme } from '@mui/material/styles';
|
||||||
import { useTheme } from '@mui/material/styles';
|
import { useTheme } from '@mui/material/styles';
|
||||||
@@ -22,6 +20,7 @@ import { WizardLayout } from '../../templates/WizardLayout';
|
|||||||
import { ProviderCard } from '../../molecules/ProviderCard';
|
import { ProviderCard } from '../../molecules/ProviderCard';
|
||||||
import { FilterPanel } from '../../molecules/FilterPanel';
|
import { FilterPanel } from '../../molecules/FilterPanel';
|
||||||
import { MapProviderDrawer } from '../../molecules/MapProviderDrawer';
|
import { MapProviderDrawer } from '../../molecules/MapProviderDrawer';
|
||||||
|
import { LocationSearchInput } from '../../molecules/LocationSearchInput';
|
||||||
import {
|
import {
|
||||||
ProviderMap,
|
ProviderMap,
|
||||||
type ProviderMapActiveState,
|
type ProviderMapActiveState,
|
||||||
@@ -29,7 +28,6 @@ import {
|
|||||||
} from '../../organisms/ProviderMap';
|
} from '../../organisms/ProviderMap';
|
||||||
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 { Link } from '../../atoms/Link';
|
import { Link } from '../../atoms/Link';
|
||||||
import { Switch } from '../../atoms/Switch';
|
import { Switch } from '../../atoms/Switch';
|
||||||
import { Typography } from '../../atoms/Typography';
|
import { Typography } from '../../atoms/Typography';
|
||||||
@@ -350,10 +348,6 @@ 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('');
|
|
||||||
|
|
||||||
// ─── Mobile map-first plumbing ───
|
// ─── Mobile map-first plumbing ───
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -362,14 +356,6 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
const [mapActive, setMapActive] = React.useState<ProviderMapActiveState | null>(null);
|
const [mapActive, setMapActive] = React.useState<ProviderMapActiveState | null>(null);
|
||||||
const showMobileMapLayout = isMobile && viewMode === 'map';
|
const showMobileMapLayout = isMobile && viewMode === 'map';
|
||||||
|
|
||||||
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]));
|
||||||
const [priceMaxInput, setPriceMaxInput] = React.useState(String(filterValues.priceRange[1]));
|
const [priceMaxInput, setPriceMaxInput] = React.useState(String(filterValues.priceRange[1]));
|
||||||
@@ -606,74 +592,12 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
gap: 1,
|
gap: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Search input (mobile variant reuses the committed-chip pattern) */}
|
{/* Search input — committed-chip pattern, chrome via controlInputSx */}
|
||||||
<Autocomplete
|
<LocationSearchInput
|
||||||
multiple
|
value={searchQuery}
|
||||||
freeSolo
|
onChange={onSearchChange}
|
||||||
options={[]}
|
onCommit={onSearch}
|
||||||
forcePopupIcon={false}
|
aria-label="Search providers by town or suburb"
|
||||||
clearIcon={null}
|
|
||||||
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
|
||||||
inputValue={searchDraft}
|
|
||||||
onInputChange={(_, newDraft, reason) => {
|
|
||||||
if (reason === 'reset') return;
|
|
||||||
setSearchDraft(newDraft);
|
|
||||||
}}
|
|
||||||
onChange={(_, newValue) => {
|
|
||||||
if (newValue.length === 0) {
|
|
||||||
onSearchChange('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const last = newValue[newValue.length - 1];
|
|
||||||
if (typeof last === 'string') commitSearch(last);
|
|
||||||
}}
|
|
||||||
renderTags={(value, getTagProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const { key, ...chipProps } = getTagProps({ index });
|
|
||||||
return <Chip key={key} label={option} size="small" {...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">
|
|
||||||
<IconButton
|
|
||||||
aria-label="Search"
|
|
||||||
onClick={() => commitSearch(searchDraft)}
|
|
||||||
sx={{
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: 'primary.main',
|
|
||||||
color: 'primary.contrastText',
|
|
||||||
'&:hover': { bgcolor: 'primary.dark' },
|
|
||||||
'&:focus-visible': { outline: 'none' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SearchIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
sx={controlInputSx}
|
sx={controlInputSx}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -855,89 +779,14 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
|
|||||||
borderColor: 'divider',
|
borderColor: 'divider',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Location search — committed location renders as a chip inside the
|
{/* Location search — committed location renders as a chip inside
|
||||||
input. Typing produces a draft; Enter or the search button commit
|
the input. Shared with the mobile-map floating strip via the
|
||||||
it. Deleting the chip clears the committed filter. */}
|
LocationSearchInput molecule. */}
|
||||||
<Autocomplete
|
<LocationSearchInput
|
||||||
multiple
|
value={searchQuery}
|
||||||
freeSolo
|
onChange={onSearchChange}
|
||||||
options={[]}
|
onCommit={onSearch}
|
||||||
forcePopupIcon={false}
|
aria-label="Search providers by town or suburb"
|
||||||
clearIcon={null}
|
|
||||||
value={searchQuery.trim() ? [searchQuery.trim()] : []}
|
|
||||||
inputValue={searchDraft}
|
|
||||||
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);
|
|
||||||
}}
|
|
||||||
renderTags={(value, getTagProps) =>
|
|
||||||
value.map((option, index) => {
|
|
||||||
const { key, ...chipProps } = getTagProps({ index });
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
key={key}
|
|
||||||
label={option}
|
|
||||||
size="small"
|
|
||||||
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.5 }}>
|
|
||||||
<IconButton
|
|
||||||
aria-label="Search"
|
|
||||||
onClick={() => commitSearch(searchDraft)}
|
|
||||||
sx={{
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor: 'primary.main',
|
|
||||||
color: 'primary.contrastText',
|
|
||||||
'&:hover': { bgcolor: 'primary.dark' },
|
|
||||||
'&:focus-visible': { outline: 'none' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SearchIcon sx={{ fontSize: 16 }} />
|
|
||||||
</IconButton>
|
|
||||||
</InputAdornment>
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
sx={[controlInputSx, { mb: 1.5 }]}
|
sx={[controlInputSx, { mb: 1.5 }]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user