diff --git a/.changeset/6645-header-bar-crumb-icon.md b/.changeset/6645-header-bar-crumb-icon.md
new file mode 100644
index 0000000000..457ce914d4
--- /dev/null
+++ b/.changeset/6645-header-bar-crumb-icon.md
@@ -0,0 +1,42 @@
+---
+'@object-ui/components': patch
+---
+
+`ui:header-bar` now resolves a crumb's authored `icon` to a glyph instead of drawing
+nothing (objectui#6645).
+
+`HeaderBarSchema.crumbs` is typed `BreadcrumbItem[]` — the same declaration
+`BreadcrumbSchema.items` uses — and its Zod mirror does not merely declare `icon`, it
+**describes** it (`.describe('Breadcrumb icon')`), so any authoring surface that reads Zod
+`describe` can already offer the key to an author. `header-bar.tsx` contained zero
+occurrences of the substring `icon`.
+
+After PR #6644 repaired the breadcrumb side, **one declared key behaved differently on its
+two consumers**: authored on a `breadcrumb` item it drew a glyph, authored on a
+`header-bar` crumb it drew nothing. The asymmetry was invisible only because it had been a
+uniform zero on both. It is now asserted directly — one crumb object is rendered through
+both renderers and the resolved glyph compared, in the positive direction and on a retired
+spelling.
+
+Resolved through the **shared** `resolveIcon`, never a local normaliser: objectui#5993 is
+the standing lesson that a local copy is the same algorithm under a different function, and
+the alias later added there to absorb a lucide retirement reached every `action:*` site
+except `ui:button`. So this is the lucide **record** surface — a live name draws its glyph,
+an unknown or retired spelling draws nothing rather than degrading to a wrong one. The
+`home` -> lucide `House` rename lives only in the shared resolver's map, and a pin asserts
+it from the outside, so a future local re-implementation is red.
+
+The glyph renders once per crumb inside `BreadcrumbItem`, **above** `BreadcrumbLabel`, so
+all three of that helper's arms — the siblings quick-switch dropdown, the last crumb's
+`BreadcrumbPage`, and every earlier `BreadcrumbLink` — carry it by construction rather than
+one at a time.
+
+Scored `patch`, matching PR #6644's scoring of the identical repair on the sibling
+consumer: no new capability, a declared key that drew nothing starts drawing.
+
+A `crumbs-with-icons` catalog fixture authors the key and the docs page documents it, so
+this does not come back next round as "declared but unenforced". The icon-record gate gains
+a `header-bar` census entry for the same reason `context-menu` gained one in objectui#6278:
+until the repair the names reached no resolver and declining them was correct, and a census
+entry is a fact about a renderer. That is not objectui#5992's blind spot, which is the gate
+*guessing* at containers nobody read off a renderer.
diff --git a/.changeset/6646-breadcrumb-separator-max-items.md b/.changeset/6646-breadcrumb-separator-max-items.md
new file mode 100644
index 0000000000..81335dcd5c
--- /dev/null
+++ b/.changeset/6646-breadcrumb-separator-max-items.md
@@ -0,0 +1,40 @@
+---
+'@object-ui/components': minor
+---
+
+`ui:breadcrumb` now reads the two declared keys it never referenced — `separator` and
+`maxItems` (objectui#6646).
+
+`BreadcrumbSchema` has declared both since it shipped (`packages/types/src/navigation.ts`,
+mirrored in `zod/navigation.zod.ts`), and `separator` is additionally advertised to authors
+on the component's own documentation page. The renderer contained zero occurrences of
+either name: it always emitted the bare `BreadcrumbSeparator` and it never collapsed. That
+made `separator` the sharper of the two — an author who read the page, wrote
+`"separator": "/"` and saw a chevron got feedback **identical** to having misspelled the
+key, with nothing to tell the two apart.
+
+**Scored `minor`, not `patch`, on the separator default.** The sibling repair (PR #6644,
+the same renderer's `icon` key) was a `patch` because it only started drawing something
+where nothing had been drawn. This one also changes what an **unauthored** breadcrumb
+renders: `separator` carries `@default '/'` in the declaration while the renderer fell
+through to shadcn's `ChevronRight`, so declared default and actual render disagreed, and
+honouring only the authored value would have left the docs lying about the unauthored one.
+The render is aligned to the declaration (`schema.separator ?? '/'`) rather than the
+declaration being rewritten to match the render — rewriting a published `@default` is a
+contract change, which ADR-0049 routes to a maintainer, and this card's dispatched arm is
+"implement the declaration". Every existing `ui:breadcrumb` therefore separates with `/`
+instead of a chevron unless it authors otherwise. `''` is honoured as authored (no visible
+separator), not promoted to the default — hence `??` and not `||`.
+
+`maxItems` bounds how many crumbs are **rendered**. When the trail is longer, the first
+crumb and the last `maxItems - 1` survive with shadcn's `BreadcrumbEllipsis` between them,
+so the current page — the crumb a trail exists to name — is never the one dropped; at
+`maxItems: 1` there is no room for both ends and the current page is what stays. A value
+that cannot mean a count (absent, non-finite, below `1`) is declined rather than coerced,
+because silently inventing a truncated trail is worse than ignoring the key.
+
+Two catalog fixtures author the keys (`custom-separator`, `collapsed-trail`) and the docs
+page gained a section for each, plus the `maxItems` row its interface block never carried.
+
+`packages/types` is untouched: both keys were already declared, and the only thing missing
+was a renderer that read them.
diff --git a/content/docs/components/data-display/breadcrumb.mdx b/content/docs/components/data-display/breadcrumb.mdx
index 344eddf309..49b57fb82a 100644
--- a/content/docs/components/data-display/breadcrumb.mdx
+++ b/content/docs/components/data-display/breadcrumb.mdx
@@ -13,6 +13,28 @@ The Breadcrumb component shows the current page's location within the site hiera
+## Separator
+
+An item trail is joined by `separator`, a plain string. Leave it unauthored and
+the trail separates with the declared default, `/`:
+
+
+
+Any string is accepted, including an empty one (`""`) for no visible separator
+at all.
+
+## Collapsing a long trail
+
+`maxItems` bounds how many crumbs are **rendered**. When the trail is longer,
+the first crumb and the last `maxItems - 1` survive and an ellipsis marks what
+was elided — so the current page, the crumb the trail exists to name, is never
+the one dropped:
+
+
+
+A trail already within `maxItems` is rendered whole, and a `maxItems` that
+cannot mean a count (below `1`) is ignored rather than emptying the trail.
+
## Icons
An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's
@@ -34,7 +56,8 @@ interface BreadcrumbItem {
interface BreadcrumbSchema {
type: 'breadcrumb';
items: BreadcrumbItem[]; // Breadcrumb items
- separator?: string; // Custom separator
+ separator?: string; // Separator between crumbs (default "/")
+ maxItems?: number; // Maximum crumbs to display before collapsing
className?: string;
}
```
diff --git a/content/docs/components/navigation/header-bar.mdx b/content/docs/components/navigation/header-bar.mdx
index 6e5afc445a..8bf92eb8f4 100644
--- a/content/docs/components/navigation/header-bar.mdx
+++ b/content/docs/components/navigation/header-bar.mdx
@@ -13,19 +13,35 @@ The Header Bar component provides a standard application header with sidebar tri
+## Crumb Icons
+
+A crumb's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's
+runtime `icons` record — the same surface `ui:breadcrumb`, `ui:button` and the
+`action:*` family resolve against, reached through the same shared resolver. A
+name that is not a live key of that record (an unknown, or a retired spelling
+such as `layout`) renders **no glyph**, never a fallback glyph and never the
+literal name as text.
+
+
+
+`crumbs` and a `ui:breadcrumb`'s `items` are the **same** declared item shape,
+so an icon authored on either draws the same glyph.
+
## Schema
```plaintext
-interface Breadcrumb {
+interface BreadcrumbItem {
label: string; // Breadcrumb text
href?: string; // Link URL (optional for last item)
+ icon?: string; // kebab-case Lucide icon name (e.g. "panels-top-left")
+ siblings?: Array<{ label: string; href: string }>; // Quick-switch dropdown
}
interface HeaderBarSchema {
type: 'header-bar';
// Breadcrumbs
- crumbs?: Breadcrumb[]; // Breadcrumb items
+ crumbs?: BreadcrumbItem[]; // Breadcrumb items
// Styling
className?: string; // Tailwind CSS classes
@@ -55,6 +71,7 @@ interface HeaderBarSchema {
- The Header Bar component includes a sidebar trigger button that works with the Sidebar component
- The last breadcrumb item is typically rendered as plain text (current page)
+- A crumb may carry an `icon` (see [Crumb Icons](#crumb-icons)) and `siblings`, which turns it into a quick-switch dropdown
- All other breadcrumb items should have an `href` to enable navigation
- The component has a fixed height of 64px (`h-16`)
- Includes a vertical separator between the sidebar trigger and breadcrumbs
diff --git a/examples/schema-catalog/src/catalog-meta.json b/examples/schema-catalog/src/catalog-meta.json
index 962f765a3d..f2b142ae54 100644
--- a/examples/schema-catalog/src/catalog-meta.json
+++ b/examples/schema-catalog/src/catalog-meta.json
@@ -243,5 +243,32 @@
"drawer",
"crud"
]
+ },
+ "components-data-display-breadcrumb/custom-separator": {
+ "title": "Custom Separator",
+ "description": "The declared `separator` key, authored as \">\". Left unauthored, the trail separates with the declared default \"/\".",
+ "tags": [
+ "breadcrumb",
+ "separator",
+ "navigation"
+ ]
+ },
+ "components-data-display-breadcrumb/collapsed-trail": {
+ "title": "Collapsed Trail",
+ "description": "`maxItems: 3` over a five-crumb trail: the root, an elision marker, and the last two crumbs.",
+ "tags": [
+ "breadcrumb",
+ "maxItems",
+ "collapse"
+ ]
+ },
+ "components-navigation-header-bar/crumbs-with-icons": {
+ "title": "Crumbs With Icons",
+ "description": "`crumbs[].icon` — kebab-case Lucide names resolved to glyphs, the same declared key a `ui:breadcrumb` item carries.",
+ "tags": [
+ "header",
+ "breadcrumb",
+ "icon"
+ ]
}
}
diff --git a/examples/schema-catalog/src/index.ts b/examples/schema-catalog/src/index.ts
index 36c05203bd..99c0e276e1 100644
--- a/examples/schema-catalog/src/index.ts
+++ b/examples/schema-catalog/src/index.ts
@@ -108,6 +108,8 @@ import components_data_display_avatar_avatar_with_fallback from './schemas/compo
import components_data_display_avatar_avatar_with_image from './schemas/components-data-display-avatar/avatar-with-image.json' with { type: 'json' };
import components_data_display_badge_badge_variants from './schemas/components-data-display-badge/badge-variants.json' with { type: 'json' };
import components_data_display_breadcrumb_basic_breadcrumb from './schemas/components-data-display-breadcrumb/basic-breadcrumb.json' with { type: 'json' };
+import components_data_display_breadcrumb_collapsed_trail from './schemas/components-data-display-breadcrumb/collapsed-trail.json' with { type: 'json' };
+import components_data_display_breadcrumb_custom_separator from './schemas/components-data-display-breadcrumb/custom-separator.json' with { type: 'json' };
import components_data_display_breadcrumb_with_icons from './schemas/components-data-display-breadcrumb/with-icons.json' with { type: 'json' };
import components_data_display_kbd_command_palette from './schemas/components-data-display-kbd/command-palette.json' with { type: 'json' };
import components_data_display_kbd_copy_shortcut from './schemas/components-data-display-kbd/copy-shortcut.json' with { type: 'json' };
@@ -279,6 +281,7 @@ import components_layout_stack_basic_stack from './schemas/components-layout-sta
import components_layout_tabs_basic_tabs from './schemas/components-layout-tabs/basic-tabs.json' with { type: 'json' };
import components_navigation_header_bar_admin_breadcrumbs from './schemas/components-navigation-header-bar/admin-breadcrumbs.json' with { type: 'json' };
import components_navigation_header_bar_app_navigation from './schemas/components-navigation-header-bar/app-navigation.json' with { type: 'json' };
+import components_navigation_header_bar_crumbs_with_icons from './schemas/components-navigation-header-bar/crumbs-with-icons.json' with { type: 'json' };
import components_navigation_header_bar_deep_navigation from './schemas/components-navigation-header-bar/deep-navigation.json' with { type: 'json' };
import components_navigation_header_bar_settings_path from './schemas/components-navigation-header-bar/settings-path.json' with { type: 'json' };
import components_navigation_header_bar_simple_header from './schemas/components-navigation-header-bar/simple-header.json' with { type: 'json' };
@@ -1320,6 +1323,26 @@ const REGISTRY: Record = {
},
schema: components_data_display_breadcrumb_basic_breadcrumb,
},
+ 'components-data-display-breadcrumb/collapsed-trail': {
+ id: 'components-data-display-breadcrumb/collapsed-trail',
+ meta: {
+ title: "Collapsed Trail",
+ description: "`maxItems: 3` over a five-crumb trail: the root, an elision marker, and the last two crumbs.",
+ category: 'components-data-display-breadcrumb',
+ tags: ["breadcrumb", "maxItems", "collapse"],
+ },
+ schema: components_data_display_breadcrumb_collapsed_trail,
+ },
+ 'components-data-display-breadcrumb/custom-separator': {
+ id: 'components-data-display-breadcrumb/custom-separator',
+ meta: {
+ title: "Custom Separator",
+ description: "The declared `separator` key, authored as \">\". Left unauthored, the trail separates with the declared default \"/\".",
+ category: 'components-data-display-breadcrumb',
+ tags: ["breadcrumb", "separator", "navigation"],
+ },
+ schema: components_data_display_breadcrumb_custom_separator,
+ },
'components-data-display-breadcrumb/with-icons': {
id: 'components-data-display-breadcrumb/with-icons',
meta: {
@@ -2870,6 +2893,16 @@ const REGISTRY: Record = {
},
schema: components_navigation_header_bar_app_navigation,
},
+ 'components-navigation-header-bar/crumbs-with-icons': {
+ id: 'components-navigation-header-bar/crumbs-with-icons',
+ meta: {
+ title: "Crumbs With Icons",
+ description: "`crumbs[].icon` — kebab-case Lucide names resolved to glyphs, the same declared key a `ui:breadcrumb` item carries.",
+ category: 'components-navigation-header-bar',
+ tags: ["header", "breadcrumb", "icon"],
+ },
+ schema: components_navigation_header_bar_crumbs_with_icons,
+ },
'components-navigation-header-bar/deep-navigation': {
id: 'components-navigation-header-bar/deep-navigation',
meta: {
diff --git a/examples/schema-catalog/src/schemas/components-data-display-breadcrumb/collapsed-trail.json b/examples/schema-catalog/src/schemas/components-data-display-breadcrumb/collapsed-trail.json
new file mode 100644
index 0000000000..96b8d54f44
--- /dev/null
+++ b/examples/schema-catalog/src/schemas/components-data-display-breadcrumb/collapsed-trail.json
@@ -0,0 +1,25 @@
+{
+ "type": "breadcrumb",
+ "maxItems": 3,
+ "items": [
+ {
+ "label": "Home",
+ "href": "/"
+ },
+ {
+ "label": "Products",
+ "href": "/products"
+ },
+ {
+ "label": "Electronics",
+ "href": "/products/electronics"
+ },
+ {
+ "label": "Laptops",
+ "href": "/products/electronics/laptops"
+ },
+ {
+ "label": "ObjectBook Pro"
+ }
+ ]
+}
diff --git a/examples/schema-catalog/src/schemas/components-data-display-breadcrumb/custom-separator.json b/examples/schema-catalog/src/schemas/components-data-display-breadcrumb/custom-separator.json
new file mode 100644
index 0000000000..f1d105aedd
--- /dev/null
+++ b/examples/schema-catalog/src/schemas/components-data-display-breadcrumb/custom-separator.json
@@ -0,0 +1,21 @@
+{
+ "type": "breadcrumb",
+ "separator": ">",
+ "items": [
+ {
+ "label": "Home",
+ "href": "/"
+ },
+ {
+ "label": "Products",
+ "href": "/products"
+ },
+ {
+ "label": "Category",
+ "href": "/products/category"
+ },
+ {
+ "label": "Item"
+ }
+ ]
+}
diff --git a/examples/schema-catalog/src/schemas/components-navigation-header-bar/crumbs-with-icons.json b/examples/schema-catalog/src/schemas/components-navigation-header-bar/crumbs-with-icons.json
new file mode 100644
index 0000000000..8c7cdf9b4f
--- /dev/null
+++ b/examples/schema-catalog/src/schemas/components-navigation-header-bar/crumbs-with-icons.json
@@ -0,0 +1,19 @@
+{
+ "type": "header-bar",
+ "crumbs": [
+ {
+ "label": "Home",
+ "href": "#",
+ "icon": "home"
+ },
+ {
+ "label": "Settings",
+ "href": "#",
+ "icon": "settings"
+ },
+ {
+ "label": "Profile",
+ "icon": "user"
+ }
+ ]
+}
diff --git a/packages/components/src/__tests__/breadcrumb-item-icon.test.tsx b/packages/components/src/__tests__/breadcrumb-item-icon.test.tsx
index 93ac2f294d..d0061943d5 100644
--- a/packages/components/src/__tests__/breadcrumb-item-icon.test.tsx
+++ b/packages/components/src/__tests__/breadcrumb-item-icon.test.tsx
@@ -53,15 +53,23 @@
*
* ## The instrument's positive control
*
- * `BreadcrumbSeparator` always draws a `ChevronRight`, in a SIBLING ``. A
- * bare `container.querySelector('svg')` would therefore be green in both worlds
- * — the blind instrument this suite must not use. Every row scopes to the
- * crumb's own `` and names the glyph by the class lucide derives from the
- * icon's own identity (`svg.lucide-`), while the chevron is asserted at
- * container level as a control ON THE INSTRUMENT: if a `lucide-book` row is red
- * while the chevron row is green, the query works and the authored icon is
+ * Every row scopes to the crumb's own `` and names the glyph by the class
+ * lucide derives from the icon's own identity (`svg.lucide-`). A bare
+ * `container.querySelector('svg')` would be green in both worlds — the blind
+ * instrument this suite must not use — so a separate control establishes that
+ * the selector finds a glyph when one IS present: if a `lucide-book` row is red
+ * while the control is green, the query works and the authored icon is
* genuinely absent.
*
+ * ⚠️ That control used to be asserted on THIS renderer's output, because
+ * `BreadcrumbSeparator` fell through to a `ChevronRight` in a sibling ``.
+ * objectui#6646 aligned the renderer's default separator to the `@default '/'`
+ * its declaration has always carried, so no chevron is drawn here any more and
+ * the control would have been RED for a reason that has nothing to do with
+ * icons. It is preserved by rendering the shadcn PRIMITIVE directly — still a
+ * positive control on the selector, and now green in both worlds by
+ * construction rather than by a renderer default that was free to change.
+ *
* ## Why lucide is NOT mocked
*
* The contract under test is "the authored name is resolved against lucide's
@@ -95,6 +103,7 @@ import { ComponentRegistry } from '@object-ui/core';
// cold transform is billed to `hookTimeout`. See
// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
import '../renderers';
+import { BreadcrumbSeparator } from '../ui/breadcrumb';
afterEach(() => cleanup());
@@ -131,14 +140,21 @@ describe('ui:breadcrumb item icon resolution (objectui#5931)', () => {
expect(screen.getByText('Docs').closest('[aria-current="page"]')).not.toBeNull();
});
- it('positive control on the instrument — a queryable svg IS present', () => {
- // Green in both worlds BY DESIGN: `BreadcrumbSeparator` always draws a
- // chevron. It exists so a red `lucide-*` row cannot be misread as a
- // broken query.
- const { container } = renderCrumbs(TWO());
+ it('positive control on the instrument — a queryable svg IS findable', () => {
+ // Green in both worlds BY DESIGN, and independent of what `ui:breadcrumb`
+ // chooses to draw: the shadcn primitive is rendered DIRECTLY, so this
+ // proves the `svg.lucide-*` selector works and a red `lucide-*` row below
+ // cannot be misread as a broken query. See the header for why it no
+ // longer reads the renderer's own separator (objectui#6646).
+ const { container } = render();
expect(container.querySelector('svg.lucide-chevron-right')).not.toBeNull();
- // …and it is NOT inside either crumb, which is why the rows below can
- // scope to the crumb's own and stay discriminating.
+ });
+
+ it('…and no glyph leaks into a crumb that authored none', () => {
+ // The other half of the old control: the rows below can scope to the
+ // crumb's own and stay discriminating because nothing else puts an
+ // svg there.
+ renderCrumbs(TWO());
expect(crumbFor('Home').querySelector('svg')).toBeNull();
});
});
diff --git a/packages/components/src/__tests__/breadcrumb-separator-max-items.test.tsx b/packages/components/src/__tests__/breadcrumb-separator-max-items.test.tsx
new file mode 100644
index 0000000000..e2adc5905c
--- /dev/null
+++ b/packages/components/src/__tests__/breadcrumb-separator-max-items.test.tsx
@@ -0,0 +1,302 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `ui:breadcrumb` honours its two remaining declared keys — `separator` and
+ * `maxItems` (objectui#6646).
+ *
+ * ## The class
+ *
+ * Both keys are DECLARED on the published protocol — `BreadcrumbSchema` in
+ * `packages/types/src/navigation.ts`, mirrored in
+ * `packages/types/src/zod/navigation.zod.ts` — and `separator` is additionally
+ * ADVERTISED to authors on the component's own documentation page
+ * (`content/docs/components/data-display/breadcrumb.mdx`). Before this suite
+ * `renderers/data-display/breadcrumb.tsx` contained ZERO occurrences of either
+ * name: it always emitted a bare `BreadcrumbSeparator` (shadcn's
+ * `ChevronRight`) and it never collapsed.
+ *
+ * The sharp half is `separator`, and it is sharp for a reason a "declared but
+ * unenforced" label does not carry on its face: an author who reads the page,
+ * writes `"separator": "/"`, and sees a chevron gets feedback IDENTICAL to
+ * having misspelled the key. Nothing distinguishes "the key does nothing" from
+ * "I typed `seperator`". `separatorIsDistinguishableFromATypo` below is that
+ * exact discrimination, asserted directly rather than implied.
+ *
+ * ## The declared default was ALSO wrong, and that is its own row
+ *
+ * `separator` carries `@default '/'` in the declaration while the renderer fell
+ * through to shadcn's `ChevronRight`. Declared default and actual render
+ * DISAGREED, so honouring only the authored value would have left the docs
+ * lying about the unauthored one. The repair aligns the RENDER to the
+ * DECLARATION (`schema.separator ?? '/'`) rather than rewriting the declared
+ * default, because rewriting a published `@default` is a contract change and
+ * this card's dispatched arm is "implement the declaration", not "amend it".
+ * `theDeclaredDefault` is that assertion, kept separate from the authored-value
+ * rows so a repair that honoured one and not the other cannot read as green.
+ *
+ * ## `maxItems` and where its number lands
+ *
+ * The declaration says "Maximum items to display before collapsing", so the
+ * count of RENDERED crumbs is what the number bounds — asserted directly, not
+ * inferred from which labels survive. The collapse keeps the FIRST crumb and
+ * the LAST `maxItems - 1`, with shadcn's `BreadcrumbEllipsis` between them, so
+ * the current page (the last crumb, and the whole point of a breadcrumb) is
+ * never the thing that gets dropped. At `maxItems: 1` there is no room for both
+ * ends and the LAST one is what survives — that asymmetry is deliberate and has
+ * its own row rather than being left to the reader.
+ *
+ * ## The instrument, and why container-level svg queries are refused
+ *
+ * `BreadcrumbEllipsis` draws a lucide glyph and every crumb may draw one of its
+ * own (objectui#5931), so `container.querySelector('svg')` is green in every
+ * world and proves nothing. Rows here scope to the element they are about: the
+ * separator's own ``, or a crumb's own ``.
+ *
+ * ## Why the renderer is invoked DIRECTLY
+ *
+ * `ComponentRegistry.get(name)` returns the component the registry actually
+ * renders; driving through `SchemaRenderer` injects its own props around it and
+ * can be green in both directions (PR #4603's toggle case, restated by #4580).
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
+// cold transform is billed to `hookTimeout`. See
+// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
+import '../renderers';
+import { BreadcrumbSeparator } from '../ui/breadcrumb';
+
+afterEach(() => cleanup());
+
+function renderBreadcrumb(schema: Record) {
+ const C = ComponentRegistry.get('breadcrumb') as React.ComponentType;
+ return render();
+}
+
+/** The separator ``s — a DIFFERENT `` from any crumb's. */
+function separators(container: HTMLElement): HTMLElement[] {
+ return Array.from(container.querySelectorAll('li[role="presentation"]'));
+}
+
+/**
+ * Every CRUMB ``, which is neither of the other two kinds of `` the
+ * list holds. A separator is ``; the elision is a
+ * plain `` wrapping shadcn's ``, so "not a
+ * separator" alone counts it as a crumb and every `maxItems` count reads one
+ * too high. Both are excluded here, by the one attribute shadcn uses to mark
+ * decorative nodes.
+ */
+function crumbs(container: HTMLElement): HTMLElement[] {
+ return Array.from(container.querySelectorAll('li')).filter(
+ (li) =>
+ li.getAttribute('role') !== 'presentation' &&
+ li.querySelector('[role="presentation"]') === null,
+ );
+}
+
+const FIVE = [
+ { label: 'Home', href: '/' },
+ { label: 'Products', href: '/products' },
+ { label: 'Electronics', href: '/products/electronics' },
+ { label: 'Laptops', href: '/products/electronics/laptops' },
+ { label: 'ObjectBook Pro' },
+];
+
+describe('ui:breadcrumb separator + maxItems (objectui#6646)', () => {
+ describe('harness controls', () => {
+ it('mounts every crumb on first render — nothing here is lazy', () => {
+ const { container } = renderBreadcrumb({ items: FIVE });
+ expect(crumbs(container)).toHaveLength(5);
+ expect(screen.getByText('ObjectBook Pro')).toBeTruthy();
+ });
+
+ it('positive control on the instrument — `svg.lucide-*` is a query that works', () => {
+ // Green in BOTH worlds by construction: this renders the shadcn primitive
+ // DIRECTLY, not through `ui:breadcrumb`. It used to be asserted on the
+ // renderer's own output, which was only valid while the renderer's
+ // default separator WAS that chevron — the very thing this card changes.
+ // Kept, at the primitive, so a red `lucide-*` row below still cannot be
+ // misread as a broken selector.
+ const { container } = render();
+ expect(container.querySelector('svg.lucide-chevron-right')).not.toBeNull();
+ });
+
+ it('a separator sits between crumbs and nowhere else', () => {
+ const { container } = renderBreadcrumb({ items: FIVE });
+ expect(separators(container)).toHaveLength(4);
+ });
+ });
+
+ describe('separator — the declared default', () => {
+ it('renders the declared `@default \'/\'` when none is authored', () => {
+ // RED before the repair: every separator was shadcn's `ChevronRight`, so
+ // the declaration's `@default '/'` and the actual render DISAGREED.
+ const { container } = renderBreadcrumb({ items: FIVE });
+ for (const sep of separators(container)) {
+ expect(sep.textContent).toBe('/');
+ }
+ });
+
+ it('draws NO chevron glyph once the declared default is honoured', () => {
+ // The other half of the same alignment: a repair that appended `/` while
+ // still drawing the chevron would pass the row above and still show the
+ // author something the declaration never promised.
+ const { container } = renderBreadcrumb({ items: FIVE });
+ for (const sep of separators(container)) {
+ expect(sep.querySelector('svg')).toBeNull();
+ }
+ });
+ });
+
+ describe('separator — the authored value', () => {
+ it('renders an authored single-character separator', () => {
+ // RED before the repair: `schema.separator` was never read.
+ const { container } = renderBreadcrumb({ items: FIVE, separator: '>' });
+ const seps = separators(container);
+ expect(seps).toHaveLength(4);
+ for (const sep of seps) {
+ expect(sep.textContent).toBe('>');
+ }
+ });
+
+ it('renders an authored multi-character separator verbatim', () => {
+ const { container } = renderBreadcrumb({ items: FIVE, separator: '::' });
+ for (const sep of separators(container)) {
+ expect(sep.textContent).toBe('::');
+ }
+ });
+
+ it('an authored separator is DISTINGUISHABLE from a misspelled key', () => {
+ // This card's whole subject. Before the repair both spellings produced
+ // the identical chevron, so an author who did everything right got the
+ // feedback of someone who had typo'd the key name.
+ const correct = renderBreadcrumb({ items: FIVE, separator: '·' });
+ const correctText = separators(correct.container).map((s) => s.textContent);
+ cleanup();
+ const typo = renderBreadcrumb({ items: FIVE, seperator: '·' });
+ const typoText = separators(typo.container).map((s) => s.textContent);
+
+ expect(correctText).toEqual(['·', '·', '·', '·']);
+ expect(typoText).toEqual(['/', '/', '/', '/']);
+ expect(correctText).not.toEqual(typoText);
+ });
+
+ it('an empty-string separator is honoured, not treated as unauthored', () => {
+ // `''` is a legal `string`. A `||` fallback would silently promote it to
+ // the default; `??` is what the declaration asks for.
+ const { container } = renderBreadcrumb({ items: FIVE, separator: '' });
+ for (const sep of separators(container)) {
+ expect(sep.textContent).toBe('');
+ expect(sep.querySelector('svg')).toBeNull();
+ }
+ });
+ });
+
+ describe('maxItems — the collapse', () => {
+ it('renders at most `maxItems` crumbs', () => {
+ // RED before the repair: all five rendered and nothing collapsed.
+ const { container } = renderBreadcrumb({ items: FIVE, maxItems: 3 });
+ expect(crumbs(container)).toHaveLength(3);
+ });
+
+ it('keeps the FIRST crumb and the LAST `maxItems - 1`', () => {
+ renderBreadcrumb({ items: FIVE, maxItems: 3 });
+ expect(screen.getByText('Home')).toBeTruthy();
+ expect(screen.getByText('Laptops')).toBeTruthy();
+ expect(screen.getByText('ObjectBook Pro')).toBeTruthy();
+ expect(screen.queryByText('Products')).toBeNull();
+ expect(screen.queryByText('Electronics')).toBeNull();
+ });
+
+ it('marks the elision with shadcn\'s BreadcrumbEllipsis, not a bare gap', () => {
+ // Without this the collapse is indistinguishable from an author having
+ // shipped a shorter trail — the same "no way to tell" failure `separator`
+ // has above, one level over.
+ const { container } = renderBreadcrumb({ items: FIVE, maxItems: 3 });
+ const ellipsis = container.querySelector('li > span[role="presentation"]');
+ expect(ellipsis).not.toBeNull();
+ // shadcn's `BreadcrumbEllipsis` carries an sr-only "More" beside its
+ // glyph — the accessible half, and what makes the elision a rendered
+ // FACT rather than three crumbs quietly missing.
+ expect(ellipsis!.textContent).toContain('More');
+ expect(ellipsis!.querySelector('svg')).not.toBeNull();
+ });
+
+ it('still renders the last crumb as the current page after collapsing', () => {
+ renderBreadcrumb({ items: FIVE, maxItems: 3 });
+ expect(screen.getByText('ObjectBook Pro').closest('[aria-current="page"]')).not.toBeNull();
+ });
+
+ it('keeps the CURRENT PAGE, not the root, when `maxItems` is 1', () => {
+ // There is no room for both ends; the declaration's subject is a
+ // location trail, so the location is what survives.
+ const { container } = renderBreadcrumb({ items: FIVE, maxItems: 1 });
+ expect(crumbs(container)).toHaveLength(1);
+ expect(screen.getByText('ObjectBook Pro')).toBeTruthy();
+ expect(screen.queryByText('Home')).toBeNull();
+ });
+
+ it('separates the collapsed trail with the same authored separator', () => {
+ const { container } = renderBreadcrumb({ items: FIVE, maxItems: 3, separator: '>' });
+ const seps = separators(container);
+ expect(seps).toHaveLength(3); // first | … | Laptops | ObjectBook Pro
+ for (const sep of seps) {
+ expect(sep.textContent).toBe('>');
+ }
+ });
+
+ it('resolves a surviving crumb\'s icon through the collapse (objectui#5931)', () => {
+ const { container } = renderBreadcrumb({
+ items: [
+ { label: 'Home', href: '/', icon: 'home' },
+ { label: 'Products', href: '/products', icon: 'book' },
+ { label: 'Laptops', href: '/laptops', icon: 'book' },
+ { label: 'ObjectBook Pro', icon: 'panels-top-left' },
+ ],
+ maxItems: 2,
+ });
+ const kept = crumbs(container);
+ expect(kept).toHaveLength(2);
+ expect(kept[0].querySelector('svg.lucide-house')).not.toBeNull();
+ expect(kept[1].querySelector('svg.lucide-panels-top-left')).not.toBeNull();
+ });
+ });
+
+ describe('maxItems — when it must NOT collapse', () => {
+ it('does not collapse when the trail is shorter than `maxItems`', () => {
+ const { container } = renderBreadcrumb({ items: FIVE, maxItems: 9 });
+ expect(crumbs(container)).toHaveLength(5);
+ expect(container.querySelector('li > span[role="presentation"]')).toBeNull();
+ });
+
+ it('does not collapse when the trail is exactly `maxItems` long', () => {
+ // The boundary the declaration words as "maximum items to display":
+ // five items under `maxItems: 5` are already within the maximum.
+ const { container } = renderBreadcrumb({ items: FIVE, maxItems: 5 });
+ expect(crumbs(container)).toHaveLength(5);
+ expect(container.querySelector('li > span[role="presentation"]')).toBeNull();
+ });
+
+ it('does not collapse when `maxItems` is absent', () => {
+ const { container } = renderBreadcrumb({ items: FIVE });
+ expect(crumbs(container)).toHaveLength(5);
+ expect(container.querySelector('li > span[role="presentation"]')).toBeNull();
+ });
+
+ it('declines a nonsensical `maxItems` rather than rendering an empty trail', () => {
+ for (const bad of [0, -1, Number.NaN]) {
+ const { container } = renderBreadcrumb({ items: FIVE, maxItems: bad });
+ expect(crumbs(container), `maxItems: ${String(bad)}`).toHaveLength(5);
+ cleanup();
+ }
+ });
+ });
+});
diff --git a/packages/components/src/__tests__/header-bar-crumb-icon.test.tsx b/packages/components/src/__tests__/header-bar-crumb-icon.test.tsx
new file mode 100644
index 0000000000..a7767f37fb
--- /dev/null
+++ b/packages/components/src/__tests__/header-bar-crumb-icon.test.tsx
@@ -0,0 +1,286 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * `ui:header-bar` resolves a crumb's authored `icon` to a glyph — the SAME
+ * `BreadcrumbItem.icon` `ui:breadcrumb` resolves (objectui#6645).
+ *
+ * ## The class, and why it is a card rather than a footnote
+ *
+ * `HeaderBarSchema.crumbs` is typed `BreadcrumbItem[]` — the very interface
+ * `BreadcrumbSchema.items` uses (`packages/types/src/navigation.ts`, mirrored as
+ * `BreadcrumbItemSchema` in `packages/types/src/zod/navigation.zod.ts`). That
+ * mirror does not merely DECLARE `icon`, it DESCRIBES it
+ * (`.describe('Breadcrumb icon')`), so any authoring surface that reads Zod
+ * `describe` — designer, schema hints, generated docs — can already present the
+ * key to an author. The window in which "nobody has authored it yet" protects
+ * anyone is therefore narrower than an unauthored-fixture census suggests.
+ *
+ * `renderers/navigation/header-bar.tsx` contained ZERO occurrences of the
+ * substring `icon`: its `BreadcrumbLabel` helper read `crumb.label`,
+ * `crumb.siblings` and `crumb.href`, and nothing else.
+ *
+ * ## The asymmetry is the subject, so it is asserted DIRECTLY
+ *
+ * After objectui#5931 / PR #6644, one declared key behaved DIFFERENTLY on its
+ * two consumers: authored on a `breadcrumb` item it drew a glyph, authored on a
+ * `header-bar` crumb it drew nothing. That asymmetry was invisible only because
+ * it had been a uniform zero on both. `theTwoConsumersAgree` below renders the
+ * SAME crumb object through both renderers and compares the resolved glyph, so
+ * a future repair that drifts one of them apart from the other is red here and
+ * nowhere else.
+ *
+ * ## The resolver is the SHARED one, and that is a measurable fact
+ *
+ * The repair calls `resolveIcon` from `renderers/action/resolve-icon.ts` — the
+ * same function `ui:button`, the `action:*` family, `ui:dropdown-menu`,
+ * `ui:context-menu` and `ui:breadcrumb` route through — and NOT a local
+ * normaliser of its own. objectui#5993 is the lesson: a local copy is "the same
+ * algorithm under a different function", and an alias later added to absorb a
+ * lucide retirement reached every `action:*` site EXCEPT `ui:button`. The
+ * `home` -> `lucide-house` row below is what pins that from the outside: that
+ * indirection exists ONLY in the shared resolver's `iconNameMap`, so a local
+ * re-implementation of `toPascalCase` alone renders nothing and the row is red.
+ *
+ * ## The RECORD surface, not the lazy one
+ *
+ * Names resolve against lucide's runtime `icons` RECORD, so an unknown or
+ * RETIRED spelling renders NOTHING. `layout` is the control: a deprecated
+ * lucide export absent from the runtime record (`Layout === PanelsTopLeft` is
+ * TRUE — the retired alias is the very same object under a dead name). Its row
+ * is what rules out `LazyIcon`, which degrades an unknown name to the
+ * `Database` glyph — trading a no-icon failure for a WRONG-icon one, ruled out
+ * for authored icon fields by objectui#5622 / #5633.
+ *
+ * ## Why every row scopes to the crumb's own ``
+ *
+ * This header draws lucide glyphs that have nothing to do with any crumb:
+ * `SidebarTrigger`'s panel icon, `BreadcrumbSeparator`'s chevron between
+ * crumbs, and `ChevronDown` on a siblings dropdown. A container-level
+ * `querySelector('svg')` is green in every world — the blind instrument this
+ * suite must not use.
+ *
+ * ## The `SidebarProvider` host
+ *
+ * `SidebarTrigger` calls `useSidebar()`, which THROWS without a provider. A
+ * caught throw renders error-boundary markup that reads as an attribute-clean
+ * pass (`widget-dom-leak-sweep`'s trap 4), so the host is a precondition of
+ * measuring anything here at all — `theHarnessRendersTheRealHeader` asserts it
+ * rather than assuming it.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+// Registers the renderers at module scope, NOT inside a `beforeAll` — there the
+// cold transform is billed to `hookTimeout`. See
+// object-ui/no-dynamic-import-in-test-hook (objectui#3010/#3021).
+import '../renderers';
+import { SidebarProvider } from '../ui';
+
+afterEach(() => cleanup());
+
+function renderHeaderBar(crumbs: any[]) {
+ const C = ComponentRegistry.get('header-bar') as React.ComponentType;
+ return render(
+
+
+ ,
+ );
+}
+
+function renderBreadcrumb(items: any[]) {
+ const C = ComponentRegistry.get('breadcrumb') as React.ComponentType;
+ return render();
+}
+
+/** The crumb's own ``. Separators are DIFFERENT ``s. */
+function crumbFor(label: string): HTMLElement {
+ const el = screen.getByText(label).closest('li');
+ if (!el) throw new Error(`no ancestor for ${label}`);
+ return el as HTMLElement;
+}
+
+/** Two crumbs, so the link arm and the page arm are both exercised. */
+const TWO = (icon?: string) => [
+ { label: 'Home', href: '#', ...(icon ? { icon } : {}) },
+ { label: 'Current Page', ...(icon ? { icon } : {}) },
+];
+
+describe('ui:header-bar crumb icon resolution (objectui#6645)', () => {
+ describe('harness controls', () => {
+ it('theHarnessRendersTheRealHeader — not error-boundary markup', () => {
+ // `useSidebar()` throws without `SidebarProvider`, and the caught throw
+ // is clean markup that would make every "renders no glyph" row below pass
+ // for the wrong reason.
+ const { container } = renderHeaderBar(TWO());
+ expect(container.querySelector('header')).not.toBeNull();
+ expect(crumbFor('Home')).toBeTruthy();
+ expect(crumbFor('Current Page')).toBeTruthy();
+ });
+
+ it('both arms are the ones the renderer is documented to produce', () => {
+ renderHeaderBar(TWO());
+ expect(screen.getByText('Home').closest('a')).not.toBeNull();
+ expect(screen.getByText('Current Page').closest('[aria-current="page"]')).not.toBeNull();
+ });
+
+ it('positive control on the instrument — foreign glyphs ARE present, outside every crumb', () => {
+ // Green in both worlds BY DESIGN. It exists so a red `lucide-*` row
+ // cannot be misread as a broken query, and it is the reason no row here
+ // may query at container level.
+ const { container } = renderHeaderBar(TWO());
+ expect(container.querySelector('svg')).not.toBeNull();
+ expect(crumbFor('Home').querySelector('svg')).toBeNull();
+ expect(crumbFor('Current Page').querySelector('svg')).toBeNull();
+ });
+ });
+
+ describe('BreadcrumbLink arm (every crumb but the last)', () => {
+ it('renders the resolved glyph for a live icon name', () => {
+ // RED before the repair: the crumb contained no svg whatsoever.
+ renderHeaderBar([{ label: 'Projects', href: '#', icon: 'book' }, { label: 'Web App' }]);
+ expect(crumbFor('Projects').querySelector('svg.lucide-book')).not.toBeNull();
+ });
+
+ it('renders no glyph for a RETIRED spelling — the RECORD surface, not a fallback', () => {
+ renderHeaderBar([{ label: 'Projects', href: '#', icon: 'layout' }, { label: 'Web App' }]);
+ expect(crumbFor('Projects').querySelector('svg')).toBeNull();
+ });
+
+ it('renders no glyph for an UNKNOWN name', () => {
+ renderHeaderBar([{ label: 'Projects', href: '#', icon: 'not-a-real-icon' }, { label: 'Web App' }]);
+ expect(crumbFor('Projects').querySelector('svg')).toBeNull();
+ });
+ });
+
+ describe('BreadcrumbPage arm (the last crumb)', () => {
+ it('renders the resolved glyph for a live icon name', () => {
+ // RED before the repair, and red again if only the link arm were fixed —
+ // "a narrower version of the same bug" (objectui#5930).
+ renderHeaderBar([{ label: 'Projects', href: '#' }, { label: 'Web App', icon: 'panels-top-left' }]);
+ expect(crumbFor('Web App').querySelector('svg.lucide-panels-top-left')).not.toBeNull();
+ });
+
+ it('renders no glyph when no icon is authored', () => {
+ renderHeaderBar(TWO());
+ expect(crumbFor('Current Page').querySelector('svg')).toBeNull();
+ });
+ });
+
+ describe('the siblings-dropdown arm — this renderer\'s THIRD arm', () => {
+ // `BreadcrumbLabel` branches on `crumb.siblings` BEFORE it branches on
+ // `isLast`, so a repair written inside that helper's leaf arms would miss
+ // this one. `ui:breadcrumb` has no such arm; it is the one shape this card
+ // cannot inherit from PR #6644 and must measure for itself.
+ it('renders the resolved glyph beside a crumb that opens a dropdown', () => {
+ renderHeaderBar([
+ {
+ label: 'Accounts',
+ href: '#',
+ icon: 'book',
+ siblings: [{ label: 'Contacts', href: '#contacts' }],
+ },
+ { label: 'Acme Inc' },
+ ]);
+ expect(crumbFor('Accounts').querySelector('svg.lucide-book')).not.toBeNull();
+ });
+
+ it('and the dropdown\'s own chevron is still there — the glyph did not replace it', () => {
+ renderHeaderBar([
+ {
+ label: 'Accounts',
+ href: '#',
+ icon: 'book',
+ siblings: [{ label: 'Contacts', href: '#contacts' }],
+ },
+ { label: 'Acme Inc' },
+ ]);
+ expect(crumbFor('Accounts').querySelector('svg.lucide-chevron-down')).not.toBeNull();
+ });
+ });
+
+ describe('the shared resolver, pinned from the outside', () => {
+ it('routes `home` to lucide\'s `House` — an indirection only the SHARED resolver has', () => {
+ // `resolveIcon`'s `iconNameMap` is the only place this rename lives. A
+ // local `toPascalCase` copy in `header-bar.tsx` would look for `Home`,
+ // find nothing in the record, and render no glyph — which is exactly the
+ // objectui#5993 failure this row exists to refuse.
+ renderHeaderBar([{ label: 'Home', href: '#', icon: 'home' }, { label: 'Current Page' }]);
+ expect(crumbFor('Home').querySelector('svg.lucide-house')).not.toBeNull();
+ });
+
+ it('draws the glyph and not the word', () => {
+ // The regression guard against acquiring objectui#5930's defect —
+ // printing the authored name as a text node. Not discriminating on its
+ // own (this renderer never printed it), and a glyph-presence assertion
+ // cannot see it.
+ renderHeaderBar([{ label: 'Projects', href: '#', icon: 'book' }, { label: 'Web App' }]);
+ expect(crumbFor('Projects').querySelector('svg.lucide-book')).not.toBeNull();
+ expect(screen.queryByText('book')).toBeNull();
+ });
+ });
+
+ describe('theTwoConsumersAgree — the asymmetry this card is about', () => {
+ it('the same authored crumb draws the same glyph on `ui:breadcrumb` and `ui:header-bar`', () => {
+ // ONE object, both consumers. Before the repair the breadcrumb side drew
+ // `lucide-book` and the header-bar side drew nothing — one declared key,
+ // two behaviours.
+ const CRUMB = { label: 'Docs', href: '#', icon: 'book' };
+ const TAIL = { label: 'Components' };
+
+ renderBreadcrumb([CRUMB, TAIL]);
+ const onBreadcrumb = crumbFor('Docs').querySelector('svg')?.getAttribute('class') ?? null;
+ cleanup();
+
+ renderHeaderBar([CRUMB, TAIL]);
+ const onHeaderBar = crumbFor('Docs').querySelector('svg')?.getAttribute('class') ?? null;
+
+ expect(onBreadcrumb).not.toBeNull();
+ expect(onHeaderBar).not.toBeNull();
+ expect(onHeaderBar).toBe(onBreadcrumb);
+ });
+
+ it('…and they agree on a RETIRED spelling too — both draw nothing', () => {
+ // The agreement must hold in the negative direction as well, or a
+ // header-bar that quietly used `LazyIcon` would satisfy the row above
+ // while diverging on exactly the names that matter.
+ const CRUMB = { label: 'Docs', href: '#', icon: 'layout' };
+ const TAIL = { label: 'Components' };
+
+ renderBreadcrumb([CRUMB, TAIL]);
+ expect(crumbFor('Docs').querySelector('svg')).toBeNull();
+ cleanup();
+
+ renderHeaderBar([CRUMB, TAIL]);
+ expect(crumbFor('Docs').querySelector('svg')).toBeNull();
+ });
+ });
+
+ describe('the crumbs-with-icons catalog fixture', () => {
+ // The fixture is a live specimen AND a declared AI few-shot retrieval
+ // source, so every name it ships must actually draw.
+ it('draws a glyph for every icon name it declares', () => {
+ renderHeaderBar([
+ { label: 'Home', href: '#', icon: 'home' },
+ { label: 'Settings', href: '#', icon: 'settings' },
+ { label: 'Profile', icon: 'user' },
+ ]);
+ for (const [label, glyph] of [
+ ['Home', 'lucide-house'],
+ ['Settings', 'lucide-settings'],
+ ['Profile', 'lucide-user'],
+ ]) {
+ expect(
+ crumbFor(label).querySelector(`svg.${glyph}`),
+ `${label} should draw ${glyph}`,
+ ).not.toBeNull();
+ }
+ });
+ });
+});
diff --git a/packages/components/src/renderers/data-display/breadcrumb.tsx b/packages/components/src/renderers/data-display/breadcrumb.tsx
index b219891b08..fe2d3c065b 100644
--- a/packages/components/src/renderers/data-display/breadcrumb.tsx
+++ b/packages/components/src/renderers/data-display/breadcrumb.tsx
@@ -7,8 +7,8 @@
*/
import { ComponentRegistry } from '@object-ui/core';
-import type { BreadcrumbSchema } from '@object-ui/types';
-import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from '../../ui/breadcrumb';
+import type { BreadcrumbSchema, BreadcrumbItem as BreadcrumbItemType } from '@object-ui/types';
+import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbEllipsis } from '../../ui/breadcrumb';
import { renderChildren } from '../../lib/utils';
import { resolveKeyedI18nLabel } from '@object-ui/react';
// `BreadcrumbItem.icon` is an authored lucide NAME that this renderer never
@@ -24,6 +24,57 @@ import { resolveKeyedI18nLabel } from '@object-ui/react';
// authored icon fields by objectui#5622 and #5633.
import { resolveIcon } from '../action/resolve-icon';
+/**
+ * `BreadcrumbSchema` declares FOUR authorable keys and this renderer used to
+ * read one of them. `icon` was repaired by objectui#5931; `separator` and
+ * `maxItems` are objectui#6646, and the two below are what honour them.
+ *
+ * ⚠️ The declaration this file consumes is `packages/types/src/navigation.ts`'s
+ * `BreadcrumbSchema` — the one `packages/types/src/index.ts` re-exports, and
+ * the ONLY one carrying `maxItems`. A SECOND exported interface of the same
+ * name lives in `packages/types/src/data-display.ts` (reached only through the
+ * `DataDisplaySchema` union) and declares neither `maxItems` nor `icon`.
+ * TypeScript warns about neither, so "I changed `BreadcrumbSchema`" is not a
+ * statement that identifies a file. Both are recorded in
+ * `scripts/__tests__/one-authority-per-exported-name-6273.test.ts`'s
+ * `KNOWN_COLLISIONS`.
+ */
+
+/** The declared `@default '/'` of `BreadcrumbSchema.separator`. */
+const DEFAULT_SEPARATOR = '/';
+
+/** One rendered position in the trail: a crumb, or the elision standing in for several. */
+type Slot = { kind: 'item'; item: BreadcrumbItemType } | { kind: 'ellipsis' };
+
+/**
+ * Apply `maxItems` — "Maximum items to display before collapsing".
+ *
+ * The number bounds the count of RENDERED crumbs, so the result is exactly
+ * `maxItems` of them (plus the elision marker, which is not a crumb). The FIRST
+ * crumb and the LAST `maxItems - 1` survive: a breadcrumb's subject is the
+ * current location, so the final crumb is the one thing a collapse must never
+ * drop. At `maxItems: 1` there is no room for both ends and the current page is
+ * what stays.
+ *
+ * A `maxItems` that cannot mean a count — absent, non-finite, below 1 — is
+ * DECLINED rather than coerced: the alternative is rendering an empty or
+ * arbitrarily truncated trail from a value the author most likely mistyped, and
+ * silently inventing a trail is worse than ignoring the key.
+ */
+function collapseTrail(items: BreadcrumbItemType[], maxItems: unknown): Slot[] {
+ const all: Slot[] = items.map((item) => ({ kind: 'item', item }));
+ if (typeof maxItems !== 'number' || !Number.isFinite(maxItems)) return all;
+ const max = Math.floor(maxItems);
+ if (max < 1 || items.length <= max) return all;
+ const head = Math.min(1, max - 1);
+ const tail = max - head;
+ return [
+ ...items.slice(0, head).map((item): Slot => ({ kind: 'item', item })),
+ { kind: 'ellipsis' },
+ ...items.slice(items.length - tail).map((item): Slot => ({ kind: 'item', item })),
+ ];
+}
+
ComponentRegistry.register('breadcrumb',
({ schema, ...props }: { schema: BreadcrumbSchema; [key: string]: any }) => {
const {
@@ -32,7 +83,13 @@ ComponentRegistry.register('breadcrumb',
style,
...breadcrumbProps
} = props;
-
+
+ // `??`, not `||`: `''` is a legal `string` and an author who writes it means
+ // "no visible separator". A `||` here would silently promote that to `'/'`,
+ // which is the same declared-key-does-something-else defect one value over.
+ const separator = schema.separator ?? DEFAULT_SEPARATOR;
+ const slots = collapseTrail(schema.items ?? [], schema.maxItems);
+
return (
- {schema.items?.map((item, idx) => {
- const isLast = idx === (schema.items?.length || 0) - 1;
+ {slots.map((slot, idx) => {
+ const isLast = idx === slots.length - 1;
+ // The separator is authored ONCE and rendered at every position, so
+ // a collapsed trail is separated exactly like an uncollapsed one.
+ const trailing = !isLast && {separator};
+
+ if (slot.kind === 'ellipsis') {
+ return (
+
+
+
+
+ {trailing}
+
+ );
+ }
+
+ const item = slot.item;
// Resolved ONCE per item and rendered ABOVE the page/link split, so
// BOTH arms carry it by construction. Repairing only the leaf arm
// would be "a narrower version of the same bug" (objectui#5930) —
@@ -58,7 +131,7 @@ ComponentRegistry.register('breadcrumb',
{resolveKeyedI18nLabel(item.label) ?? ''}
)}
- {!isLast && }
+ {trailing}
);
})}
@@ -70,7 +143,9 @@ ComponentRegistry.register('breadcrumb',
namespace: 'ui',
label: 'Breadcrumb',
inputs: [
- { name: 'className', type: 'string', label: 'CSS Class' }
+ { name: 'className', type: 'string', label: 'CSS Class' },
+ { name: 'separator', type: 'string', label: 'Separator' },
+ { name: 'maxItems', type: 'number', label: 'Max Items Before Collapsing' }
],
defaultProps: {
items: [
diff --git a/packages/components/src/renderers/navigation/header-bar.tsx b/packages/components/src/renderers/navigation/header-bar.tsx
index ff31f91b01..7f47dac7ae 100644
--- a/packages/components/src/renderers/navigation/header-bar.tsx
+++ b/packages/components/src/renderers/navigation/header-bar.tsx
@@ -26,6 +26,21 @@ import {
Input,
} from '../../ui';
import { ChevronDown, Search } from 'lucide-react';
+// `crumbs` is typed `BreadcrumbItem[]` — the SAME declaration `ui:breadcrumb`'s
+// `items` uses — and `BreadcrumbItem.icon` is not merely declared but DESCRIBED
+// in the zod mirror (`icon: z.string().optional().describe('Breadcrumb icon')`),
+// so an authoring surface that reads `describe` can already offer the key. This
+// renderer read `label`, `siblings` and `href` and nothing else, so after
+// objectui#5931 repaired the breadcrumb side one declared key behaved
+// DIFFERENTLY on its two consumers — a glyph there, nothing here (objectui#6645).
+//
+// ⛔ Through the SHARED `resolveIcon`, never a local normaliser. objectui#5993
+// is the lesson: a local copy is the same algorithm under a different function,
+// and the alias later added there to absorb a lucide retirement reached every
+// `action:*` site EXCEPT `ui:button`. Routing here means the RECORD surface —
+// an unknown or RETIRED spelling renders NOTHING, never `LazyIcon`'s `Database`
+// fallback (ruled out for authored icon fields by objectui#5622 / #5633).
+import { resolveIcon } from '../action/resolve-icon';
function BreadcrumbLabel({ crumb, isLast }: { crumb: BreadcrumbItemType; isLast: boolean }) {
const label = resolveKeyedI18nLabel(crumb.label) ?? '';
@@ -65,14 +80,28 @@ ComponentRegistry.register('header-bar',
- {schema.crumbs?.map((crumb: BreadcrumbItemType, idx: number) => (
-
-
-
-
- {idx < schema.crumbs!.length - 1 && }
-
- ))}
+ {schema.crumbs?.map((crumb: BreadcrumbItemType, idx: number) => {
+ // Resolved ONCE per crumb and rendered ABOVE `BreadcrumbLabel`, so
+ // all THREE of its arms — the siblings dropdown, the last crumb's
+ // `BreadcrumbPage` and every earlier `BreadcrumbLink` — carry the
+ // glyph by construction. Repairing inside that helper would have had
+ // to touch each arm, and missing one is "a narrower version of the
+ // same bug" (objectui#5930). Resolved HERE rather than in a
+ // `CrumbIcon` helper for the same reason `breadcrumb.tsx` resolves
+ // inline: a component value produced during render and rendered from
+ // a nested component is what `react-hooks/static-components`
+ // refuses, and this is the shape the sibling renderer already uses.
+ const Icon = resolveIcon(crumb.icon);
+ return (
+
+
+ {Icon && }
+
+
+ {idx < schema.crumbs!.length - 1 && }
+
+ );
+ })}
diff --git a/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs
index 567000b120..bd9b70f3b6 100644
--- a/scripts/check-lucide-icon-record-names.mjs
+++ b/scripts/check-lucide-icon-record-names.mjs
@@ -107,6 +107,29 @@
* tree-view 30 read, but as a TWO-VALUED literal switch
* (`node.icon === 'folder'`) — never a record lookup
*
+ * ── An EIGHTH container, added by objectui#6645 ─────────────────────────
+ * The row above is what this table's own rule predicts: a verdict is a fact
+ * about a renderer, so it expires when that renderer is repaired.
+ *
+ * header-bar 3 RECORD, since objectui#6645 — `resolveIcon(crumb.icon)`
+ * in `renderers/navigation/header-bar.tsx`, resolved
+ * once per crumb ABOVE all three arms of its
+ * `BreadcrumbLabel` helper. JUDGED HERE.
+ *
+ * Until that repair this container reached NO resolver and declining its
+ * names was right — the same sequence `context-menu` went through in
+ * objectui#6278, and for the same reason: a census entry declares that a
+ * type's names REACH a vocabulary, and until the repair landed they reached
+ * nothing. ⚠️ It is NOT objectui#5992's blind spot, which is this gate
+ * GUESSING at containers nobody read off a renderer; this row was read off
+ * the renderer, like every other row here.
+ *
+ * The three names are the ones `crumbs-with-icons.json` authors, which the
+ * same card added — before it the container authored none. Re-measured over
+ * `examples/schema-catalog/` with the walk described above: 64 untyped names
+ * across EIGHT containers, and the seven figures above re-measure
+ * IDENTICALLY (61 + 3). The scan-root figure below moves by the same 3.
+ *
* `dropdown-menu.tsx` itself is correctly ABSENT from part 1's census: it
* imports `resolveIcon`, not `icons`, so the record read happens in
* `renderers/action/resolve-icon.ts`, which is already declared. Part 1
@@ -126,7 +149,8 @@
* ⚠️ SCOPE, which the count above does not carry on its face: it is
* measured over `examples/schema-catalog/`, while this gate SCANS
* `packages/`, `apps/` and `examples/`. Over the full scan roots the same
- * walk finds 76 untyped names across NINE containers — the extra 15 all in
+ * walk found 76 untyped names across NINE containers (79 across TEN since
+ * objectui#6645 — the same +3 in the same one container) — the extra 15 all in
* `packages/types/examples/` (`tree-view` +6, `timeline` +3, plus `list` 3
* and `sidebar` 3, two containers this table does not name at all). Both
* extra containers were read: `data-display/list.tsx` and
@@ -284,6 +308,31 @@ export const RECORD_READING_TYPES = {
min: 1,
resolver: 'packages/components/src/renderers/action/resolve-icon.ts (via renderers/overlay/dropdown-menu.tsx)',
},
+ // objectui#6645 — `header-bar` crumbs, and the same shape and route as the
+ // `context-menu` entry above. The container's OWN `icon` is never read
+ // (`paths: []`); the names sit on untyped children at `crumbs[].icon`, and
+ // every one of them goes through the single `resolveIcon(crumb.icon)` call in
+ // `CrumbIcon`, which renders ABOVE all three arms of `BreadcrumbLabel` (the
+ // siblings dropdown, the last crumb's `BreadcrumbPage`, and every earlier
+ // `BreadcrumbLink`) — so no arm can be forgotten and no arm needs its own
+ // path.
+ //
+ // This entry could not have existed before that repair: until it landed the
+ // renderer contained ZERO occurrences of `icon`, so the names reached nothing
+ // and DECLINING them was correct — which is exactly what objectui#6645's
+ // triage recorded, and exactly the sequence `context-menu` went through in
+ // objectui#6278. ⚠️ Adding it is therefore not objectui#5992's blind spot
+ // (this gate GUESSING at a container nobody read off a renderer); it is this
+ // table's own rule, that a row's verdict expires when its renderer changes.
+ //
+ // `min` is the MEASURED count — the three names `crumbs-with-icons.json`
+ // authors, the fixture the same card added.
+ 'header-bar': {
+ paths: [],
+ descendants: true,
+ min: 3,
+ resolver: 'packages/components/src/renderers/action/resolve-icon.ts (via renderers/navigation/header-bar.tsx)',
+ },
// `ui:icon` — the node type whose WHOLE job is naming a glyph. It could not
// have been censused before objectui#5631: that renderer named its glyph with
// `name`, the SDUI IDENTITY key, and every path in this table is an `icon`