From 4f433e2a8fb04aa0fcc64428594696e1396cd8d6 Mon Sep 17 00:00:00 2001 From: Richie Date: Thu, 23 Apr 2026 10:11:08 +1000 Subject: [PATCH] Extract LocationSearchInput molecule from ProvidersStep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../LocationSearchInput.stories.tsx | 92 ++++++++ .../LocationSearchInput.tsx | 199 ++++++++++++++++++ .../molecules/LocationSearchInput/index.ts | 1 + .../pages/ProvidersStep/ProvidersStep.tsx | 181 ++-------------- 4 files changed, 307 insertions(+), 166 deletions(-) create mode 100644 src/components/molecules/LocationSearchInput/LocationSearchInput.stories.tsx create mode 100644 src/components/molecules/LocationSearchInput/LocationSearchInput.tsx create mode 100644 src/components/molecules/LocationSearchInput/index.ts diff --git a/src/components/molecules/LocationSearchInput/LocationSearchInput.stories.tsx b/src/components/molecules/LocationSearchInput/LocationSearchInput.stories.tsx new file mode 100644 index 0000000..81869ee --- /dev/null +++ b/src/components/molecules/LocationSearchInput/LocationSearchInput.stories.tsx @@ -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 = { + title: 'Molecules/LocationSearchInput', + component: LocationSearchInput, + tags: ['autodocs'], + parameters: { layout: 'centered' }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// 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 ; + }, + 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 ; + }, + 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 ; + }, +}; + +/** 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 ( + { + console.log('committed:', v); + }} + /> + ); + }, + args: { sx: providerChromeSx, placeholder: 'Type a suburb and press Enter' }, +}; diff --git a/src/components/molecules/LocationSearchInput/LocationSearchInput.tsx b/src/components/molecules/LocationSearchInput/LocationSearchInput.tsx new file mode 100644 index 0000000..e4dd5ae --- /dev/null +++ b/src/components/molecules/LocationSearchInput/LocationSearchInput.tsx @@ -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; +} + +// ─── 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( + ( + { + 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 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 ( + + ); + }) + } + renderInput={(params) => ( + + + + + {params.InputProps.startAdornment} + + ), + endAdornment: ( + + commit(draft)} + sx={{ + width: 28, + height: 28, + borderRadius: '50%', + bgcolor: 'primary.main', + color: 'primary.contrastText', + '&:hover': { bgcolor: 'primary.dark' }, + '&:focus-visible': { outline: 'none' }, + }} + > + + + + ), + }} + /> + )} + sx={[INTERNAL_SX, ...(Array.isArray(sx) ? sx : [sx])]} + /> + ); + }, +); + +LocationSearchInput.displayName = 'LocationSearchInput'; +export default LocationSearchInput; diff --git a/src/components/molecules/LocationSearchInput/index.ts b/src/components/molecules/LocationSearchInput/index.ts new file mode 100644 index 0000000..5588e31 --- /dev/null +++ b/src/components/molecules/LocationSearchInput/index.ts @@ -0,0 +1 @@ +export { LocationSearchInput, type LocationSearchInputProps } from './LocationSearchInput'; diff --git a/src/components/pages/ProvidersStep/ProvidersStep.tsx b/src/components/pages/ProvidersStep/ProvidersStep.tsx index 3db5ee5..e2c1918 100644 --- a/src/components/pages/ProvidersStep/ProvidersStep.tsx +++ b/src/components/pages/ProvidersStep/ProvidersStep.tsx @@ -11,10 +11,8 @@ import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButton from '@mui/material/ToggleButton'; import useMediaQuery from '@mui/material/useMediaQuery'; import SwapVertIcon from '@mui/icons-material/SwapVert'; -import SearchIcon from '@mui/icons-material/Search'; import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined'; import MapOutlinedIcon from '@mui/icons-material/MapOutlined'; -import LocationOnOutlinedIcon from '@mui/icons-material/LocationOnOutlined'; import PhoneIcon from '@mui/icons-material/Phone'; import type { SxProps, Theme } 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 { FilterPanel } from '../../molecules/FilterPanel'; import { MapProviderDrawer } from '../../molecules/MapProviderDrawer'; +import { LocationSearchInput } from '../../molecules/LocationSearchInput'; import { ProviderMap, type ProviderMapActiveState, @@ -29,7 +28,6 @@ import { } from '../../organisms/ProviderMap'; import { Button } from '../../atoms/Button'; import { Chip } from '../../atoms/Chip'; -import { IconButton } from '../../atoms/IconButton'; import { Link } from '../../atoms/Link'; import { Switch } from '../../atoms/Switch'; import { Typography } from '../../atoms/Typography'; @@ -350,10 +348,6 @@ export const ProvidersStep: React.FC = ({ // ─── Local state ─── const [sortAnchor, setSortAnchor] = React.useState(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 ─── const theme = useTheme(); @@ -362,14 +356,6 @@ export const ProvidersStep: React.FC = ({ const [mapActive, setMapActive] = React.useState(null); 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) ─── const [priceMinInput, setPriceMinInput] = React.useState(String(filterValues.priceRange[0])); const [priceMaxInput, setPriceMaxInput] = React.useState(String(filterValues.priceRange[1])); @@ -606,74 +592,12 @@ export const ProvidersStep: React.FC = ({ gap: 1, }} > - {/* Search input (mobile variant reuses the committed-chip pattern) */} - { - 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 ; - }) - } - renderInput={(params) => ( - - - - - {params.InputProps.startAdornment} - - ), - endAdornment: ( - - commitSearch(searchDraft)} - sx={{ - width: 28, - height: 28, - borderRadius: '50%', - bgcolor: 'primary.main', - color: 'primary.contrastText', - '&:hover': { bgcolor: 'primary.dark' }, - '&:focus-visible': { outline: 'none' }, - }} - > - - - - ), - }} - /> - )} + {/* Search input — committed-chip pattern, chrome via controlInputSx */} + @@ -855,89 +779,14 @@ export const ProvidersStep: React.FC = ({ borderColor: 'divider', }} > - {/* Location search — committed location renders as a chip inside the - input. Typing produces a draft; Enter or the search button commit - it. Deleting the chip clears the committed filter. */} - { - // 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 ( - - ); - }) - } - renderInput={(params) => ( - - - - - {params.InputProps.startAdornment} - - ), - endAdornment: ( - - commitSearch(searchDraft)} - sx={{ - width: 28, - height: 28, - borderRadius: '50%', - bgcolor: 'primary.main', - color: 'primary.contrastText', - '&:hover': { bgcolor: 'primary.dark' }, - '&:focus-visible': { outline: 'none' }, - }} - > - - - - ), - }} - /> - )} + {/* Location search — committed location renders as a chip inside + the input. Shared with the mobile-map floating strip via the + LocationSearchInput molecule. */} +