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:
265
src/components/molecules/SearchBar/SearchBar.stories.tsx
Normal file
265
src/components/molecules/SearchBar/SearchBar.stories.tsx
Normal 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: “{submitted}”
|
||||||
|
</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: “{submitted}”
|
||||||
|
</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 “{value}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
173
src/components/molecules/SearchBar/SearchBar.tsx
Normal file
173
src/components/molecules/SearchBar/SearchBar.tsx
Normal 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;
|
||||||
2
src/components/molecules/SearchBar/index.ts
Normal file
2
src/components/molecules/SearchBar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { SearchBar } from './SearchBar';
|
||||||
|
export type { SearchBarProps } from './SearchBar';
|
||||||
Reference in New Issue
Block a user