/** * `` — root → current, as antd breadcrumbs. * * Every caption goes through `renderCategoryLabel`, which is the one place * this skin decides between "translate this" and "print this": a category's * `name` is a translation KEY unless the row says `translatable: false` * (`catalog/labels.ts`). A skin that called `t()` unconditionally would print * the stored string for an untranslatable category twice-wrapped, and one that * never called it would show `category.electronics` at a visitor. */ import type { CSSProperties, ReactElement, ReactNode } from "react"; import { Breadcrumb, Skeleton, Typography } from "antd"; import { useT } from "@stapel/core"; import { renderCategoryLabel } from "../catalog/labels.js"; import { CategoryBreadcrumbs } from "../headless/CategoryBreadcrumbs.js"; import type { CategoryCrumbInput } from "../headless/CategoryBreadcrumbs.js"; import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js"; import { CategoryLink } from "./CategoryLink.js"; import type { LinkComponentProp } from "./CategoryLink.js"; import { ErrorAlert, LoadBoundary, SkinTheme } from "@stapel/tokens-antd/skin"; import type { ThemeModeProp } from "./types.js"; export interface CategoryBreadcrumbsBarProps extends ThemeModeProp, LinkComponentProp { readonly slug?: string; readonly categoryId?: number | null; /** Path prefix for a crumb's link. Default `/c`. */ readonly basePath?: string; /** * What the bar does when the catalogue could not be read, or when the slug * resolves to nothing. * * `"state"` (default): say so — this bar is sometimes mounted alone, in a * header, and a silent header is a silent failure. `"quiet"`: render * nothing, for a PAGE that states the same fact under it. `` * passes `"quiet"`, which is why one outage no longer produces a bare red * sentence with a blue link on top of a designed error panel, and why an * unknown slug is not announced twice. */ readonly onAbsent?: "state" | "quiet"; /** * Which crumbs render as plain text instead of a link — see * `CategoryCrumb.linked`. Forwarded verbatim to ``; * omitted, a TRANSPARENT WRAPPER ancestor (`catalog/wrapper.ts`) is * unlinked automatically. Supplied, this predicate is the only thing * consulted — see `CategoryBreadcrumbsProps.unlink`. */ readonly unlink?: (crumb: CategoryCrumbInput) => boolean; /** * Hold the trail that is on the glass while the next category's rows are in * flight, instead of swapping it for the skeleton. Default `false` — a bar * mounted alone in a header keeps the behaviour it has. * * `` passes `true` (and the id of the frame it is HOLDING), * because a page that keeps its heading, its sub-categories and its * listings across a sibling press, and blanks the one row above them to a * skeleton, is the same rebuild the page's own `keepPrevious` removed — * just narrower. The kept trail carries `data-stapel-load-refreshing="true"` * (``) until the newer rows land, so the wait is stated * without being drawn as an absence. */ readonly keepPrevious?: boolean; } /** * The one declaration a bare trail keeps: the theme's text colour, live. * See the block comment on the `` below. */ const BARE_TRAIL_STYLE: CSSProperties = { color: "var(--stapel-text)" }; export function CategoryBreadcrumbsBar( props: CategoryBreadcrumbsBarProps ): ReactElement { const t = useT(); const base = props.basePath ?? "/c"; const quiet = props.onAbsent === "quiet"; /** A crumb's title: `href` on the ITEM would make antd render its own * anchor, which is exactly the full page load this seam removes. So the * link is the title. */ const crumbLink = ( href: string, label: ReactNode, slug?: string, categoryId?: number ): ReactElement => ( {label} ); return ( /* A TRAIL IS NOT A PANEL — `surface="bare"`, so this bar paints nothing and takes the ground it stands on. * * `` defaults to `surface="raised"`, which puts * `colorBgContainer` on its own root. On a light theme that is the same * colour as the page and nobody ever saw it; on a dark one it is a * lighter band, and it was measured on a live storefront as a 1392x24 ribbon * across the top of every `/c/:slug` and every listing page — a panel * behind one line of links, with no radius, no border and nothing inside * it that belongs on a surface of its own. * * `bare` is the same choice `SkinDialog` makes for the same reason: this * wrapper exists to give antd its theme (the `ConfigProvider` below it is * the whole point), not to paint. * * `bare` also drops the text colour `raised` would have written, so the * colour is stated back as the CUSTOM PROPERTY — the same trade the flat * tile grid made one release ago, and strictly better than what it * replaces: `--stapel-text` resolves per theme at paint time, where * `SkinTheme` froze the value of whichever side mounted first. */ {(bag) => ( } failed={(error) => quiet ? null : ( ) } > {(crumbs) => bag.unknownSlug ? ( quiet ? null : ( {t(CATEGORIES_I18N_KEYS.categoryUnknownSlug)} ) ) : ( ({ // The CURRENT crumb is where you already are: a label, // never a link — the one item antd would happily render // as a link to the page under your feet. A crumb marked // `linked: false` (a transparent wrapper, or the host's // own `unlink`) gets the same plain-text treatment — // same typography, no anchor — without touching // `aria-current`, which only ever belongs to the // current crumb above. title: crumb.isCurrent || !crumb.linked ? renderCategoryLabel(crumb.label, t) : crumbLink( `${base}/${crumb.category.slug}`, renderCategoryLabel(crumb.label, t), crumb.category.slug, crumb.category.id ), })), ]} /> ) } )} ); }