import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters" import type { Meta, StoryObj } from "@storybook/react" import { type CSSProperties, useState } from "react" import { Badge } from "../Badge/Badge.tsx" import { Button } from "../Button/Button.tsx" import { ContainerBoard, StoryCell, StoryGrid, } from "../board.storyHelpers.tsx" import { EmptyState } from "../EmptyState/EmptyState.tsx" import { ValuePairs } from "../ValuePairs/ValuePairs.tsx" import type { GroupedListGroup, GroupedListItem, } from "./GroupedList.tsx" import { GroupedList } from "./GroupedList.tsx" import { GROUPED_LIST_HEADING_SURFACE_PROPERTY } from "./groupedListStickyHeading.ts" /** * Fixture data only. A signage app's pages, grouped by the screen * that shows them — the shape castkit's page picker draws. */ const openButton = (name: string, prefix = "") => ( ) const SIGNAGE_GROUPS: GroupedListGroup[] = [ { heading: "Screens", items: [ { actions: openButton("Lobby"), badges: PIN, description: "3 views: Welcome, Weather, and Schedule", key: "lobby", label: "Lobby", }, { actions: openButton("Break Room"), description: "1 view: Menu", key: "break-room", label: "Break Room", }, ], key: "screens", }, { heading: "On the Lobby screen", items: [ { actions: openButton("Welcome"), description: "/views/welcome", key: "welcome", label: "Welcome", }, { actions: openButton("Weather"), description: "/views/weather", key: "weather", label: "Weather", }, { actions: openButton("Schedule"), description: "/views/schedule", key: "schedule", label: "Schedule", }, ], key: "screen:lobby", }, { heading: "Not on a screen", items: [ { actions: openButton("Test Pattern"), description: "/views/test-pattern", key: "test-pattern", label: "Test Pattern", }, ], key: "views", }, ] /** * Fixture data only. Chores grouped by the team that earns them, * each with a summary, badges and an Edit button — the shape * tally-marks' card list draws. */ const CHORE_GROUPS: GroupedListGroup[] = [ { heading: "Team Orion", items: [ { actions: ( ), badges: ( <> Once a day after 3:00 PM ), content: "Up to 5 pts a day", description: "water-ferns", key: "water-ferns", label: "Water the ferns", }, { actions: ( ), badges: ( No tag ), content: "Up to 3 scans a day · 6 pts max", description: "sort-recycling", key: "sort-recycling", label: "Sort the recycling", }, ], key: "orion", summary: "2 cards · up to 11 pts a day", }, { heading: "Team Vega", items: [ { actions: ( ), badges: ( Once a day ), content: "Up to 2 pts a day", description: "feed-goldfish", key: "feed-goldfish", label: "Feed the goldfish", }, ], key: "vega", summary: "1 card · up to 2 pts a day", }, ] /** * Fixture data only. A chore card whose whole row opens its editor: * the shape tally-marks' Rows view draws, with the payout beside the * text and the daily cap under it. */ type ChoreCard = { isActive: boolean isHighlighted?: boolean key: string label: string maxPerDay: number points: number } const CHORE_CARDS: ChoreCard[] = [ { isActive: true, key: "water-ferns", label: "Water the ferns", maxPerDay: 5, points: 5, }, { isActive: true, isHighlighted: true, key: "sort-recycling", label: "Sort the recycling", maxPerDay: 6, points: 2, }, { isActive: false, key: "feed-goldfish", label: "Feed the goldfish", maxPerDay: 2, points: 2, }, ] /** * One chore card as a row. `onSelect` opens it; the action beside it * is a separate target. A switched-off card is muted and says so in * a badge. */ const toChoreRow = ( card: ChoreCard, { onDelete, onSelect, }: { onDelete?: (label: string) => void onSelect?: (label: string) => void } = {}, ): GroupedListItem => ({ actions: ( ), aside: ( ), badges: card.isActive ? undefined : ( off ), content: `Up to ${card.maxPerDay} pts a day`, description: card.key, highlightIntent: "success", isHighlighted: card.isHighlighted, isMuted: !card.isActive, key: card.key, label: card.label, onSelect: () => { onSelect?.(card.label) }, selectLabel: `Edit ${card.label}`, }) const toChoreGroups = ( handlers?: Parameters[1], ): GroupedListGroup[] => [ { heading: "Team Orion", items: CHORE_CARDS.map((card) => toChoreRow(card, handlers), ), key: "orion", summary: "3 cards · up to 11 pts a day", }, ] /** * Fixture data only. The same plain row in each highlight intent. */ const HIGHLIGHT_INTENT_GROUPS: GroupedListGroup[] = [ { heading: "Highlight intents", items: ( [ "accent", "success", "info", "warning", "danger", "neutral", ] as const ).map((intent) => ({ description: `highlightIntent="${intent}"`, highlightIntent: intent, isHighlighted: true, key: intent, label: `Highlighted ${intent}`, })), key: "intents", }, ] const LONG_GROUPS: GroupedListGroup[] = [ { heading: "On the Conference Center North Atrium Wayfinding screen", items: [ { actions: openButton( "Quarterly All-Hands Countdown and Seating Chart", ), badges: ( <> PIN Rotates every 30 s ), description: "/views/quarterly-all-hands-countdown-and-seating-chart-for-the-north-atrium", key: "all-hands", label: "Quarterly All-Hands Countdown and Seating Chart", }, { actions: openButton("Map"), description: "/views/map", key: "map", label: "Map", }, ], key: "atrium", }, ] /** * Fixture data only. Enough screens that the pane has to scroll, so * a stuck heading has rows to sit over. */ const MANY_SCREEN_GROUPS: GroupedListGroup[] = [ "Lobby", "Break Room", "Loading Dock", ].map((screen) => ({ heading: `On the ${screen} screen`, items: ["Welcome", "Weather", "Schedule", "Menu"].map( (view) => ({ actions: openButton(`${view} on ${screen}`), description: `/views/${view.toLowerCase()}`, key: view, label: view, }), ), key: screen, })) /** * A fixed block size is what makes the pane scroll; the heading * sticks to the pane, the nearest scrolling ancestor. */ const SCROLL_PANE_STYLE: CSSProperties = { blockSize: "16rem", } /** * The host says which surface the list sits on. The pane is raised, * so the stuck heading is raised too. */ const RAISED_PANE_STYLE = { ...SCROLL_PANE_STYLE, [GROUPED_LIST_HEADING_SURFACE_PROPERTY]: "var(--color-surface-raised)", } as CSSProperties /** * A cap rather than a width, so the long label clips in every window * — an ultrawide grid cell would otherwise hold it whole and the * truncation half of the board would show nothing. */ const TRUNCATION_CELL_STYLE = { maxInlineSize: "20rem" } const meta = { title: "Components/Data/GroupedList", component: GroupedList, parameters: { layout: "padded" }, args: { groups: SIGNAGE_GROUPS, headingLevel: 3, isTextTruncated: true, size: "md", }, } satisfies Meta export default meta type Story = StoryObj /** * A signage app's pages under the screen that shows them. Each row is * a name, a muted line, an optional badge, and one action named * after its row. */ export const Playground: Story = { parameters: playgroundParameters, } /** * `size` and `isTextTruncated` are the two axes. `sm` is a settings * pane or a sidebar; `md` is a page. Wrapping is for a list whose * rows are read rather than scanned. */ export const AllVariants: Story = { render: () => (
), } /** * Every optional part of a row, added one at a time — and the two * group states: an empty group is not drawn, and a list with no * items at all draws its `emptyState`. */ export const AllStates: Story = { render: () => ( } groups={[ { heading: "Screens", items: [], key: "screens", }, ]} size="sm" /> ), } /** * The text column asks for 12rem and grows. Below that, the actions * drop under the text instead of crushing the label — decided by the * content, with no container query and no media query. */ export const Responsive: Story = { render: () => ( ), } /** * Tab walks the actions in reading order, group by group. Nothing * else in the list takes focus: a row is not a control, and its * action carries its name ("Open Lobby"). */ export const Interactive: Story = {} /** * A long label and a long path clip to one line, and the full text * is the tooltip while — and only while — it is clipped. The short * row underneath has no tooltip at all. */ export const LongText: Story = { render: () => (
), } /** * Nothing to list. The app chooses the wording — "nothing yet" and * "nothing matches" are different sentences — and passes it as * `emptyState`. */ export const Empty: Story = { args: { emptyState: ( ), groups: [], }, } /** * The Narrow View: a phone-width window. Badges wrap under the * label, and the actions wrap under the text. */ export const NarrowView: Story = { args: { groups: CHORE_GROUPS, size: "sm" }, globals: { viewport: { value: "mobile1" } }, } /** * `isHeadingSticky` pins each heading to the top of the scrolling * pane while its rows scroll under it. On the left the pane is the * page surface, which is the default heading background. On the * right the pane is raised, and it sets * `--charcuterie-grouped-list-heading-surface` to match. */ export const StickyHeadings: Story = { render: () => (
), } /** * A row whose whole surface is its primary action. `onSelect` draws * one native button under the row's text, named by `selectLabel`; * the Delete action sits above it as its own target. The status * line under the list says which one a click reached. * * The second card is highlighted (a scan just paid it), and the * third is switched off, so it is muted. */ export const SelectableRows: Story = { render: function SelectableRowsStory() { const [lastEvent, setLastEvent] = useState( "Nothing chosen yet", ) return (
{ setLastEvent(`Deleted ${label}`) }, onSelect: (label) => { setLastEvent(`Opened ${label}`) }, })} />

{lastEvent}

) }, } /** * Every per-row state at once: hover, press and keyboard focus on * the whole-row button (forced by the pseudo-states addon, the only * way to review them in a static board), a muted row, and a * highlight in each intent. The highlight's pulse is frozen here, so * this is also its static, reduced-motion look. */ export const RowStates: Story = { parameters: { pseudo: { active: ["#active-row li:first-child > button"], focusVisible: ["#focus-row li:first-child > button"], hover: ["#hover-row li:first-child > button"], }, }, render: () => ( {( [ ["resting", "resting-row"], ["hover (forced)", "hover-row"], ["active (forced)", "active-row"], ["focus-visible (forced)", "focus-row"], ] as const ).map(([label, id]) => (
toChoreRow(card), ), key: id, }, ]} size="sm" />
))}
), } /** * Light and dark side by side, each panel scoped with its own * `data-scheme`, so both read at once whatever the toolbar says. */ export const ColorSchemes: Story = { render: () => ( {(["light", "dark"] as const).map((scheme) => (
({ ...group, items: group.items.map((item) => ({ ...item, actions: openButton( item.label, `${scheme} `, ), })), }))} size="sm" />
))}
), }