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