diff --git a/src/components/molecules/SearchBar/SearchBar.stories.tsx b/src/components/molecules/SearchBar/SearchBar.stories.tsx new file mode 100644 index 0000000..956f9b9 --- /dev/null +++ b/src/components/molecules/SearchBar/SearchBar.stories.tsx @@ -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 = { + 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) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +// ─── 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 ( + + + {submitted && ( + + Searched for: “{submitted}” + + )} + + ); + }, +}; + +// ─── 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 ( + + + {submitted && ( + + Searched for: “{submitted}” + + )} + + ); + }, +}; + +// ─── Small Size ───────────────────────────────────────────────────────────── + +/** Small size (40px) — for compact layouts like sidebar filters */ +export const SmallSize: Story = { + render: function Render() { + const [value, setValue] = React.useState(''); + + return ( + + + + Medium (48px) — default + + + + + + Small (40px) — compact + + + + + ); + }, +}; + +// ─── 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 ( + + ); + }, +}; + +// ─── Loading State ────────────────────────────────────────────────────────── + +/** Loading — search in progress, button shows spinner */ +export const Loading: Story = { + render: function Render() { + const [value, setValue] = React.useState('Parsons funeral'); + + return ( + + ); + }, +}; + +// ─── Disabled ─────────────────────────────────────────────────────────────── + +/** Disabled — entire search bar is non-interactive */ +export const Disabled: Story = { + render: function Render() { + const [value, setValue] = React.useState(''); + + return ( + + ); + }, +}; + +// ─── 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([]); + + 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 ( + + Find a funeral director + { + setValue(v); + handleSearch(v); + }} + onSearch={handleSearch} + placeholder="Search by name or suburb..." + showButton + buttonLabel="Find" + /> + {results.length > 0 && ( + + {results.map((name) => ( + + {name} + + ))} + + )} + {value && results.length === 0 && ( + + No providers match “{value}” + + )} + + ); + }, +}; + +// ─── Venue Search ─────────────────────────────────────────────────────────── + +/** Realistic — venue search with compact size */ +export const VenueSearch: Story = { + render: function Render() { + const [value, setValue] = React.useState(''); + + return ( + + Find a venue + + Search chapels, gardens, churches, and function venues near you. + + + + ); + }, +}; diff --git a/src/components/molecules/SearchBar/SearchBar.tsx b/src/components/molecules/SearchBar/SearchBar.tsx new file mode 100644 index 0000000..5361cd1 --- /dev/null +++ b/src/components/molecules/SearchBar/SearchBar.tsx @@ -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; +} + +// ─── 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 + * + * ``` + */ +export const SearchBar = React.forwardRef( + ( + { + 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(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 ( + + onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + size={size} + disabled={disabled} + fullWidth + startIcon={} + inputRef={inputRef} + endAdornment={ + loading && !showButton ? ( + + + + ) : value ? ( + + + + + + ) : undefined + } + inputProps={{ + 'aria-label': ariaLabel, + }} + /> + + {showButton && ( + + )} + + ); + }, +); + +SearchBar.displayName = 'SearchBar'; +export default SearchBar; diff --git a/src/components/molecules/SearchBar/index.ts b/src/components/molecules/SearchBar/index.ts new file mode 100644 index 0000000..8785990 --- /dev/null +++ b/src/components/molecules/SearchBar/index.ts @@ -0,0 +1,2 @@ +export { SearchBar } from './SearchBar'; +export type { SearchBarProps } from './SearchBar';