import type { ReactElement, ReactNode } from "react"; import type { Category } from "../api/types.js"; import type { CarouselEntry, CategoryTileEntry } from "../headless/CategoryCarousel.js"; import type { LinkComponentProp } from "./CategoryLink.js"; import type { ThemeModeProp } from "./types.js"; /** 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"; /** The class the compact row's art carries, so the sheet can reach it. */ export declare const CATEGORY_TILE_ART_CLASS = "stapel-category-tile-art"; /** * 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 declare function tileStageRows(count: number, columns: number): number; /** * 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"; /** 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 declare const CATEGORY_TILE_CLASS = "stapel-category-tile"; /** The modifier a `"flat"` tile carries — the hover/focus rules hang on it. */ export declare const CATEGORY_TILE_FLAT_CLASS = "stapel-category-tile-flat"; /** The `href` the hoisted tile sheet is deduplicated by. */ export declare 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 declare function categoryTileCss(): string; /** * Turn a category's opaque icon reference into an ADDRESS — the seam for a * host whose CDN needs a lookup rather than a prefix. * * `renderIcon` already lets a host draw whatever it likes, but it costs the * host the tile's own `` (its lazy loading, its aspect ratio, its alt * text) for the one thing it usually wants: the URL. A resolver hands back a * string and keeps the library's picture. * * It takes the CATEGORY, not the reference, because a store of opaque refs * (`product/`) is keyed by the row, and the alternative — projecting * every row into a new `entries` array just to rewrite one field — is a copy * of the catalogue per render. * * `undefined` is DECLINING this row, not an error: the next arm answers. * Whatever comes back still goes through {@link categoryIconSrc}, so a * resolver that returns an opaque string or a `data:` URI draws the monogram * rather than a broken image. */ export type CategoryIconResolver = (category: Category) => string | undefined; /** * WHETHER THE BROWSER MAY HYPHENATE A CAPTION IT CANNOT FIT. * * `"manual"` is the default and the skin's own long-standing answer (D90): a * caption breaks between words and no character appears that the catalogue * author did not write. A name too wide even for the column * {@link COMPACT_MIN_COLUMN_PX} guarantees ends in an ellipsis rather than * being split. * * `"auto"` is the OTHER reading of the same break, and a deployment whose * catalogue disagrees with D90 needs it: a walk of a 390px phone measured * "Nedvizhimost" and "Elektronika" set over two lines with the break INSIDE * the word and nothing marking it — "Nedvizhim / ost" — which a reader has to * notice is one word before they can decide whether to tap it. The arithmetic * is not a rendering accident and no line count can escape it: at the * compact tile's measured 63px caption column, a 12-letter root name is * ~83px wide, so SOME break is forced and the only question is whether it is * marked. * * A PROP and not a flip, because the two readings are both measured and they * disagree: D90 walked a catalogue whose long captions read worse hyphenated, * this one walks a catalogue whose long captions read worse unhyphenated, and * neither is the other's bug. The default is unchanged, so no existing host * moves; the deployment that measured the second reading asks for it by name. * * `hyphens: auto` needs a LANGUAGE to hyphenate by — without one the browser * has no pattern set and the declaration is inert. So the caption is stamped * with the i18n engine's own locale when this is `"auto"`, rather than the * pair trusting a host to have put `lang` on its ``. */ export type TileLabelHyphens = "manual" | "auto"; /** * THE TILE CAPTION'S NAME — the one element a reading of the label may aim * at, in every anatomy. * * A tile's caption used to be an unnamed `` inside the tile link, and * WHICH span it is depends on the anatomy: the label comes FIRST on the * regular and `size="compact"` tiles and SECOND on `density="compact"`, whose * art leads. So anything reaching for it by position — a stylesheet's * `span:first-child`, a probe's `querySelector` — aims at the label on one * surface and at the art on the next, and reports a number either way. That * is not hypothetical twice over: it is the `!important` clamp override * {@link CategoryTileGridProps.labelLines} replaced, and then a stand probe * that read `-webkit-line-clamp` off the tile LINK (where it is `none`, on a * page where the clamp is working perfectly) and counted the art box and the * monogram as two of the caption's "lines". * * Every ordinary tile's caption carries this. The two special tiles keep * their own, older names on the same span — `categories-tile-grid-all` on the * "All" tile — because a test that reads the clamp off THAT tile already * points at the right element and must keep pointing at it. */ export declare const CATEGORY_TILE_LABEL_TESTID = "categories-tile-label"; 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.overflow}'s two answers. */ export type TileOverflow = "none" | "modal"; export declare function CategoryTileGrid(props: CategoryTileGridProps): ReactElement | null; //# sourceMappingURL=CategoryTileGrid.d.ts.map