/** * `` — the phone landing's category tiles: two rows that * scroll sideways, with the third column peeking in so the row reads as * scrollable without a scrollbar to say so. * * ── Why a second surface instead of a prop on the strip ──────────────────── * * `` is a WRAPPING row of labelled cards — the desktop * shape, and it stays exactly what it is. This is a different geometry with a * different reading order (a fixed two-row grid, a horizontal scroll port, a * tile whose label and art sit in opposite corners), and folding both into one * component would mean a `layout` prop nobody could photograph either arm of. * They share the one thing worth sharing: the headless `` * bag, so both rows are the same categories in the same order. * * ── The image seam is the strip's seam, unchanged ────────────────────────── * * `carousel_icon` / `catalog_icon` are OPAQUE STRINGS the backend deliberately * does not resolve, so this skin never BUILDS one: it hands the reference to * the host through `renderIcon` — the SAME contract * `` takes, so a storefront wires its CDN resolver once * and both surfaces draw art. Where the reference already IS an address (a * seeded catalogue writes the uploaded asset's URL into `catalog_icon`) the * tile draws it, and where it is neither the tile draws the category's own * initial as {@link TileMonogram} — a tile with nothing in its art corner * reads as a broken tile. Never a guessed URL, and therefore never a broken * image; the order and the reasoning are on {@link tileArt}. * * ── Two sources of rows, one geometry ────────────────────────────────────── * * By default the rows come from the carousel bag, which answers exactly one * question: which categories the operator put on the front page. A CATEGORY * landing asks a different one — what is inside THIS category — and its answer * is already in the host's hand (`useCategoryTree()`), not on the carousel * endpoint. {@link CategoryTileGridProps.entries} is that second source, and * when it is given `` is not mounted at all, so the override * costs no request it would then discard. * * ── Where tiles STOP ─────────────────────────────────────────────────────── * * Tiles are the top level and a top-level category's children, and nothing * deeper: below that a category is a CHARACTERISTIC, chosen through cascading * child selectors when filtering or posting, not a tile a person navigates * into. {@link CategoryTileGridProps.categoryDepth} is how a landing says * which depth it is at, and past the cap this component renders NOTHING at * all — not an empty state, because there is no absence to report: the * sub-categories exist, they are simply offered somewhere else, in a different * shape. The number itself lives in `catalog/tiles.ts` so the search and * composer surfaces read the same one. * * ── Geometry ─────────────────────────────────────────────────────────────── * * Every length is relative to the CONTAINER, not to the viewport: the column * is `100% / 2.5` of the scroll port minus one gap, and the tile's height * comes from its aspect ratio. A tile sized in viewport pixels is a tile that * is the wrong size inside every panel, sheet and column that is not the whole * screen — and this row is mounted inside all three. * * `layout="wrap"` keeps that rule and drops the scroll port: the columns come * from `minmax(min(minTileWidth, 100%), 1fr)`, so the tiles wrap onto as many * lines as the container needs and none of them is off screen. */ import { useMemo, useState } from "react"; import type { CSSProperties, ReactElement, ReactNode } from "react"; import { Flex, Input, List, Skeleton } from "antd"; import { cssVar, fontSize, fontWeight, radii, spacing } from "@stapel/tokens-antd"; import { STAPEL_UI_KEYS, useI18n, useT } from "@stapel/core"; import type { LinkComponent } from "@stapel/core"; import type { Category } from "../api/types.js"; import { renderCategoryLabel } from "../catalog/labels.js"; import { categoryIconSrc, categoryOffersTileGrid } from "../catalog/tiles.js"; import { CategoryCarousel } from "../headless/CategoryCarousel.js"; import type { CarouselEntry, CategoryTileEntry, VirtualTileEntry, } from "../headless/CategoryCarousel.js"; import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js"; import { CategoryLink } from "./CategoryLink.js"; import type { LinkComponentProp } from "./CategoryLink.js"; import { EmptyState, ErrorAlert, LoadList, PHONE_CONTROL_HEIGHT, SkinDialog, SkinTheme, } from "@stapel/tokens-antd/skin"; import type { ThemeModeProp } from "./types.js"; /** Rows in the scroller. Two, per the reference — one row wastes the height a * phone has, three makes the tile too small to carry a two-line label. */ const TILE_ROWS = 2; /** * How many columns fit the scroll port. The half is the whole point: a row * that ends flush on a column edge looks like the end of the list, and the * peeking third tile is what says "there is more to the right" without a * scrollbar, an arrow or a hint line. */ const VISIBLE_COLUMNS = 2.5; /** * The COMPACT geometry, in two numbers (the owner's ruling on tile size, * 2026-09-02: the cozy tiles were "huge" at 390px and a fraction-of-container * column turned a 1440px catalogue page into a wall of ~550px tiles). * * More visible columns makes the phone row dense — a 390px port shows four * tiles and the peek of a fifth. The PIXEL CAP is the desktop half of the * same fix: the column is `min(fraction, cap)`, so a wide container gets a * modest strip of 128px tiles instead of inflating the fraction into * billboards. Both numbers stay relative to the CONTAINER first — the cap * only stops the growth, it never stretches a narrow port. */ const COMPACT_VISIBLE_COLUMNS = 4.4; const COMPACT_MAX_COLUMN_PX = 128; /** * THE NARROWEST A COMPACT COLUMN MAY BE, and it is an arithmetic rather than * a taste. * * The fraction alone gave a 390px phone a 63px caption column, and the * catalogue's longest root name — a twelve-letter single word — measures * ~83px at the caption's 12px (the stand's own reading: nine letters, 62px). * 83 into 63 does not go, so the caption broke inside the word. * * So the column has a FLOOR: the longest name's measure plus the tile's * inline padding, rounded up a step for the next long name. Below it the * grid gives up a column rather than a word — three tiles and a peek on a * 390px phone instead of four and a peek. The fraction and the 128px cap are * untouched above it, so every wider container is exactly what it was. */ const COMPACT_MIN_COLUMN_PX = 96; /** The two tile geometries a host can ask for. `cozy` is the reference * two-row scroller; `compact` is the dense strip — see the constants above. */ export type TileDensity = "cozy" | "compact"; /** * The tile's own ANATOMY, orthogonal to {@link TileDensity} (which packs the * SCROLLER's columns) and to {@link TileLayout} (scroll vs wrap). * * `"regular"` (default) is the reference root tile — label top-left, art * bottom-right, unchanged. `"compact"` is the reference's SECOND-level tile * (owner's verdict, 2026-09-04): a person landing inside a category sees a * denser row about HALF the root tile's height for the same width. A root page * (the home) stays `"regular"`; every tile page below it is where `"compact"` * belongs. * * WHAT IS IN THAT ROW depends on the SURFACE, and only since the flat tile * (architect's verdict, superseding 2026-09-04 for the flat surface only) — * see {@link tileSizeCompact} and {@link tileSizeCompactFlat}. The height and * the density are the 2026-09-04 ruling either way. */ export type TileSize = "regular" | "compact"; /** * `"compact"` size's own geometry — a THIRD anatomy, not a smaller `cozy` or * a re-skinned `density: "compact"` (that one centres the art over the label * for the phone SCROLLER; this one is a horizontal row, because the two solve * different problems at different depths of the catalogue). * * The aspect ratio is derived, not guessed: {@link TILE_ASPECT_RATIO} is * `4 / 3` (height = 0.75 × width), so HALF that height at the same width is * `1.5 / 4` → `8 / 3` the other way up. */ const COMPACT_SIZE_ASPECT_RATIO = "8 / 3"; /** Denser grid than the regular 240px default — the owner's number. */ const COMPACT_SIZE_MIN_TILE_WIDTH = 220; /** Smaller type at this density — the label is one clamped line shorter too * (2, not 3), because a horizontal row has no third line to spend. */ const COMPACT_SIZE_LABEL_FONT_SIZE = 13; /** A small picture, never a corner ornament at this size — a fixed fraction * of the row, capped so a wide row does not inflate it into a second tile. */ const COMPACT_SIZE_ART_WIDTH = "32%"; const COMPACT_SIZE_ART_MAX_PX = 56; /** * WHERE THE COMPACT ROW'S PICTURE BECOMES A CORNER GLYPH, measured on the * TILE and not on the window. * * The row is `[caption][art]`, and the art took a flat 32% of it. On a narrow * scroller that is the caption's width being spent on decoration: a storefront * running this size at 2.5 columns on a 390px phone left ~90px of caption, and * its two longest single-word root names — twelve and eleven letters — ended * in an ellipsis INSIDE the word. The name is the only part of a tile that * says where the tap goes, so it is the part that may not be sacrificed. * * Below this width the picture drops to {@link COMPACT_SIZE_ART_GLYPH_PX} and * the caption takes everything it gives up. The threshold is a CONTAINER * query on the tile, so a host that mounts the same size in a wide sidebar and * a narrow phone strip gets the right answer in both without telling the pair * which is which. */ const COMPACT_SIZE_ART_TIGHT_PX = 200; const COMPACT_SIZE_ART_GLYPH_PX = 24; /** The container this tile establishes, so the query above cannot be * answered by somebody else's box. */ const COMPACT_SIZE_CONTAINER = "stapel-category-tile"; /** The class the compact row's art carries, so the sheet can reach it. */ export const CATEGORY_TILE_ART_CLASS = "stapel-category-tile-art"; /** Tighter gap between tiles at this density, and between the label and the * picture inside one row. */ const COMPACT_SIZE_GAP = spacing[1]; const DEFAULT_GAP = spacing[2]; /** Tile proportions — wider than tall, so a two-line label and the art corner * both fit without the row eating the fold. */ const TILE_ASPECT_RATIO = "4 / 3"; /** * The art corner's own aspect ratio — the same ratio {@link TileImage} draws * its `` at. Giving the WRAPPER this ratio too (not just the image) is * what reserves the corner's height on the first frame: a definite width (a * percentage of the tile, itself sized by {@link TILE_ASPECT_RATIO} from a * definite grid column) times a definite ratio is a definite height, with no * dependency on an asset that has not decoded yet. The box is the SAME shape * whether it ends up holding a picture or {@link TileMonogram} — the tile's * own height must not depend on which one a row happens to have. */ const ART_ASPECT_RATIO = "3 / 2"; /** * Lines a tile's label may take before it is clipped. * * Three, not the original two, and with hyphenation below — measured on a * live 390px classified catalogue: the tile's inner column is ~105px there, * one root name is a single 12-letter word wider than that (the clamp * ellipsized it on its FIRST line), and the longest root name is three lines * of text however it breaks. Real root names in a hyphenating language need * the third line more than the art corner needs the headroom. */ const LABEL_LINES = 3; /** * HOW MANY ROWS `count` TILES TAKE IN `columns` COLUMNS. * * One line of arithmetic, exported, because three places need the same answer * and a fourth (a host holding the stage's box from outside the pair) had no * way to ask for it and was guessing a height instead — which is the defect * this file's budget note keeps recording under other names. * * Six tiles in five columns is TWO rows; five in five is ONE. A stage that * reserves a fixed number of rows leaves the difference as an empty band * under the last tile, before whatever block comes next. * * Zero tiles is zero rows and not one: there is no content, so there is no * box, and a row reserved for nothing is the hole an empty state exists to * replace. */ export function tileStageRows(count: number, columns: number): number { if (count <= 0) return 0; return Math.ceil(count / Math.max(1, columns)); } /** * How many tiles the loading arm reserves room for when the caller does not * say — the number this arm has always drawn. * * A caller that KNOWS how many are coming says so * ({@link CategoryTileGridProps.reserveCount}) and the reservation is then the * box the tiles land in rather than a fixed four: the skeletons sit in the * SAME grid, so the browser lays out `tileStageRows(count, columns)` rows for * the reserve exactly as it will for the answer, with no second copy of the * geometry to drift from the first. */ const RESERVED_TILES_DEFAULT = 4; /** * THE BOX THE TILES ARRIVE INTO — the row's own geometry, filled with * skeletons, and the one place it is written. * * Shared between the carousel arm's `loading` slot and * {@link CategoryTileGridProps.reserve}, because a reserved box that is not * the box the tiles land in is a smaller shift rather than no shift: the * skeletons carry {@link TILE_ASPECT_RATIO} inside the same * {@link listStyle} grid, so the reservation is the same height whichever * side of the seam asked for it. */ function ReservedTiles(props: { readonly layout: TileLayout; readonly density: TileDensity; readonly minTileWidth: number; readonly size: TileSize; /** How many tiles are coming. One skeleton each, in the grid they will land * in, so the reserved height is the rows they will take and no more. */ readonly count: number; /** The scroller's column count, so the box held before the answer is the * box the answer lands in. */ readonly visibleColumns?: number; }): ReactElement { const slots = Array.from({ length: Math.max(0, props.count) }, (_, i) => i); return (
{slots.map((slot) => ( ))}
); } /** The scroller's own geometry, `gap` threaded through so `size: "compact"` * (see {@link COMPACT_SIZE_GAP}) can tighten it without a second copy of the * grid rules. */ function scrollerStyle( density: TileDensity, gap: number, count: number, visibleColumns: number = VISIBLE_COLUMNS ): CSSProperties { const columns = density === "compact" ? // A floor, the fraction, a cap — see `COMPACT_MIN_COLUMN_PX`. `clamp` // rather than a nested `min`/`max` pair so the three numbers read in // the order they act in. `clamp(${COMPACT_MIN_COLUMN_PX}px, calc(100% / ${COMPACT_VISIBLE_COLUMNS} - ${gap}px), ${COMPACT_MAX_COLUMN_PX}px)` : // `100%` is the SCROLL PORT's content box, so the tile is a fraction // of the box it was mounted in — see this file's header. `calc(100% / ${String(visibleColumns)} - ${gap}px)`; // Two rows is the shape, not a floor. `grid-auto-flow: column` fills a // declared row whether or not a tile lands in it, so a stage with one tile // used to declare a second, empty row and pay its gap — an empty band under // the only thing on screen. The rows are what the tiles need, capped at the // reference's two. const rows = Math.min(TILE_ROWS, Math.max(1, count)); return { display: "grid", gridAutoFlow: "column", gridTemplateRows: `repeat(${String(rows)}, auto)`, gridAutoColumns: columns, gap, overflowX: "auto", overscrollBehaviorX: "contain", scrollSnapType: "x proximity", // The scroll port is the affordance; a scrollbar over a 2-row tile grid // on a phone is chrome that covers the art. scrollbarWidth: "none", }; } /** * The two ways the tiles fill their container. * * `"scroll"` (default) is the reference phone row: two rows deep, sideways, * with the peeking column that says there is more. `"wrap"` is the same tiles * with no scroll port at all — as many per line as the width allows, wrapping * onto as many lines as it takes, so EVERY category is on screen at once. * * A landing that wants the whole catalogue visible (a desktop home page, a * category page's subcategories) cannot get there from the scroller: a wrapped * grid is a different geometry, not a wider one, and a host that needed it had * to draw its own tiles — which is the same tile anatomy re-implemented, and * the copy that drifts. */ export type TileLayout = "scroll" | "wrap"; /** * The narrowest a wrapped tile may be before the grid drops a column * (`repeat(auto-fill, minmax(...))`). 240px is the reference desktop tile. */ const DEFAULT_MIN_TILE_WIDTH = 240; /** * The wrapping arm. * * `min(, 100%)` rather than the bare width: `minmax` with a fixed * minimum is the classic auto-fill overflow — inside a container narrower * than one tile the track keeps its minimum and the page scrolls sideways, * which is precisely the thing this layout exists not to do. */ function wrapStyle(minTileWidth: number, gap: number): CSSProperties { return { display: "grid", gridTemplateColumns: `repeat(auto-fill, minmax(min(${String(minTileWidth)}px, 100%), 1fr))`, gap, }; } /** The container the tiles sit in — one of the two geometries. */ function listStyle( layout: TileLayout, density: TileDensity, minTileWidth: number, size: TileSize, count: number, visibleColumns?: number ): CSSProperties { const gap = size === "compact" ? COMPACT_SIZE_GAP : DEFAULT_GAP; // `wrap` needs no row count of its own: the tiles are the grid's items, so // the browser lays out `tileStageRows(count, columns)` rows and not one // more — which is the whole reason the reserve draws one skeleton per tile // rather than a fixed four. return layout === "wrap" ? wrapStyle(minTileWidth, gap) : scrollerStyle(density, gap, count, visibleColumns); } const tileBase: CSSProperties = { display: "flex", flexDirection: "column", justifyContent: "space-between", aspectRatio: TILE_ASPECT_RATIO, padding: spacing[3], borderRadius: radii.lg, /* THE CARD TILE IS TINTED (owner, 2026-09-13). * * It drew `surface-sunken` — the neutral step BELOW the page — and on the * walked storefront a grid of them read as a packed list with dividers * rather than as the reference's illustrated cards: at #f4f5f7 against a * #ffffff page the box is a hairline, so what a reader sees is the gaps * between the tiles and not the tiles themselves. * * `brand-subtle` is this vocabulary's own TINT role (brand.100 light / * brand.900 dark). A colour rather than a shade of grey is the whole * difference between "a row in a list" and "a card with a picture on it", * and it is a declared ROLE rather than a value chosen here — a deployment * that regenerates its palette moves this tile with it, which a hex would * not. The text role above it is unchanged and stays legible on both sides: * `text` is gray.900 on brand.100 and gray.100 on brand.900. * * The FLAT tile's hover fill stays `surface-sunken` deliberately — see * `categoryTileCss`. */ background: cssVar("brand-subtle"), // A tile is a link, and a link that inherits the anchor colour reads as a // sentence rather than as a surface. color: cssVar("text"), scrollSnapAlign: "start", overflow: "hidden", }; /** * The compact tile is a different ANATOMY, not just a smaller cozy tile: at * ~70px the reference label-top-left/art-bottom-right corners collide, and a * hyphenating root name clipped to its first syllable ("Nedvi-") reads as a * broken screen. The dense-grid convention every classified app uses at this * size is the icon centred on top with the label centred under it — the * label gets the tile's full width, which is what lets a long root name * hyphenate into two honest lines instead of clipping into one. */ const tileCompact: CSSProperties = { ...tileBase, aspectRatio: "1 / 1", // The INLINE padding is the caption's column, one step tighter than the // block padding: on a ~96px tile those 8px are a letter and a half of the // longest root name, and the art is centred so it loses nothing by them. paddingBlock: spacing[2], paddingInline: spacing[1], justifyContent: "center", alignItems: "center", gap: spacing[1], }; /** * `size: "compact"`'s CARD tile — a HORIZONTAL row (name left, small picture * right), not the `density: "compact"` icon-over-label square above. `size` * takes precedence: the two never both apply to one tile. * * The owner's 2026-09-04 anatomy, unchanged and still the answer whenever * there is a fill to hold it — see {@link tileSizeCompactFlat} for what the * same row does without one. */ const tileSizeCompact: CSSProperties = { display: "flex", flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: spacing[2], // The box the art's own query is measured against — see // `COMPACT_SIZE_ART_TIGHT_PX`. containerType: "inline-size", containerName: COMPACT_SIZE_CONTAINER, aspectRatio: COMPACT_SIZE_ASPECT_RATIO, padding: spacing[2], borderRadius: radii.lg, // The same tint the regular card tile takes, and for the same reason: the // filled box is what makes two marks at opposite ends of a row read as one // thing, and a box nobody can see does not. background: cssVar("brand-subtle"), color: cssVar("text"), scrollSnapAlign: "start", overflow: "hidden", }; /** * `size: "compact"` WITHOUT a fill — a LIST ROW (architect's verdict, which * supersedes the 2026-09-04 ruling for the flat surface only). * * `justify-content: space-between` puts the name against one end of the row * and the picture against the other, and that is a CARD's anatomy: the filled * box is what makes two marks at opposite ends read as one thing. Take the * fill away — the owner's ruling for every tile — and the same row is two * pieces of scattered text with a gap in the middle, which is what the stand * measured. * * So the flat compact tile is what a list row has always been: the art first, * the caption second, adjacent, both against the leading edge, with the * design system's own gap between them. It is the same fix the regular flat * tile got ({@link tileFlat}) applied to a row instead of a stack — proximity * replacing the box in the axis the anatomy actually runs along. * * Everything else is the 2026-09-04 ruling: the same half-tile height, the * same density, the same padding. Only the arrangement inside the row moves. */ const tileSizeCompactFlat: CSSProperties = { ...tileSizeCompact, justifyContent: "flex-start", }; /* ── THE TILE'S SURFACE ──────────────────────────────────────────────────── * * A tile is an icon and a name. The filled box behind it was never carrying * information: on the walked storefront the home grid read as a wall of dark * rectangles, each one competing with the picture inside it, and the fills * added up to more visual weight than the whole catalogue they stand for. * * `"flat"` takes the fill and the border away and leaves the tile's contents — * and gives the SURFACE back on hover and on `:focus-visible`, where a fill * means something: this is the one you are about to open. The radius is the * same one the filled tile always had, so the hovered shape is the tile the * grid was already laid out for, and nothing about the focus ring is touched: * a keyboard walk still shows the browser's own outline over the fill. * * `"card"` is the filled tile, unchanged, for a surface that was designed * around it. */ /** Whether a tile draws a surface of its own — see * {@link CategoryTileGridProps.tileSurface}. */ export type TileSurface = "flat" | "card"; /** The class every tile carries, whichever surface it draws. */ export const CATEGORY_TILE_CLASS = "stapel-category-tile"; /** The modifier a `"flat"` tile carries — the hover/focus rules hang on it. */ export const CATEGORY_TILE_FLAT_CLASS = "stapel-category-tile-flat"; /** The `href` the hoisted tile sheet is deduplicated by. */ export const CATEGORY_TILE_STYLE_HREF = "stapel-category-tile"; /** * The flat tile's rule set. * * `background-color`, never the `background` shorthand: the shorthand would * also reset an image a host put behind a tile of its own, and a rule that * quietly erases somebody else's declaration is not a surface choice. * * A sheet rather than inline styles because `:hover` and `:focus-visible` * cannot be said in a style attribute, and the colours are `--stapel-*` * custom properties so both themes resolve at paint time. * * `surface-sunken` — this design system's neutral tertiary fill — is the hover * colour, and it deliberately did NOT follow the card tile to `brand-subtle` * when that tile was tinted (2026-09-13). A hover is a POINTER's answer: "this * is the one you are about to open", drawn under the reader's own cursor and * gone the moment it leaves. A brand tint there would put the page's accent * colour on whichever tile a mouse happens to be resting over, which is a * stronger statement than the state deserves — and on a grid of twelve it * would read as a selection rather than as a hover. The card tile's fill is * about the TILE; this one is about the pointer. */ export function categoryTileCss(): string { const flat = `.${CATEGORY_TILE_FLAT_CLASS}`; const art = `.${CATEGORY_TILE_ART_CLASS}`; return [ // A ` ); } /** * THE "ALL" TILE'S OWN ART — a pictogram, never the monogram. * * The "All" tile stands FIRST in the grid, beside ten siblings that every * seeded catalogue draws with a picture. Handed {@link TileMonogram} it drew * its label's initial instead — a lone faint capital where ten pictures are, * which a walk of the phone home screen read as an image that failed to load * (measured: `categories-tile-grid-all` carried zero ``/`` while * the grid carried ten). * * The monogram's own argument is why it cannot be the answer here. It is a * fallback for a row whose art the CATALOGUE has not supplied, and its value * is that "every tile then differs from every other tile". This tile is not a * catalogue row: it is the grid's own control, it has no `catalog_icon` to * wait for, and its meaning — "all of them" — is the one meaning a pictogram * can carry exactly. So it gets a drawn glyph and not a letter. * * Inline, and not an icon dependency: this package ships no icon set and one * four-square glyph is not the reason to take one. `aria-hidden`, like every * other tile's art — the accessible name is the tile's own label. */ function AllCategoriesGlyph(): ReactElement { return ( ); } /** The overflow tile's own art: `+N`, faint like {@link TileMonogram} — the * accessible name comes from the button's own label, this is decorative. */ function MoreGlyph(props: { readonly count: number }): ReactElement { return ( ); } export interface CategoryTileGridProps extends ThemeModeProp, LinkComponentProp { /** Path prefix for a tile's link, and the "All" tile's own href. Default * `/c` — the same convention `` and the spec's `/c/:slug` * already use. */ readonly basePath?: string; /** * Turn an opaque icon reference into something renderable — the same * contract `` takes. * * Returning `null` DECLINES the row and the remaining arms answer it: the * row's own address if it carries one, otherwise {@link TileMonogram}. So a * host that only has glyphs for its five roots hands back `null` for * everything else and the rest of the catalogue keeps its art. */ readonly renderIcon?: (reference: string, entry: CarouselEntry) => ReactNode; /** * Address a row's opaque icon reference without projecting the rows — see * {@link CategoryIconResolver}. Consulted after `renderIcon` and before the * row's own `catalog_icon`; `undefined` declines. */ readonly resolveIconSrc?: CategoryIconResolver; /** * Lead with a tile linking `basePath` itself (default `true`). Off for a row * that is already inside a category, where "All" would point at the * catalogue root the visitor just came from. */ readonly allTile?: boolean; /** * Tiles the HOST supplies, instead of the carousel bag. * * The bag answers ONE question — "which categories has the operator put on * the storefront's front page" (`carousel_enabled`, `GET /categories * /carousel/`) — and that is the only question a landing page asks. A * CATEGORY page asks a different one: "what is inside this category", whose * answer is `useCategoryTree()`'s children, already in the host's hand and * not on the carousel endpoint at all. Without this prop the second surface * either re-implements the tile geometry or renders the wrong rows, and the * tiles on `/c/transport` would be the same five the home page shows. * * Given, the component asks the server NOTHING: `` is not * mounted, so an override costs no `GET /categories/carousel/` (the request * a "swap the bag's data" implementation would still fire and discard). An * empty array is a real answer — a category with no children — and draws the * same empty state a featureless carousel draws. * * There is no loading or failed arm for an override, and that is deliberate: * the host owns the fetch it drew these rows from, so it owns the two * sentences that go with it. Handing this component a `LoadState` would give * one load two owners — {@link CategoryTileGridProps.reserve} is how the * host lends this one back the ONE thing it cannot own, which is the row's * height before the rows exist. * * Since stapel-categories 0.22.0 an entry is not always a row: a POINTER * arrives as an ordinary {@link CarouselEntry} (its every key is the * target's, so it draws and navigates like any other tile), and a VALUE of * an expanded branch arrives as a {@link VirtualTileEntry} — no id, no * slug, and the `href` its own host built out of the `filter` pair. Build * both with `categoryChildTileEntries`. */ readonly entries?: readonly CategoryTileEntry[]; /** * HOLD THE ROW'S BOX WHILE THE HOST'S OWN FETCH IS IN FLIGHT. * * The `entries` override skips this component's loading arm on purpose (the * host owns that read), and the consequence was a hole in the layout: a * category page that has not resolved its children yet renders no tile row * at all, and the row then APPEARS a beat later and pushes everything under * it down the page. The pair already draws exactly the right box — the same * grid, the same aspect ratio, the same skeletons — in its own loading arm; * without this prop the only way to get it was for the host to re-implement * it, and the measured storefront instead reserved a hand-guessed height in * its own stylesheet. * * - `false` (default) — unchanged: no rows, no box. * - `true` — reserve while `entries` is `undefined`, i.e. for exactly as * long as the host has handed nothing over. An empty array is a real * answer ("this category has no children") and ends the reservation. * - `"pending"` — the host STATES that its read is in flight, and the box * is held whatever `entries` currently says. For a host that keeps the * previous rows in hand across a refetch and would rather hold the box * than show a stale row. * * The reservation never asks the server anything: like the override arm, * `` stays unmounted, so a host-driven row costs no * `GET /categories/carousel/` in either state. Past the depth cap it is * nothing at all — there are no tiles to reserve room for. */ readonly reserve?: boolean | "pending"; /** * HOW MANY TILES the reservation holds room for. Default 4 — the number * this arm has always drawn. * * The reserved box is not a height: it is one skeleton per tile in the grid * the tiles themselves land in, so the browser lays out * `tileStageRows(count, columns)` rows for the reservation exactly as it * will for the answer. A caller that already knows the count — a host whose * own read returns the children, a page that has the tree in hand — states * it here, and the band under the last row goes away in both directions: no * hole where the answer is shorter, no shift where it is taller. * * See {@link tileStageRows} for the rule itself, which is exported so a * host holding the stage's box from OUTSIDE this pair computes it from the * same arithmetic instead of guessing a pixel height. */ readonly reserveCount?: number; /** * The 0-indexed depth of the category whose children these tiles are — a * top-level category is `0`, its child is `1`. Omitted means the catalogue * ROOT (the home screen), which is above every category and always offers * tiles. * * Past {@link MAX_TILE_DEPTH} this component renders nothing: the deeper * levels of the tree are characteristics chosen through cascading selectors, * not tiles (see this file's header). The rows are NOT lost — the host still * has them, and a list, a breadcrumb or a selector may render them. */ readonly categoryDepth?: number; /** * Tile geometry (default `"cozy"`, the reference two-row scroller). * `"compact"` is the dense strip: 4+ visible columns and an absolute cap on * the column width, so the same mount is small on a phone and a modest * strip — never a wall — inside a wide desktop column. */ readonly density?: TileDensity; /** * How the tiles fill the container — see {@link TileLayout}. Default * `"scroll"`, the reference two-row scroller, so no existing host changes * shape. */ readonly layout?: TileLayout; /** * The narrowest a tile may be in `layout="wrap"` before the grid drops a * column. Default 240px. Ignored by `"scroll"`, whose column width comes * from {@link TileDensity} and the container. */ readonly minTileWidth?: number; /** * HOW MANY COLUMNS OF THE SCROLLER ARE IN VIEW — the fraction, so the half * column that peeks is what says "there is more to the right". * * Default 2.5, the reference phone row, and `layout="wrap"` ignores it (its * columns come from {@link CategoryTileGridProps.minTileWidth}). It is also * ignored by `density="compact"`, whose column is a floor/fraction/cap of * its own. * * A PROP because the right number depends on the longest NAME in the * catalogue, which is a fact about the deployment and not about the pair. A * storefront running `size="compact"` at the default 2.5 on a 390px phone * left ~90px of caption, and its two longest single-word roots ended in an * ellipsis inside the word; at 2 they print whole. A catalogue of short * names has no reason to give up the column. */ readonly visibleColumns?: number; /** * How many of the leading tiles (by `entries` order, the "All" tile not * counted — it never carries an image) load their picture EAGERLY, with * `fetchPriority="high"`, instead of `loading="lazy"`. Default 8. * * A grid whose whole first row sits above the fold and still marks every * image `lazy` gives the browser no reason to fetch them before first * paint, so the row's real height (and whatever sits below it) arrives a * beat late — a walker measured a home page shoving its feed 224px down * this way. Past `eagerCount` a tile stays `lazy`: a mega-menu or a long * `layout="wrap"` landing still must not fetch dozens of images nobody has * scrolled to. */ readonly eagerCount?: number; /** * The tile's own anatomy — see {@link TileSize}. Default `"regular"`, the * reference root tile, so no existing host changes shape. A landing below * the home page is where `"compact"` belongs. */ readonly size?: TileSize; /** * Whether a tile draws a SURFACE of its own. Default `"flat"`. * * - `"flat"` — no fill and no border at rest: the icon and the caption are * the tile. The surface arrives on hover and on `:focus-visible`, in the * same radius and the same token fill the card tile wears, so pointing at * a tile is what colours it in. The focus ring is untouched; * - `"card"` — the filled tile this grid has always drawn. * * The default is the NEW behaviour, deliberately: `surface-sunken` behind * every tile was inherited from the first carousel strip, and a grid of a * dozen of them is a wall of boxes with the catalogue inside — the fills * carry no information and outweigh the art they contain. A surface that * was designed around the filled tile says `tileSurface="card"` and keeps * it exactly. */ readonly tileSurface?: TileSurface; /** * HOW MANY LINES A TILE'S LABEL MAY TAKE before it is clipped — the one * number of the label a deployment owns. * * Each anatomy has its own default and they are the skin's measured * answers: {@link LABEL_LINES} (3) on the regular tile, 2 on both compact * ones, where a ~80px square and a half-height horizontal row have no third * line to spend. Those defaults are right for the catalogue they were * measured on and wrong for a catalogue whose longest root NAME is longer: * the clamp is the only part of the label that depends on the words rather * than on the tile. * * Without this prop a host could only reach the clamp with an `!important` * rule against the pair's inline style — a rule that cannot be read back, * that silently aims at the wrong `` when the anatomy reorders label * and art (the compact density draws its art FIRST), and that a version * bump moving the default cannot tell it has become a no-op. This prop is * that number, and nothing else about the label moves with it. * * Default: the anatomy's own, so no existing host changes shape. */ readonly labelLines?: number; /** * WHETHER A CAPTION TOO WIDE FOR ITS COLUMN MAY BE HYPHENATED. Default * `"manual"` — the skin's own answer, unchanged for every host that does * not ask. See {@link TileLabelHyphens} for both readings and why this is a * deployment's choice rather than the skin's. */ readonly labelHyphens?: TileLabelHyphens; /** * Cap the grid at this many rows before offering the rest through * {@link CategoryTileGridProps.overflow}. Ignored unless `overflow` is * `"modal"` — see there. The "All" tile is not counted against the cap. */ readonly maxVisible?: number; /** * What happens past {@link CategoryTileGridProps.maxVisible}. Default * `"none"` — no cap is enforced and every row draws, so `maxVisible` alone * changes nothing. * * `"modal"` draws exactly `maxVisible` rows plus one "All categories" tile * that opens a dialog (`SkinDialog`) listing EVERY child — compact rows * with pictures, a search box once there are more than * {@link ALL_CATEGORIES_SEARCH_THRESHOLD}. The reference's own second-level * page (owner's verdict, 2026-09-04): a compact grid capped at 10, plus the * button. */ readonly overflow?: TileOverflow; } /** {@link CategoryTileGridProps.eagerCount}'s default — the reference * desktop grid's first row. */ const DEFAULT_EAGER_COUNT = 8; /** {@link CategoryTileGridProps.overflow}'s two answers. */ export type TileOverflow = "none" | "modal"; /** Past this many children, the overflow dialog grows a search box — a list * short enough to scan needs no filter, and a search box over three rows is * chrome nobody asked for. */ const ALL_CATEGORIES_SEARCH_THRESHOLD = 20; /** * "All categories" — every child of this rung, searchable once there are * enough of them, in a `SkinDialog` (a bottom sheet on a phone, a centred * modal on tablet/desktop — the primitive's own responsive rule, so this * component makes no surface choice of its own). Compact rows, each a real * link: a keyboard tabs through them and Enter/click navigates like any other * tile, and Esc / the mask / the dialog's own dismiss control close it — all * `SkinDialog`'s doing. */ function AllCategoriesDialog(props: { readonly open: boolean; readonly onClose: () => void; readonly entries: readonly CategoryTileEntry[]; readonly linkComponent?: LinkComponent; readonly renderIcon?: (reference: string, entry: CarouselEntry) => ReactNode; readonly resolveIconSrc?: CategoryIconResolver; }): ReactElement { const t = useT(); const [query, setQuery] = useState(""); const linkProps = props.linkComponent !== undefined ? { linkComponent: props.linkComponent } : {}; const rows = useMemo( () => props.entries.map((entry) => ({ entry, label: renderCategoryLabel(entry.label, t), })), [props.entries, t] ); const searchable = props.entries.length > ALL_CATEGORIES_SEARCH_THRESHOLD; const filtered = query === "" ? rows : rows.filter(({ label }) => label.toLocaleLowerCase().includes(query.toLocaleLowerCase()) ); return ( {searchable ? ( { setQuery(event.target.value); }} /> ) : null} {filtered.length === 0 ? ( ) : ( { const value = isVirtualTile(entry); const key = value ? `value-${entry.virtual.value}` : String(entry.category.id); return ( {label} {value ? ( ) : ( tileArt( entry.icon, label, entry, false, props.renderIcon, props.resolveIconSrc ) )} ); }} /> )} ); } /** The scroll port itself: the "All" tile, then one tile per row, then — * past `maxVisible` under `overflow: "modal"` — the "All categories" tile and * its dialog. */ function TileRow(props: { readonly entries: readonly CategoryTileEntry[]; readonly basePath: string; readonly allTile?: boolean; readonly linkComponent?: LinkComponent; readonly renderIcon?: (reference: string, entry: CarouselEntry) => ReactNode; readonly resolveIconSrc?: CategoryIconResolver; readonly density: TileDensity; readonly size: TileSize; readonly surface: TileSurface; readonly labelLines: number | undefined; readonly labelHyphens: TileLabelHyphens; readonly layout: TileLayout; readonly minTileWidth: number; readonly eagerCount: number; readonly maxVisible?: number; readonly overflow: TileOverflow; /** See {@link CategoryTileGridProps.visibleColumns}. */ readonly visibleColumns?: number; }): ReactElement { const t = useT(); /* The locale the captions are hyphenated BY — see `TileLabelHyphens`. Read from the engine rather than taken as a prop: the pair already resolves every one of these labels through that same engine, so a second answer to "what language is this" could only ever disagree with the first. */ const { locale } = useI18n(); const labelLang = props.labelHyphens === "auto" ? locale : undefined; const [dialogOpen, setDialogOpen] = useState(false); const linkProps = props.linkComponent !== undefined ? { linkComponent: props.linkComponent } : {}; const capped = props.overflow === "modal" && props.maxVisible !== undefined && props.entries.length > props.maxVisible; const visible = capped ? props.entries.slice(0, props.maxVisible) : props.entries; const hiddenCount = capped ? props.entries.length - visible.length : 0; return ( <>
{props.allTile !== false && (() => { const allLabel = t(CATEGORIES_I18N_KEYS.tilesAll); return ( } testId="categories-tile-grid-all" /> ); })()} {visible.map((entry, index) => { const label = renderCategoryLabel(entry.label, t); const value = isVirtualTile(entry); return ( ) : ( tileArt( entry.icon, label, entry, index < props.eagerCount, props.renderIcon, props.resolveIconSrc ) ) } /> ); })} {capped ? ( { setDialogOpen(true); }} /> ) : null}
{capped ? ( { setDialogOpen(false); }} entries={props.entries} {...linkProps} {...(props.renderIcon !== undefined ? { renderIcon: props.renderIcon } : {})} {...(props.resolveIconSrc !== undefined ? { resolveIconSrc: props.resolveIconSrc } : {})} /> ) : null} ); } export function CategoryTileGrid( props: CategoryTileGridProps ): ReactElement | null { const t = useT(); const basePath = props.basePath ?? "/c"; // Before any load: past the cap there are no tiles to ask for, so the // carousel bag is never mounted and the request it would make is never // fired. A component that fetched and then hid the answer would pay for // rows nobody may see. const offersTiles = categoryOffersTileGrid(props.categoryDepth); const density: TileDensity = props.density ?? "cozy"; const size: TileSize = props.size ?? "regular"; const surface: TileSurface = props.tileSurface ?? "flat"; // How many tiles the reservation holds room for. A caller that knows the // number says it and the box is the rows those tiles will take — see // `tileStageRows`; one that does not gets the arm's own long-standing four. const reserveCount = props.reserveCount ?? RESERVED_TILES_DEFAULT; const overflow: TileOverflow = props.overflow ?? "none"; const layout: TileLayout = props.layout ?? "scroll"; const minTileWidth = props.minTileWidth ?? (size === "compact" ? COMPACT_SIZE_MIN_TILE_WIDTH : DEFAULT_MIN_TILE_WIDTH); const eagerCount = props.eagerCount ?? DEFAULT_EAGER_COUNT; const rowProps = { basePath, density, size, surface, labelLines: props.labelLines, labelHyphens: props.labelHyphens ?? "manual", layout, minTileWidth, eagerCount, overflow, ...(props.visibleColumns !== undefined ? { visibleColumns: props.visibleColumns } : {}), ...(props.maxVisible !== undefined ? { maxVisible: props.maxVisible } : {}), ...(props.allTile !== undefined ? { allTile: props.allTile } : {}), ...(props.linkComponent !== undefined ? { linkComponent: props.linkComponent } : {}), ...(props.renderIcon !== undefined ? { renderIcon: props.renderIcon } : {}), ...(props.resolveIconSrc !== undefined ? { resolveIconSrc: props.resolveIconSrc } : {}), }; const override = props.entries; // The host's read is in flight — see `reserve`. `"pending"` is the host // saying so; `true` infers it from the one fact this component has, which // is that no rows have been handed over yet. const reserving = props.reserve === "pending" || (props.reserve === true && override === undefined); if (!offersTiles) return null; return ( /* THE CONTAINER TAKES THE TILES' OWN SURFACE. * * `SkinTheme` defaults to `surface="raised"`, which paints * `colorBgContainer` over the whole component — and that is a PANEL behind * the tile row: a lighter strip the width of the grid on the desktop * catalogue page, a full panel on the phone and behind the landing's * compact strip (owner's read of 0.29.0, dark). It is the same block fill * the tiles themselves just lost, one box further out, and it survived the * flat tile because it is painted by the wrapper rather than by anything * this file styles: the list geometry, the reserve box and the scroller * all sit INSIDE it and paint nothing of their own, so one gate here is * every one of the three. * * Flat tiles sit on the PAGE. `surface="bare"` paints nothing at all — * which also drops the text colour `raised` would have written, so the * flat arm states it from the token instead: `--stapel-text` resolves per * theme at paint time, where `SkinTheme` would have frozen the value of * whichever side was mounted first. The tiles' own hover fill * (`surface-sunken`) is then the only fill in the box, which is what makes * it read as a hover at all — over a `colorBgContainer` panel the two are * a step apart and the highlight is nearly invisible in the dark theme. * * `"card"` keeps the panel: a grid of filled tiles was designed on it. */ ); }