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