import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters" import type { Meta, StoryObj } from "@storybook/react" import { controlSizeArgType } from "../argTypes.storyHelpers.ts" import { ContainerBoard, StoryCell, StoryGrid, StorySection, } from "../board.storyHelpers.tsx" import type { PortraitTileItem } from "./PortraitTiles.tsx" import { PortraitTiles } from "./PortraitTiles.tsx" /** * The shape this was taken from: points-market's "Who's shopping?". * * Placeholder names on purpose. A component library's fixtures are * read by everyone who opens Storybook and are the one place a * household's real names would leak into a published repo — which * the fleet has a standing rule about, and which a PNG in a pull * request makes ungreppable afterwards. */ const SHOPPER_ITEMS = [ { hint: "points", initials: "A", label: "Avery", stat: "1,240", value: "avery", }, { hint: "points", initials: "B", label: "Bailey", stat: "860", value: "bailey", }, { hint: "points", initials: "C", label: "Casey", stat: "2,015", value: "casey", }, { hint: "points", initials: "D", label: "Devon", stat: "430", value: "devon", }, // `satisfies` rather than an annotation, so each fixture keeps its // OWN type. `PortraitTileItem` is a union — a palette hue or a // colour out of data, never both — and annotating the array widens // every element to that union, after which `{ ...item, // categorical: 3 }` below is an object that TypeScript reads as // possibly carrying a `color` too, and rejects. ] satisfies PortraitTileItem[] /** One fixture, with its colour arm still known. */ type ShopperItem = (typeof SHOPPER_ITEMS)[number] /** * A set whose subjects persist, so each one owns its hue rather than * borrowing the one its position happened to give it. * * This is the arrangement any real household picker wants: add a * fifth member and the first four keep their colours. */ const PINNED_ITEMS: PortraitTileItem[] = [ { ...(SHOPPER_ITEMS[0] as ShopperItem), categorical: 3, }, { ...(SHOPPER_ITEMS[1] as ShopperItem), categorical: 8, }, { ...(SHOPPER_ITEMS[2] as ShopperItem), categorical: 5, }, ] /** * Colours that did **not** come from the palette. * * The shape this component was taken from is coloured this way: each * subject's colour matches a physical card they tap, so the picker * and the object in their hand have to agree. Ten audited hues * cannot make that promise, and re-theming the colour would be * re-theming the card. * * Hex on purpose — the contrast pick that decides whether the * initials come out black or white reads six-digit hex and nothing * else. * * Invented colours, like the names above them. An app's real values * would be its data sitting in a published library, and a fixture * that is somebody's actual configuration is a fixture nobody can * change later. Two pale and one dark, so the story shows the * initials flipping rather than describing it. */ const CARD_COLOUR_ITEMS: PortraitTileItem[] = [ { color: "#8FD3F4", hint: "points", initials: "A", label: "Avery", stat: "1,240", value: "avery", }, { color: "#D9D2C5", hint: "points", initials: "B", label: "Bailey", stat: "860", value: "bailey", }, { color: "#3B4A6B", hint: "points", initials: "C", label: "Casey", stat: "2,015", value: "casey", }, ] /** No number at all — a picker that is only ever "who are you". */ const NAME_ONLY_ITEMS: PortraitTileItem[] = SHOPPER_ITEMS.map(({ initials, label, value }) => ({ initials, label, value, })) const meta = { title: "Components/Actions/PortraitTiles", component: PortraitTiles, parameters: { layout: "padded" }, argTypes: { size: controlSizeArgType }, // The component's own defaults, restated — Storybook does not seed // `args` from docgen, so an unstated default shows in the props // table with nothing selected in its control. args: { layout: "auto", minTileInlineSize: 200, size: "md", }, } satisfies Meta export default meta type Story = StoryObj export const Playground: Story = { parameters: playgroundParameters, args: { items: SHOPPER_ITEMS, label: "Who's shopping?", }, } /** * The two forms, side by side, forced rather than queried — so the * difference is visible without resizing anything. * * `auto` picks between them off the SET's width, which is why the * board below this one is the story that actually documents the * behaviour. */ export const AllVariants: Story = { args: { items: SHOPPER_ITEMS, label: "Who's shopping?", }, render: (controlProps) => ( ), } /** * Three sizes, each moving the face and the number together. The * type ramp does the rest on its own: `text-2xl` is 30px at * `comfortable` and 38px at `kiosk`, which is where points-market's * wall-mounted picker gets its size from without a single override. */ export const AllSizes: Story = { args: { items: SHOPPER_ITEMS.slice(0, 2), label: "Who's shopping?", }, render: (controlProps) => ( {(["sm", "md", "lg"] as const).map((size) => ( ))} ), } /** * `layout="auto"` reads the **container**, never the window. * * At 15rem the set is one narrow column and each portrait is a row — * a face beside a name, which is what fits. Past `cq-sm` (24rem) it * has room to stack, and every portrait becomes the tall column * points-market shows on its kiosk. * * Every panel here is the same window, which is the whole point: a * picker in a 320px sidebar on a 2560px monitor gets the row form, * and a media query would have given it the column. */ export const Responsive: Story = { args: { items: SHOPPER_ITEMS.slice(0, 3), label: "Who's shopping?", }, render: (controlProps) => ( {(width) => ( )} ), } /** * A picture instead of initials. It is `alt=""` on purpose — the * name is beside it inside the same tile, so an alt here is the name * announced twice. * * The broken source is not a mistake in this story. It is the case * the component handles: a 404 falls back to the initials rather * than leaving a torn hole where a face was, next to three that * loaded. */ export const WithPictures: Story = { args: { items: [ { ...(SHOPPER_ITEMS[0] as ShopperItem), imageSrc: "data:image/svg+xml;utf8," + encodeURIComponent( '', ), }, { ...(SHOPPER_ITEMS[1] as ShopperItem), // The same URL `Avatar`'s fallback story points at, and // that is not a coincidence: `smokeStorybook.ts` keeps a // short allowlist of resources that are MEANT to 404, so a // real missing chunk still fails the run. A new spelling of // "this file is absent" is an unlisted 404 and fails the // storybook gate — which is exactly what it did here. imageSrc: "/charcuterie-missing-portrait.png", }, SHOPPER_ITEMS[2] as PortraitTileItem, ], label: "Who's shopping?", }, } /** * A hue named per subject rather than taken from the position. * * The default is positional because it costs a call site nothing. It * is also the wrong default for a household: adding a fifth member * re-colours the first four, and their colour was the thing the * picker was being read by. */ export const NamedHues: Story = { args: { items: PINNED_ITEMS, label: "Who's shopping, with pinned colours", }, } /** * A colour out of data rather than out of the palette. * * Two pale fills and one dark one, which is the case the arm has to * survive: the initials go black where white would vanish and white * where black would, and the number is the same hue pulled toward * the scheme's own text colour so it stays readable on a pale * surface — and, in the dark scheme, pulled the other way. Compare * it with `NamedHues` above, where the library owns the colour and * can simply state both ends. * * Use this arm only when the colour is a **fact about the subject**. * A colour that is merely a design choice is a `categorical` index, * and it gets re-theming and a contrast guarantee for free. */ export const DataColours: Story = { args: { items: CARD_COLOUR_ITEMS, label: "Who's shopping, coloured by their cards", }, } export const AllStates: Story = { args: { items: SHOPPER_ITEMS, label: "Who's shopping?", }, render: (controlProps) => ( ({ ...item, href: `/${item.value}`, }), )} label="Who's shopping, as links" /> ), } /** * The keyboard path is the platform's, the same as `ActionTiles`: * Tab reaches each portrait, Enter and Space press a button one, * Enter follows a link one. Nothing stays selected, because nothing * was chosen — the page moved on. */ export const Interactive: Story = { args: { items: SHOPPER_ITEMS.slice(0, 2), label: "Who's shopping?", }, } export const BoundedSet: Story = { args: { label: "People", items: PINNED_ITEMS, minTileInlineSize: 220, isFillingRow: true, }, render: (props) => (
), } /** An incomplete row keeps the full row's tile widths and centers as a group. */ export const CenteredLastRow: Story = { args: { ...BoundedSet.args, items: [ ...PINNED_ITEMS, { value: "admin", label: "Admin", initials: "A", hint: "Manage", }, ], }, render: (props) => (
), }