/** * `` — the `/c/:slug` screen: breadcrumbs, sub-categories, and a * SLOT where the listings go. * * The slot is the whole design. Spec §5.1 makes this route "categories + * search": the sub-category menu comes from here, the results with facets come * from `@stapel/search-react`. A pair may not import another L2 pair * (dependency direction is strictly downward), and a screen that rendered only * half of itself would be useless — so the half this pair does not own is a * render prop, handed the resolved category. The container writes one line: * * ```tsx * ( * * )} * /> * ``` * * And when the container does NOT write it, the page says so instead of * rendering silence: an unfilled slot is `` — a named dashed * region in a development build, nothing in production. A page that renders * breadcrumbs, sub-categories and then a wordless gap where every listing * belongs looks finished, which is exactly why nobody reports it. * * ── Two ways in, and only one of them is cheap ───────────────────────────── * * `categoryId` is the fast address: `GET {id}/` and `GET {id}/children/`, two * small answers, no catalogue. `slug` is the SEO address and it cannot be * resolved without one — `stapel-categories` never overrides `lookup_field` * and its list takes no `slug` filter, so a bare slug still costs the whole * table (measured on a live classified deployment: 36 requests, 1.4 MB, 23.4 s * cold, against 0.6 s for the id). The gap is recorded in MODULE.md as an * upstream ask; until it closes, a host that KNOWS the id — every in-app * navigation does, because it drew the link — should say so, and this page * takes both so that it can. * * When both are given the id wins and the slug is decoration — EVERYWHERE on * the page, and the qualifier is there because it was earned. The `"pane"` * arm used to fall back to `` whenever a slug was on * the props, resolving it through the catalogue sync for a level the id path * had ALREADY loaded to gate the page: on a live classified deployment that * was 13.2 seconds of skeletons under "Subcategories" on a cold desktop * landing, while the same host's list page drew the widget in 0.4 s from * per-level reads. The arm now renders the rows in hand and asks one small * cached children read per row for the counts; the catalogue is paid for only * by a host that truly has nothing but a slug. * * ── One form of sub-categories, chosen here ──────────────────────────────── * * The sub-category menu used to be a titled LIST and nothing else, with no way * to ask for anything different. A host that wanted the reference design's * TILES therefore mounted `` alongside this page and hid the * list with its own stylesheet — which a live classified deployment did: on a * 390px viewport both `categories-tree-list` and `categories-tile-grid-list` * were in the DOM, the same links rendered twice, one of them invisible. A * host hiding a pair's output with CSS is the pair's bug: the page rendered * something the host could not decline. * * {@link CategoryPageProps.subcategories} is that decision, taken once and * rendered once. The arm that is not chosen is not MOUNTED — it does not * render hidden, and its testid is absent from the document, which is what * makes "one list" checkable rather than a claim about stylesheets. * * ── WHERE THE TILES STOP, AND WHAT STARTS THERE ──────────────────────────── * * The `"tiles"` arm used to render NOTHING past the catalogue's depth cap, on * the correct rule that a category below level 2 is chosen as a characteristic * rather than navigated into — and on the incorrect assumption that something * else was offering that choice. Nothing was. The measured consequence on a * live classified catalogue was total: `/c/transport` drew 5 tiles, * `/c/transport-avtomobili` drew **0**, and 2924 of 2924 active leaves — every * category that has any characteristics at all — could not be reached from a * phone by browsing. The same URL at 1440px showed a "Subcategories" list and * got there in three taps, so half the product could descend and half could * not. * * Past the cap the arm now hands over to ``, which is * the control the owner's model names: cascading child selectors, the same * gesture as `Make → Model`, the same component the composer and the filter * rail mount. Tiles stop; the ladder starts; nothing is unreachable. The * handover is one `GET {id}/children/` per rung, so it costs a fraction of a * second and never the catalogue. * * {@link CategoryPageProps.breadcrumbs} is the CSS defect one row up, and it * was hidden by the same stylesheet: a phone landing in the reference design * is back-arrow, search field and tiles, so the trail belongs to the back * arrow and a crumb row above the title is desktop furniture. Same rule, same * reason — the host STATES it and the bar is never mounted, rather than being * rendered and then covered. */ import { spacing } from "@stapel/tokens"; import { useMemo } from "react"; import type { ReactElement, ReactNode } from "react"; import { Flex, Typography } from "antd"; import { SlotPlaceholder, loadStateFromQuery, loadedRowsOrEmpty, mapLoad, useKeptLoad, useT, } from "@stapel/core"; import type { LinkComponent, LoadState } from "@stapel/core"; import { EmptyState, ErrorAlert, LoadBoundary, SkinTheme, } from "@stapel/tokens-antd/skin"; import type { Category, CategoryChild } from "../api/types.js"; import { BLOCK_RHYTHM_CLASS, BLOCK_RHYTHM_STYLE_HREF, blockRhythmCss, } from "./blockRhythm.js"; import type { BlockRhythm } from "./blockRhythm.js"; import { categoryAncestorChain } from "../catalog/cascade.js"; import { categoryLabel, renderCategoryLabel } from "../catalog/labels.js"; import { browseStage, isRowChild } from "../catalog/stage.js"; import { resolveCategorySlug } from "../catalog/tree.js"; import { browseChildren, isTransparentNode, isTransparentWrapper, } from "../catalog/wrapper.js"; import { categoryChildTileEntries } from "../headless/CategoryCarousel.js"; import type { CarouselEntry, VirtualChildHref, } from "../headless/CategoryCarousel.js"; import { useCategory, useCategoryCatalog, useCategoryChildren, useCategoryLevels, } from "../model/queries.js"; import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js"; import { CategoryBreadcrumbsBar } from "./CategoryBreadcrumbsBar.js"; import type { CategoryCrumbInput } from "../headless/CategoryBreadcrumbs.js"; import { CategoryCascadeField } from "./CategoryCascadeField.js"; import { CategoryFeatureList } from "./CategoryFeatureList.js"; import { CategoryTileGrid } from "./CategoryTileGrid.js"; import type { CategoryIconResolver, TileDensity, TileLayout, TileOverflow, TileSize, TileSurface, } from "./CategoryTileGrid.js"; import { CategoryLevelList } from "./CategoryLevelList.js"; import { CategoryTreePane } from "./CategoryTreePane.js"; import { CategoryLink } from "./CategoryLink.js"; import type { LinkComponentProp } from "./CategoryLink.js"; import type { ThemeModeProp } from "./types.js"; /** * How wide a catalogue screen may get, by default. Without one, a 1280px * window put the "2 subcategories" chip ~2,300px from the label it counts and * left more than 90% of the page empty. A host that wants a different measure * passes {@link CategoryPageProps.measure} rather than overriding this value's * `max-width` from outside with `!important`. */ export const CATEGORY_MEASURE = "64rem"; /** What a heading callback is told about the page it is titling. */ export interface CategoryHeadingContext { /** The resolved category — the row the page drew its own title from. */ readonly category: Category; /** * How many SUB-CATEGORIES this page has in hand, which is the only count * this pair owns. A results count belongs to the listings pair and is * already in the host's own state, which is the reason this slot takes a * node at all. */ readonly count?: number; } /** * The page's title, host-supplied. * * A node, or a function of {@link CategoryHeadingContext} for a host that * wants the category and the page's own count without resolving the slug a * second time. Either way it replaces the CONTENT of the page's heading and * not the heading itself: one `` in the document, in the same place, with * the host's sentence inside it — a storefront that needs "Buy a car in Sochi * · 54 364" gets it without drawing a second heading above the page's and * leaving two in the outline. */ export type CategoryHeading = | ReactNode | ((ctx: CategoryHeadingContext) => ReactNode); /** * The `"tiles"` arm's own rows: `childRows` with every TRANSPARENT child * spliced out (`catalog/wrapper.ts`) — a structural one-rung IMPORT WRAPPER * (a single child that itself has children, such as the "Services" root's * import-only "Services offer" child) OR an authored * `children_as: "transparent"` sibling among several — replaced by ITS OWN * children instead of a single tile pointing at it. * * A component rather than an inline call, for the same reason * {@link SubcategoryLevelPane} is one: detecting a transparent child is free * ({@link isTransparentWrapper} / {@link isTransparentNode} read fields * already on the rows in hand), but DRAWING its children needs one more * small read per candidate (`GET {id}/children/`), and those reads must * mount only for the arm that can use them — never under `"pane"`, * `"cascade"` or `"none"`. */ function TileSubcategories(props: { readonly childRows: readonly CategoryChild[]; readonly basePath: string; readonly depth: number; readonly hrefForVirtual?: VirtualChildHref; readonly linkComponent?: LinkComponent; readonly renderIcon?: (reference: string, entry: CarouselEntry) => ReactNode; readonly resolveIconSrc?: CategoryIconResolver; readonly tileDensity?: TileDensity; readonly tileLayout?: TileLayout; readonly tileMinWidth?: number; readonly tileSize?: TileSize; readonly tileSurface?: TileSurface; readonly tileLabelLines?: number; readonly maxVisible?: number; readonly overflow?: TileOverflow; }): ReactElement { const t = useT(); const link = props.linkComponent !== undefined ? { linkComponent: props.linkComponent } : {}; const transparentIds = useMemo(() => { const ids: number[] = []; const seen = new Set(); const addCandidate = (id: number): void => { if (!seen.has(id)) { seen.add(id); ids.push(id); } }; if (isTransparentWrapper(props.childRows)) { const only = props.childRows[0]; if (only !== undefined && isRowChild(only)) addCandidate(only.id); } for (const row of props.childRows) { if (isRowChild(row) && isTransparentNode(row)) addCandidate(row.id); } return ids; }, [props.childRows]); const transparentChildQueries = useCategoryLevels(transparentIds); const childrenById = useMemo(() => { const map = new Map(); transparentIds.forEach((id, index) => { map.set(id, transparentChildQueries.rows[index] ?? undefined); }); return map; }, [transparentIds, transparentChildQueries.rows]); // A VALUE has no id to look grandchildren up by, and `browseChildren` // never asks for one — it splices out nothing that is not a row. const entries = browseChildren(props.childRows, (child) => isRowChild(child) ? childrenById.get(child.id) : undefined ); return ( {t(CATEGORIES_I18N_KEYS.categorySubcategories)} ); } export interface CategoryPageProps extends ThemeModeProp, LinkComponentProp { /** * Replace the page's heading text — see {@link CategoryHeading}. Omitted, * the page renders the category's own translated name, which is what it has * always rendered. */ readonly heading?: CategoryHeading; /** * WHICH HEADING LEVEL the page's title takes. Default `3`, what this page * has always rendered, so no existing host changes shape. * * The level is a fact about the DOCUMENT, not about this page: a page * mounted straight into a router owns the outline and its title is the * `

`; the same page mounted under a shell that already prints the * storefront's `

` must not print a second one, and a page dropped into * a section wants an `

`. Only the composing surface knows which of the * three it is, and until this prop existed a host that knew had no way to * say it — a storefront put its own heading above the page and left two * competing headings in the outline, which is the accessibility twin of the * two-lists defect {@link CategoryPageProps.subcategories} closed. * * Content is unchanged either way — this moves the tag, never the words; * {@link CategoryPageProps.heading} is the slot for those. */ readonly headingLevel?: 1 | 2 | 3; /** * The category, by id — the address that costs two small requests. Every * in-app navigation has it, because it drew the link that got here. */ readonly categoryId?: number | null; /** * The `:slug` segment of `/c/:slug` — the SEO address, resolved against the * synced catalogue because the server offers no slug lookup. Ignored when * `categoryId` is given. See this file's header. */ readonly slug?: string; readonly basePath?: string; /** The listings half of the screen — supplied by the container, because it * belongs to another pair. Unfilled, the gap NAMES itself in development * (see this file's header). */ readonly renderListings?: (category: Category) => ReactNode; /** Show the category's feature schema. Off by default: on a storefront the * schema is the facet panel's input, not a visitor-facing table. */ readonly showFeatures?: boolean; /** * Draw the trail above the title. Default `true` — what this page has always * rendered, so no existing host changes behaviour. * * NOT a taste knob, and the reason matters because the next reader will * otherwise assume it is one. Which chrome carries "where am I" is a * DEPLOYMENT'S navigation decision, and the two answers are both correct: * * - on a desktop the trail IS the catalogue's navigation — it is how a * visitor moves back up a tree that has no other affordance on screen; * - on a phone the reference design gives that job to the back arrow in * the app bar, above a landing that is search field and tiles. A crumb * row there repeats the back arrow in a second visual language and * spends a line of a fold that has four of them. * * `false` mounts NOTHING — the bar is absent from the document rather than * covered — which is the same rule the sub-category arms follow and the only * version of this a test can check. * * An object form mounts the bar (like `true`) and forwards `unlink` to * `` — a host that holds ancestry knowledge this * page does not (a wrapper the catalogue itself cannot detect from * `children_pks`, or `tn_children_pks` on an older server, alone) states it * here instead of overriding the bar's own default from outside. Supplied, * the host's predicate REPLACES the * bar's automatic transparent-wrapper check for every crumb; see * `CategoryBreadcrumbsBarProps.unlink`. */ readonly breadcrumbs?: boolean | { readonly unlink?: (crumb: CategoryCrumbInput) => boolean }; /** * Which FORM the sub-categories take. Default `"pane"` — what this page has * always rendered, so no existing host changes behaviour. * * Exactly one arm is mounted; see this file's header for the defect that * made this a prop rather than a stylesheet. */ readonly subcategories?: SubcategoryForm; /** * Tile geometry for the `"tiles"` arm — ``, * passed through verbatim. Ignored by the `"pane"` arm, which has no tiles * to size. Default is the grid's own (`"cozy"`). */ readonly subcategoryTileDensity?: TileDensity; /** * How the `"tiles"` arm fills its container — ``, * passed through verbatim. Ignored by every other {@link SubcategoryForm}. * * Default unchanged: the grid's own `"scroll"`, so no existing host * changes shape. A desktop category landing with a handful of children * (5 tiles in a 1232px column, say) reads as an empty corner under * `"scroll"`'s phone-width scroller — `"wrap"` is the fix, the same tiles * stretched to fill the row like the reference design. */ readonly subcategoryLayout?: TileLayout; /** * The `"tiles"` arm's `minTileWidth` under `subcategoryLayout="wrap"` — * ``, passed through verbatim. Ignored by * `"scroll"` and by every other {@link SubcategoryForm}. */ readonly subcategoryMinTileWidth?: number; /** * The `"tiles"` arm's own anatomy — ``, passed * through verbatim. Ignored by every other {@link SubcategoryForm}. Default * `"regular"`, the grid's own default — a root's tiles keep their reference * anatomy. A category landing (below the home) is where `"compact"` * belongs — see `CategoryTileGrid.tsx`'s {@link TileSize}. */ readonly subcategoryTileSize?: TileSize; /** * How many lines a tile's LABEL may take in the `"tiles"` arm — * ``, passed through verbatim. Default: the * tile anatomy's own count, so no existing page changes shape. * * It travels with {@link CategoryPageProps.subcategoryTileSize} rather than * being implied by it: `"compact"` clamps at two lines because a * half-height row has no third, and a catalogue whose section names are * longer than the ones that anatomy was measured on needs the third line * without giving up the anatomy. */ readonly subcategoryLabelLines?: number; /** * The `"tiles"` arm's own row cap — ``, * passed through verbatim. Meaningful only together with * {@link CategoryPageProps.subcategoryOverflow} `"modal"`. */ readonly subcategoryMaxVisible?: number; /** * What the `"tiles"` arm does past * {@link CategoryPageProps.subcategoryMaxVisible} — * ``, passed through verbatim. Default `"none"`. */ readonly subcategoryOverflow?: TileOverflow; /** * WHAT URL A VIRTUAL CHILD'S `filter` BECOMES — see * {@link VirtualChildHref}. * * A category that expands a feature into its children * (`children_expand_by`, stapel-categories 0.22.0) has children with no row * and no slug: their address is a filter on THIS category, spelled the way * the storefront spells filters, which this page cannot know. Without it * such a child is dropped from the `"tiles"` arm and a development build * says so; every other kind of child is unaffected. */ readonly hrefForVirtual?: VirtualChildHref; /** * A narrowing made in the cascade below the tiles. * * The page does not own where that goes — the results are another pair's, * and only the container knows whether a narrowing belongs in the URL, in a * search state or in both. So it is reported, not applied. `null` is the * cascade being cleared back to this landing. */ readonly onNarrow?: (category: Category | null) => void; /** The narrowing the host currently holds, for a controlled cascade. */ readonly narrowValue?: number | null; /** * Turn a tile's opaque icon reference into something renderable — the same * contract `` and `` take, and * meaningful only on the `"tiles"` arm. Absent, a tile draws the category's * own initial; this page never builds a URL out of a reference. */ readonly renderIcon?: (reference: string, entry: CarouselEntry) => ReactNode; /** * Address a tile's opaque reference without projecting the rows — forwarded * verbatim to ``, and meaningful only on the `"tiles"` * arm. See `CategoryIconResolver`. */ readonly resolveIconSrc?: CategoryIconResolver; /** * How wide the page's own content column may get — the `max-width` this * file's header explains ({@link CATEGORY_MEASURE}'s own default). * * Without a prop a host that wanted a different measure had to override it * with `!important` from outside, against a value it could not read back — * this is that knob, taking anything CSS `max-width` takes (`"72rem"`, * `960`, `"100%"`). Default {@link CATEGORY_MEASURE}, so no existing host * changes shape. * * `"none"` is the answer for the OTHER case, and it is a real one rather * than a very large number: a page mounted inside a shell whose content * column already holds the measure every page of the site shares. A second, * lower cap there is not a narrower page, it is a second answer to a * question already answered — and the 64rem one wins, so the host's own * column silently stops applying. Removing this page's cap is what says * "the measure is not mine", and it is the same sentence `gutter={false}` * makes about the indent. */ readonly measure?: number | string; /** * WHERE the space between this page's blocks comes from. Default `"token"`. * * - `"token"` — one gap for every block, read from * `var(--stapel-block-gap)` (and `var(--stapel-block-gap-compact)` on a * coarse pointer or a narrow window), defaulting to the design system's * own spacing steps, with every direct block's outer margin reset so the * distance between two of them is ONE number a host can retune by * declaring the property once — see {@link blockRhythmCss}; * - `"legacy"` — the flat `spacing[4]` this page wrote inline, for a host * whose own layout was measured against it. * * The default is the NEW behaviour: 16px between a page's sections was the * value a vertical `` was given when the page was first assembled, not * a rhythm anybody chose, and it is what made the blocks read as one column * with no seams. `@stapel/search-react` declares the same two properties, so * a storefront tunes both halves of a category screen with one declaration. */ readonly blockRhythm?: BlockRhythm; /** * The SURFACE the subcategory tiles draw — forwarded to * {@link CategoryTileGridProps.tileSurface}. Only the `"tiles"` arm of * {@link subcategories} reads it; default `"flat"`, like the grid's own. */ readonly subcategoryTileSurface?: TileSurface; /** * The page's own side padding — its distance from whatever is to its left * and right. * * Default `true`, which is what this page has always drawn: `spacing[4]` on * all four sides, correct for a page mounted straight into a router with * nothing around it. * * `false` removes the INLINE half of it, for the far more common case — the * page mounted inside a shell whose `` already holds the * page gutter (`@stapel/shell-react`, `--stapel-page-gutter`). Two boxes * each adding their own padding is a page indented twice: the heading sits * further in than the header above it and the footer below it, which is the * three-left-edges defect the shared token role exists to end. The block * padding stays either way — vertical rhythm between a header and a page's * first line is this page's own business. * * A prop rather than a context read on purpose: whether there is a gutter * outside this component is a fact about the COMPOSITION, and the composing * surface is the only party that knows it. Defaulting to `true` keeps every * existing host exactly where it is. */ readonly gutter?: boolean; /** * Hold the page that is on the glass while the NEXT category's reads are in * flight, instead of swapping it for the skeleton. Default `true`. * * A partition press moves to a sibling: the heading changes, the rows * change, and everything between them — the trail, the sub-category chrome, * and whatever the host rendered into * {@link CategoryPageProps.renderListings} — is the same page. Redrawing * the boundary's loading arm there unmounts all of it; see * `useCategoryPageSource` for the defect (D454) and what a storefront had * to write to work around it. * * While a new frame is in flight the ready arm carries * `data-stapel-load-refreshing="true"` (``), so a host that * wants to dim or announce the wait can, without owning the state. * * The breadcrumb bar sits ABOVE that boundary and is held with it: it reads * the row the frame holds rather than the address, so the one line the * boundary does not cover cannot blank on its own — see * `CategoryBreadcrumbsBarProps.keepPrevious`. * * `false` restores the old behaviour exactly: every change of address goes * through `loading` first. For a host that draws its OWN held frame and * would otherwise hold one on top of another. * * A FAILED read is never held either way — the error arm and its retry * replace the page, because a dead category wearing the previous live one * is a dead link that looks alive. */ readonly keepPrevious?: boolean; } /** * How a category page offers what is inside it. * * `"pane"` a titled LIST of links — the default, and the only shape this * page had. Drawn from the level the id path already holds, or * by `` for a slug-only host; one shared row * markup either way (see this file's header). * `"tiles"` the reference design's tile grid within the catalogue's depth * cap (`catalog/tiles.ts`), and past it the CASCADE the cap hands * over to. One arm, two halves of one navigation model. * `"cascade"` the cascading child selectors at every depth, tiles never. For * a host that draws its own tiles above this page. * `"none"` neither. For a host that draws its own sub-category chrome and * wants the rest of this page. */ export type SubcategoryForm = "pane" | "tiles" | "cascade" | "none"; /** * ONE landing, whole: the row, the level under it, and the depth they were * read at. * * The three travel INSIDE the {@link LoadState} rather than beside it, and * that is what makes `keepPrevious` mean anything: a page holding the * previous answer must hold all of it, or the heading, the tiles and the * listings slot can disagree about which category is on screen. */ interface CategoryPageFrame { /** The landing itself. `null` = the read succeeded and named nothing, * which only the SLUG path can produce (an unknown id is a 404, and that * arrives as `failed`). */ readonly current: Category | null; /** The level under it, as the wire sends it — POINTERS at their own * `order` among the rows, and, on an expanded branch, VALUES with no row * behind them (stapel-categories 0.22.0). */ readonly children: readonly CategoryChild[]; /** The landing's own 0-indexed depth — from `tn_ancestors_pks` on the id * path, from the built node on the slug path. `null` with no landing. */ readonly depth: number | null; } /** What the two addresses resolve to, in one shape. */ interface CategoryPageSource { /** `loading` until the landing is known — or, under `keepPrevious`, `ready` * with the PREVIOUS landing and {@link LoadState} `refreshing` set. */ readonly state: LoadState; refetch(): void; } /** * Resolve the landing, by id (two small reads) or by slug (the catalogue). * * Both hooks are always called and one of them is always disabled — a * disabled TanStack query issues no request, stores nothing and reads * nothing, which is what makes "the id path never transfers the catalogue" a * property of the code rather than of the render order. * * ── Why the previous frame is kept (D454) ────────────────────────────────── * * A partition press navigates to a SIBLING: same page, same rail, same * segmented control, different rows. It changed `categoryId`, sent both reads * pending, and the boundary below swapped its subtree for a four-row * skeleton — which UNMOUNTED everything the host had rendered into * `renderListings`, including the control the person had just pressed, its * focus and its scroll position, twice (out and back). A storefront worked * around it by re-mounting these very hooks in the container and withholding * the id from this page until both had landed. That knowledge belongs here: * the page already knows when its own frame is whole. * * So the composed state goes through {@link useKeptLoad}, and the page draws * the last WHOLE frame until the next one is whole. `failed` still passes * straight through — a dead category wearing the previous live page is a dead * link that looks alive. */ function useCategoryPageSource(props: { readonly categoryId?: number | null; readonly slug?: string; readonly keepPrevious?: boolean; }): CategoryPageSource { const byId = props.categoryId !== null && props.categoryId !== undefined; const id = byId ? (props.categoryId as number) : null; const rowQuery = useCategory(id); const childrenQuery = useCategoryChildren(id); const catalogQuery = useCategoryCatalog({ enabled: !byId }); const catalog = loadStateFromQuery(catalogQuery); let fresh: LoadState; let refetch: () => void; if (byId) { const row = rowQuery.data ?? null; const childrenState = loadStateFromQuery(childrenQuery); // BOTH reads gate the page, and the children one is not optional: a page // that went `ready` on the row alone would draw "no sub-categories" for a // third of a second on every category that has some. fresh = rowQuery.error != null ? { status: "failed", error: rowQuery.error } : childrenState.status === "failed" ? childrenState : row === null || childrenState.status !== "ready" ? { status: "loading" } : { status: "ready", data: { current: row, children: loadedRowsOrEmpty(childrenState), depth: categoryAncestorChain(row).length, }, }; refetch = () => { void rowQuery.refetch(); void childrenQuery.refetch(); }; } else { const index = catalog.status === "ready" ? catalog.data.index : null; const node = index !== null && props.slug !== undefined ? (resolveCategorySlug(index, props.slug) ?? null) : null; fresh = mapLoad(catalog, () => ({ current: node?.category ?? null, children: node?.children.map((child) => child.category) ?? [], depth: node?.depth ?? null, })); refetch = () => { void catalogQuery.refetch(); }; } const state = useKeptLoad(fresh, { keepPrevious: props.keepPrevious !== false, }); return { state, refetch }; } /** * The `"pane"` arm on the ID PATH: the titled list, from the rows the page * already holds. * * `useCategoryPageSource` gated the page on `GET {id}/children/`, so by the * time this mounts the level is IN HAND — the only thing the catalogue could * still add is the per-row "N subcategories" chip, and that is one small * cached children read per row (`useCategoryLevels`), the same rung a cascade * or the next landing reads. The rows render immediately; a chip whose read * has not landed (or refused) draws nothing — no Tag, no chevron, no zero * standing in for an unknown. When it lands as >0 it draws exactly what the * catalogue path draws, because both arms render ``. * * A component rather than a branch inside `Subcategories`, because the arm is * conditional and the fan-out is a hook: mounting and unmounting WITH the arm * is what keeps the reads from firing under `"tiles"` or `"none"`. */ function SubcategoryLevelPane(props: { readonly childRows: readonly Category[]; readonly basePath: string; readonly linkComponent?: LinkComponent; }): ReactElement { const t = useT(); const ids = props.childRows.map((row) => row.id); const counts = useCategoryLevels(ids); return ( {t(CATEGORIES_I18N_KEYS.categorySubcategories)} { const level = counts.rows[ids.indexOf(row.id)]; return level == null ? null : level.length; }} basePath={props.basePath} /> ); } /** * The chosen arm, and only it. * * A leaf renders nothing in every arm: "this category has no sub-categories" * is ``'s empty sentence on a page that came looking for * them, not something to say under a heading on a page whose listings are the * point. */ function Subcategories(props: { readonly form: SubcategoryForm; readonly current: Category; readonly depth: number; /** Named `childRows`, not `children`: a prop called `children` on a React * component IS the element's children, and an array of category rows put * there would be rendered as content instead of read as data. */ readonly childRows: readonly CategoryChild[]; /** * Which address resolved `childRows`. `true` = the id path: the rows are * `GET {id}/children/`, already paid for, and the pane arm renders them * directly. `false` = the catalogue built them for a slug-only host, and * the pane arm keeps ``, whose sync is by then a cache * hit. The distinction cannot be read off `slug` — hosts pass BOTH * addresses, and choosing by slug is the 13-second defect (see the header). */ readonly rowsFromId: boolean; readonly slug?: string; readonly basePath: string; readonly linkComponent?: LinkComponent; readonly onNarrow?: (category: Category | null) => void; readonly narrowValue?: number | null; readonly renderIcon?: (reference: string, entry: CarouselEntry) => ReactNode; readonly resolveIconSrc?: CategoryIconResolver; readonly tileDensity?: TileDensity; readonly tileLayout?: TileLayout; readonly tileMinWidth?: number; readonly tileSize?: TileSize; readonly tileSurface?: TileSurface; readonly tileLabelLines?: number; readonly maxVisible?: number; readonly overflow?: TileOverflow; readonly hrefForVirtual?: VirtualChildHref; }): ReactElement | null { const t = useT(); const link = props.linkComponent !== undefined ? { linkComponent: props.linkComponent } : {}; if (props.form === "none") return null; if (props.childRows.length === 0) return null; const cascade = ( {t(CATEGORIES_I18N_KEYS.categorySubcategories)} { props.onNarrow?.(row); } } : {})} /> ); if (props.form === "cascade") return cascade; if (props.form === "tiles") { // Past a root the tiles hand over to the ladder rather than to nothing — // see this file's header for the 2924 leaves that answer used to hide. // `browseStage` is the same call the browse contract's two-level rule // names everywhere else, so this arm's own choice cannot drift from it. if (browseStage(props.current) !== "tiles") return cascade; return ( ); } // The id path already holds this level — mounting the pane here would // resolve the slug through the full catalogue sync for rows that are // sitting in `childRows`, which is the 13-second defect in the header. if (props.rowsFromId) { return ( ); } if (props.slug === undefined) return cascade; return ( ); } export function CategoryPage(props: CategoryPageProps): ReactElement { const t = useT(); const base = props.basePath ?? "/c"; const link = props.linkComponent !== undefined ? { linkComponent: props.linkComponent } : {}; const source = useCategoryPageSource({ ...(props.categoryId !== undefined ? { categoryId: props.categoryId } : {}), ...(props.slug !== undefined ? { slug: props.slug } : {}), ...(props.keepPrevious !== undefined ? { keepPrevious: props.keepPrevious } : {}), }); const keeping = props.keepPrevious !== false; /** * The category the page is DRAWING, which during a held frame is not the * one it was asked for. * * The trail belongs to the frame, not to the address bar: handed the raw * `categoryId`, `` started the new category's own * reads while everything under it still showed the previous one, went * `loading`, and drew a lone `ant-skeleton-input-sm` where root → current * had been — a hole in a frame the page was deliberately holding whole. So * it reads the held row, and moves when the frame moves. `null` (a first * mount, or `keepPrevious={false}`) falls back to the address, which is * what makes the first paint unchanged. */ const heldId = source.state.status === "ready" ? (source.state.data.current?.id ?? null) : null; const legacyRhythm = props.blockRhythm === "legacy"; return ( {/* The rhythm's rules — see `blockRhythmCss`. Hoisted, deduped by `href`, and not mounted at all when the host asked for the old inline gap. */} {!legacyRhythm && ( )} {/* The page owns the outage and the dead address; the bar under the same read must not state either a second time in a second visual language. */} {props.breadcrumbs === false ? null : ( )} ( // The substrate's default arm would say "something went // wrong"; this pair knows WHICH thing, and keeps the // technical detail core split off beside it. )} > {(frame) => frame.current === null ? ( {t(CATEGORIES_I18N_KEYS.categoryBackToCatalog)} } /> ) : ( {props.heading === undefined ? renderCategoryLabel(categoryLabel(frame.current), t) : typeof props.heading === "function" ? props.heading({ category: frame.current, count: frame.children.length, }) : props.heading} {props.showFeatures === true ? ( ) : null} {props.renderListings !== undefined ? ( props.renderListings(frame.current) ) : ( )} ) } ); }