diff --git a/src/components/molecules/SortMenu/SortMenu.stories.tsx b/src/components/molecules/SortMenu/SortMenu.stories.tsx new file mode 100644 index 0000000..6d6e2e4 --- /dev/null +++ b/src/components/molecules/SortMenu/SortMenu.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import Box from '@mui/material/Box'; +import { SortMenu } from './SortMenu'; + +const meta: Meta = { + title: 'Molecules/SortMenu', + component: SortMenu, + tags: ['autodocs'], + parameters: { layout: 'centered' }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +const providerSortOptions = [ + { value: 'recommended', label: 'Recommended' }, + { value: 'nearest', label: 'Nearest' }, + { value: 'price_low', label: 'Price low to high' }, + { value: 'price_high', label: 'Price high to low' }, +]; + +// Caller-provided chrome mirroring ProvidersStep's chip strip. +const controlChromeSx = { + height: 32, + bgcolor: 'background.paper', + borderColor: 'var(--fa-color-neutral-300)', + borderRadius: 'var(--fa-button-border-radius-default)', + boxShadow: 'var(--fa-shadow-sm)', + textTransform: 'none', + '&:hover': { + bgcolor: 'background.paper', + borderColor: 'var(--fa-color-neutral-300)', + }, + '&:focus-visible': { outline: 'none' }, +} as const; + +// ─── Stories ──────────────────────────────────────────────────────────────── + +/** Compact variant — the trigger reads "Sort by" regardless of current + * value. Current value surfaces in the menu's selected state. Best for + * narrow layouts (mobile). */ +export const Compact: Story = { + render: (args) => { + const [value, setValue] = useState('recommended'); + return ; + }, + args: { + options: providerSortOptions, + variant: 'compact', + sx: controlChromeSx, + }, +}; + +/** Verbose variant — trigger reads "Sort: " with a + * swap-vertical icon. Best for desktop where horizontal space is cheap. */ +export const Verbose: Story = { + render: (args) => { + const [value, setValue] = useState('price_low'); + return ; + }, + args: { + options: providerSortOptions, + variant: 'verbose', + sx: controlChromeSx, + }, +}; + +/** No chrome — raw output. Useful for checking the molecule's default + * Button atom appearance before any caller sx. */ +export const Bare: Story = { + render: (args) => { + const [value, setValue] = useState('recommended'); + return ; + }, + args: { + options: providerSortOptions, + variant: 'compact', + }, +}; + +/** Smaller option set — demonstrating that the component adapts to any + * options array, not just the provider-sort defaults. */ +export const TwoOptions: Story = { + render: (args) => { + const [value, setValue] = useState('newest'); + return ; + }, + args: { + options: [ + { value: 'newest', label: 'Newest first' }, + { value: 'oldest', label: 'Oldest first' }, + ], + variant: 'verbose', + sx: controlChromeSx, + }, +}; diff --git a/src/components/molecules/SortMenu/SortMenu.tsx b/src/components/molecules/SortMenu/SortMenu.tsx new file mode 100644 index 0000000..763072a --- /dev/null +++ b/src/components/molecules/SortMenu/SortMenu.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import SwapVertIcon from '@mui/icons-material/SwapVert'; +import type { SxProps, Theme } from '@mui/material/styles'; +import { Button } from '../../atoms/Button'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +/** A sort option shown in the menu */ +export interface SortOption { + /** Machine-readable value (e.g. 'price_low'). Passed back via `onChange`. */ + value: string; + /** Human-readable label (e.g. 'Price low to high'). Shown in the menu and, + * in the `verbose` variant, on the trigger button. */ + label: string; +} + +/** Props for the FA SortMenu molecule */ +export interface SortMenuProps { + /** Current sort value (controlled). Must match one of the options' values. */ + value: string; + /** Fires when the user picks a different sort option. */ + onChange: (value: string) => void; + /** Sort options to surface in the menu, in display order. */ + options: SortOption[]; + /** Trigger label variant: + * - `compact` (default): button reads just "Sort by"; current value + * surfaces only in the menu's selected item and in the aria-label. + * Best for narrow surfaces (mobile, chip-strip floating controls). + * - `verbose`: button reads "Sort: " with a leading + * swap-vertical icon. Best for desktop where horizontal space is + * cheap and the current value is worth surfacing inline. */ + variant?: 'compact' | 'verbose'; + /** MUI sx prop — applied to the trigger Button. Callers pass chrome + * (bgcolor, border, shadow, radius, height) here. */ + sx?: SxProps; +} + +// ─── Component ────────────────────────────────────────────────────────────── + +/** + * Dropdown sort control — a trigger Button + anchored Menu. + * + * Tap the button → menu opens anchored to the button's bottom-right; pick + * an option → menu closes and `onChange` fires with the new value. The + * currently-selected option is visually marked in the menu (MUI's + * `selected` state on MenuItem). + * + * **Accessibility:** trigger button has `aria-haspopup="listbox"` and an + * `aria-label` that spells out the current sort ("Sort by Recommended"), + * so screen-reader users get the state regardless of which label variant + * is rendered. Selected MenuItem has `aria-selected="true"` via MUI. + * + * Originally extracted from ProvidersStep (which had the same Button + + * Menu pattern inline in two places with a minor "Sort by" vs + * "Sort: