Add SearchBar molecule — search input with optional submit button

- Composes Input + IconButton + Button for provider/venue search
- Enter-to-submit, progressive clear button, inline loading spinner
- Two sizes aligned with Input/Button (medium 48px, small 40px)
- Guards empty submissions, refocuses input after clear
- role="search" landmark for screen reader navigation
- Critique: 35/40 — P2 fixes applied (empty submit, inline loading, refocus)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 20:50:05 +11:00
parent 1ed8a026ef
commit 667c97a237
3 changed files with 440 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import { SearchBar } from './SearchBar';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
const meta: Meta<typeof SearchBar> = {
title: 'Molecules/SearchBar',
component: SearchBar,
tags: ['autodocs'],
parameters: {
layout: 'centered',
},
argTypes: {
value: { control: 'text' },
placeholder: { control: 'text' },
size: { control: 'select', options: ['medium', 'small'] },
showButton: { control: 'boolean' },
buttonLabel: { control: 'text' },
disabled: { control: 'boolean' },
loading: { control: 'boolean' },
fullWidth: { control: 'boolean' },
},
decorators: [
(Story) => (
<Box sx={{ width: 480 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof SearchBar>;
// ─── Default ────────────────────────────────────────────────────────────────
/** Default — input-only search bar with Enter to submit */
export const Default: Story = {
render: function Render() {
const [value, setValue] = React.useState('');
const [submitted, setSubmitted] = React.useState('');
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<SearchBar
value={value}
onChange={setValue}
onSearch={setSubmitted}
placeholder="Search funeral directors..."
/>
{submitted && (
<Typography variant="body2" color="text.secondary">
Searched for: &ldquo;{submitted}&rdquo;
</Typography>
)}
</Box>
);
},
};
// ─── With Button ────────────────────────────────────────────────────────────
/** Search bar with an explicit submit button */
export const WithButton: Story = {
render: function Render() {
const [value, setValue] = React.useState('');
const [submitted, setSubmitted] = React.useState('');
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<SearchBar
value={value}
onChange={setValue}
onSearch={setSubmitted}
placeholder="Search venues..."
showButton
/>
{submitted && (
<Typography variant="body2" color="text.secondary">
Searched for: &ldquo;{submitted}&rdquo;
</Typography>
)}
</Box>
);
},
};
// ─── Small Size ─────────────────────────────────────────────────────────────
/** Small size (40px) — for compact layouts like sidebar filters */
export const SmallSize: Story = {
render: function Render() {
const [value, setValue] = React.useState('');
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography variant="label" sx={{ mb: 1, display: 'block' }}>
Medium (48px) default
</Typography>
<SearchBar
value={value}
onChange={setValue}
placeholder="Search providers..."
showButton
/>
</Box>
<Box>
<Typography variant="label" sx={{ mb: 1, display: 'block' }}>
Small (40px) compact
</Typography>
<SearchBar
value={value}
onChange={setValue}
placeholder="Search providers..."
size="small"
showButton
/>
</Box>
</Box>
);
},
};
// ─── With Preloaded Value ───────────────────────────────────────────────────
/** Pre-filled value with clear button visible */
export const WithPreloadedValue: Story = {
render: function Render() {
const [value, setValue] = React.useState('Melbourne funeral directors');
return (
<SearchBar
value={value}
onChange={setValue}
placeholder="Search funeral directors..."
showButton
/>
);
},
};
// ─── Loading State ──────────────────────────────────────────────────────────
/** Loading — search in progress, button shows spinner */
export const Loading: Story = {
render: function Render() {
const [value, setValue] = React.useState('Parsons funeral');
return (
<SearchBar
value={value}
onChange={setValue}
placeholder="Search..."
showButton
loading
/>
);
},
};
// ─── Disabled ───────────────────────────────────────────────────────────────
/** Disabled — entire search bar is non-interactive */
export const Disabled: Story = {
render: function Render() {
const [value, setValue] = React.useState('');
return (
<SearchBar
value={value}
onChange={setValue}
placeholder="Search unavailable..."
showButton
disabled
/>
);
},
};
// ─── Provider Search ────────────────────────────────────────────────────────
/** Realistic — provider search on listing page */
export const ProviderSearch: Story = {
render: function Render() {
const [value, setValue] = React.useState('');
const [results, setResults] = React.useState<string[]>([]);
const providers = [
'H. Parsons Funeral Directors',
'Peaceful Rest Funerals',
'Heritage Memorials',
'Garden of Memories',
'Bayside Funeral Services',
];
const handleSearch = (query: string) => {
if (!query.trim()) {
setResults([]);
return;
}
setResults(
providers.filter((p) =>
p.toLowerCase().includes(query.toLowerCase()),
),
);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5">Find a funeral director</Typography>
<SearchBar
value={value}
onChange={(v) => {
setValue(v);
handleSearch(v);
}}
onSearch={handleSearch}
placeholder="Search by name or suburb..."
showButton
buttonLabel="Find"
/>
{results.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{results.map((name) => (
<Typography key={name} variant="body2">
{name}
</Typography>
))}
</Box>
)}
{value && results.length === 0 && (
<Typography variant="body2" color="text.secondary">
No providers match &ldquo;{value}&rdquo;
</Typography>
)}
</Box>
);
},
};
// ─── Venue Search ───────────────────────────────────────────────────────────
/** Realistic — venue search with compact size */
export const VenueSearch: Story = {
render: function Render() {
const [value, setValue] = React.useState('');
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5">Find a venue</Typography>
<Typography variant="body2" color="text.secondary">
Search chapels, gardens, churches, and function venues near you.
</Typography>
<SearchBar
value={value}
onChange={setValue}
placeholder="Suburb, postcode, or venue name..."
size="small"
/>
</Box>
);
},
};

View File

@@ -0,0 +1,173 @@
import React from 'react';
import Box from '@mui/material/Box';
import InputAdornment from '@mui/material/InputAdornment';
import CircularProgress from '@mui/material/CircularProgress';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import type { SxProps, Theme } from '@mui/material/styles';
import { Input } from '../../atoms/Input';
import { IconButton } from '../../atoms/IconButton';
import { Button } from '../../atoms/Button';
// ─── Types ───────────────────────────────────────────────────────────────────
/** Props for the FA SearchBar molecule */
export interface SearchBarProps {
/** Current search value (controlled) */
value: string;
/** Called when the search value changes */
onChange: (value: string) => void;
/** Called when the user submits a search (Enter key or button click) */
onSearch?: (value: string) => void;
/** Placeholder text */
placeholder?: string;
/** Input size — `medium` (48px) or `small` (40px) */
size?: 'medium' | 'small';
/** Whether to show a submit button alongside the input */
showButton?: boolean;
/** Label for the submit button (default: "Search") */
buttonLabel?: string;
/** Whether the search bar is disabled */
disabled?: boolean;
/** Whether a search is in progress (shows loading on button) */
loading?: boolean;
/** Whether the input takes full width of its container */
fullWidth?: boolean;
/** Accessible label for the search input */
'aria-label'?: string;
/** MUI sx prop for the root container */
sx?: SxProps<Theme>;
}
// ─── Component ───────────────────────────────────────────────────────────────
/**
* Search bar for the FA design system.
*
* Composes Input (with search icon) + optional Button for provider and
* venue search screens. Supports controlled input, Enter-to-submit,
* clear button, and a loading state for async searches.
*
* Sizes align with Input + Button for visual harmony:
* - `medium` — Input medium (48px) + Button large (48px)
* - `small` — Input small (40px) + Button medium (40px)
*
* Usage:
* ```tsx
* <SearchBar
* value={query}
* onChange={setQuery}
* onSearch={handleSearch}
* placeholder="Search funeral directors..."
* />
* ```
*/
export const SearchBar = React.forwardRef<HTMLDivElement, SearchBarProps>(
(
{
value,
onChange,
onSearch,
placeholder = 'Search...',
size = 'medium',
showButton = false,
buttonLabel = 'Search',
disabled = false,
loading = false,
fullWidth = true,
'aria-label': ariaLabel = 'Search',
sx,
},
ref,
) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && onSearch && value.trim()) {
onSearch(value.trim());
}
};
const handleClear = () => {
onChange('');
// Refocus the input after clearing so the user can resume typing
inputRef.current?.focus();
};
const handleSubmit = () => {
if (onSearch && value.trim()) {
onSearch(value.trim());
}
};
// Button size aligns with input: medium input → large button (both 48px)
const buttonSize = size === 'medium' ? 'large' : 'medium';
return (
<Box
ref={ref}
role="search"
sx={[
{
display: 'flex',
alignItems: 'flex-start',
gap: 1.5,
...(fullWidth && { width: '100%' }),
},
...(Array.isArray(sx) ? sx : [sx]),
]}
>
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
size={size}
disabled={disabled}
fullWidth
startIcon={<SearchIcon />}
inputRef={inputRef}
endAdornment={
loading && !showButton ? (
<InputAdornment position="end">
<CircularProgress size={18} color="inherit" aria-label="Searching" />
</InputAdornment>
) : value ? (
<InputAdornment position="end">
<IconButton
aria-label="Clear search"
onClick={handleClear}
size="small"
disabled={disabled}
edge="end"
sx={{ mr: -0.5 }}
>
<ClearIcon />
</IconButton>
</InputAdornment>
) : undefined
}
inputProps={{
'aria-label': ariaLabel,
}}
/>
{showButton && (
<Button
variant="contained"
size={buttonSize}
onClick={handleSubmit}
disabled={disabled || !value.trim()}
loading={loading}
sx={{ flexShrink: 0 }}
>
{buttonLabel}
</Button>
)}
</Box>
);
},
);
SearchBar.displayName = 'SearchBar';
export default SearchBar;

View File

@@ -0,0 +1,2 @@
export { SearchBar } from './SearchBar';
export type { SearchBarProps } from './SearchBar';