Extract SortMenu molecule from ProvidersStep

Lifts the sort Button + anchored Menu pattern into a reusable molecule.
ProvidersStep previously had the same trigger-button + menu-items-with-
selected-state inline twice (mobile-map floating strip + desktop sticky
bar) with a minor variant split — "Sort by" compact label on mobile vs
"Sort: <label>" verbose label on desktop with a swap-vertical icon.

API: value (controlled string) + onChange + options array +
variant ('compact' | 'verbose') + sx (trigger chrome). Non-generic
string typing keeps the forwardRef clean; callers with typed unions
cast at the boundary (trivial, one line). Anchor state is fully
internal to the molecule.

Four Storybook stories (Compact, Verbose, Bare, TwoOptions) exercise
both variants, the bare-no-chrome default, and a non-provider options
set to demonstrate reuse.

Not tied to a product-specific sort domain — intended for VenueStep,
CoffinsStep, or any future page needing a sort menu. ProvidersStep's
SORT_OPTIONS stays in the page as the caller's domain data; the
molecule just renders whatever options it's given.

ProvidersStep cleanup: drops the Button, Menu, MenuItem, SwapVertIcon
imports and the sortAnchor state that only supported the inline
version.

Verified: compact label on mobile ("Sort by"), verbose label on
desktop ("Sort: Recommended" + swap icon), menu anchoring,
selected-state, aria-label all match pre-extraction behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 10:30:00 +10:00
parent 2b39f43264
commit a7db1974c3
4 changed files with 239 additions and 84 deletions

View File

@@ -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<typeof SortMenu> = {
title: 'Molecules/SortMenu',
component: SortMenu,
tags: ['autodocs'],
parameters: { layout: 'centered' },
decorators: [
(Story) => (
<Box sx={{ p: 4 }}>
<Story />
</Box>
),
],
};
export default meta;
type Story = StoryObj<typeof SortMenu>;
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 <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: providerSortOptions,
variant: 'compact',
sx: controlChromeSx,
},
};
/** Verbose variant — trigger reads "Sort: <current label>" 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 <SortMenu {...args} value={value} onChange={setValue} />;
},
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 <SortMenu {...args} value={value} onChange={setValue} />;
},
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 <SortMenu {...args} value={value} onChange={setValue} />;
},
args: {
options: [
{ value: 'newest', label: 'Newest first' },
{ value: 'oldest', label: 'Oldest first' },
],
variant: 'verbose',
sx: controlChromeSx,
},
};

View File

@@ -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: <current label>" 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<Theme>;
}
// ─── 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: <label>" difference). Intended for reuse on VenueStep,
* CoffinsStep, or anywhere a sort menu is needed.
*/
export const SortMenu = React.forwardRef<HTMLButtonElement, SortMenuProps>(
({ value, onChange, options, variant = 'compact', sx }, ref) => {
const [anchor, setAnchor] = React.useState<null | HTMLElement>(null);
const current = options.find((o) => o.value === value);
const ariaLabel = `Sort by ${current?.label ?? 'default'}`;
return (
<>
<Button
ref={ref}
variant="outlined"
color="secondary"
size="small"
startIcon={variant === 'verbose' ? <SwapVertIcon sx={{ fontSize: 16 }} /> : undefined}
onClick={(e) => setAnchor(e.currentTarget)}
aria-haspopup="listbox"
aria-label={ariaLabel}
sx={sx}
>
{variant === 'compact' ? (
'Sort by'
) : (
<>
<Box component="span" sx={{ color: 'text.secondary', fontWeight: 400, mr: 0.5 }}>
Sort:
</Box>
{current?.label ?? ''}
</>
)}
</Button>
<Menu
anchorEl={anchor}
open={Boolean(anchor)}
onClose={() => setAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{options.map((opt) => (
<MenuItem
key={opt.value}
selected={opt.value === value}
onClick={() => {
onChange(opt.value);
setAnchor(null);
}}
sx={{ fontSize: '0.813rem' }}
>
{opt.label}
</MenuItem>
))}
</Menu>
</>
);
},
);
SortMenu.displayName = 'SortMenu';
export default SortMenu;

View File

@@ -0,0 +1 @@
export { SortMenu, type SortMenuProps, type SortOption } from './SortMenu';

View File

@@ -5,12 +5,9 @@ import InputAdornment from '@mui/material/InputAdornment';
import Autocomplete from '@mui/material/Autocomplete';
import FormControlLabel from '@mui/material/FormControlLabel';
import Slider from '@mui/material/Slider';
import MenuItem from '@mui/material/MenuItem';
import Menu from '@mui/material/Menu';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import ToggleButton from '@mui/material/ToggleButton';
import useMediaQuery from '@mui/material/useMediaQuery';
import SwapVertIcon from '@mui/icons-material/SwapVert';
import ViewListOutlinedIcon from '@mui/icons-material/ViewListOutlined';
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
import type { SxProps, Theme } from '@mui/material/styles';
@@ -21,12 +18,12 @@ import { FilterPanel } from '../../molecules/FilterPanel';
import { MapProviderDrawer } from '../../molecules/MapProviderDrawer';
import { LocationSearchInput } from '../../molecules/LocationSearchInput';
import { HelpBar } from '../../molecules/HelpBar';
import { SortMenu } from '../../molecules/SortMenu';
import {
ProviderMap,
type ProviderMapActiveState,
type ProviderMapHandle,
} from '../../organisms/ProviderMap';
import { Button } from '../../atoms/Button';
import { Chip } from '../../atoms/Chip';
import { Switch } from '../../atoms/Switch';
import { Typography } from '../../atoms/Typography';
@@ -345,9 +342,6 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
? 'Take your time exploring providers. You can always come back and choose a different one.'
: 'These providers are near your location. Each has their own packages and pricing.';
// ─── Local state ───
const [sortAnchor, setSortAnchor] = React.useState<null | HTMLElement>(null);
// ─── Mobile map-first plumbing ───
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@@ -608,40 +602,14 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
{filterDialogChildren}
</FilterPanel>
{/* Sort — compact "Sort by" trigger; current value surfaces in
the menu's selected state and in the aria-label. */}
<Button
variant="outlined"
color="secondary"
size="small"
onClick={(e) => setSortAnchor(e.currentTarget)}
aria-haspopup="listbox"
aria-label={`Sort by ${SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}`}
{/* Sort — compact trigger on the mobile floating strip */}
<SortMenu
value={sortBy}
onChange={(v) => onSortChange?.(v as ProviderSortBy)}
options={SORT_OPTIONS}
variant="compact"
sx={controlButtonSx}
>
Sort by
</Button>
<Menu
anchorEl={sortAnchor}
open={Boolean(sortAnchor)}
onClose={() => setSortAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{SORT_OPTIONS.map((opt) => (
<MenuItem
key={opt.value}
selected={opt.value === sortBy}
onClick={() => {
onSortChange?.(opt.value);
setSortAnchor(null);
}}
sx={{ fontSize: '0.813rem' }}
>
{opt.label}
</MenuItem>
))}
</Menu>
/>
{/* View toggle — right-aligned; same outline/radius/fill/shadow
as Filters + Sort, with brand fill on the selected side. */}
@@ -784,52 +752,16 @@ export const ProvidersStep: React.FC<ProvidersStepProps> = ({
{filterDialogChildren}
</FilterPanel>
{/* Sort — mobile shows a compact "Sort by" (grouped left next to
Filters, matching the map-view order); desktop shows the full
"Sort: <value>" with its swap icon, pushed to the right. */}
{/* Sort — compact "Sort by" on mobile (grouped left next to
Filters); verbose "Sort: <label>" on desktop (pushed right). */}
<Box sx={{ ml: { xs: 0, md: 'auto' } }}>
<Button
variant="outlined"
color="secondary"
size="small"
startIcon={isMobile ? undefined : <SwapVertIcon sx={{ fontSize: 16 }} />}
onClick={(e) => setSortAnchor(e.currentTarget)}
aria-haspopup="listbox"
aria-label={`Sort by ${SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}`}
<SortMenu
value={sortBy}
onChange={(v) => onSortChange?.(v as ProviderSortBy)}
options={SORT_OPTIONS}
variant={isMobile ? 'compact' : 'verbose'}
sx={controlButtonSx}
>
{isMobile ? (
'Sort by'
) : (
<>
<Box component="span" sx={{ color: 'text.secondary', fontWeight: 400, mr: 0.5 }}>
Sort:
</Box>
{SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? 'Recommended'}
</>
)}
</Button>
<Menu
anchorEl={sortAnchor}
open={Boolean(sortAnchor)}
onClose={() => setSortAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
{SORT_OPTIONS.map((opt) => (
<MenuItem
key={opt.value}
selected={opt.value === sortBy}
onClick={() => {
onSortChange?.(opt.value);
setSortAnchor(null);
}}
sx={{ fontSize: '0.813rem' }}
>
{opt.label}
</MenuItem>
))}
</Menu>
/>
</Box>
{/* Mobile-only view toggle — pinned to the right via ml: auto on xs.