Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/6645-header-bar-crumb-icon.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions .changeset/6646-breadcrumb-separator-max-items.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 24 additions & 1 deletion content/docs/components/data-display/breadcrumb.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,28 @@ The Breadcrumb component shows the current page's location within the site hiera

<SchemaExample id="components-data-display-breadcrumb/with-icons" />

## Separator

An item trail is joined by `separator`, a plain string. Leave it unauthored and
the trail separates with the declared default, `/`:

<SchemaExample id="components-data-display-breadcrumb/custom-separator" />

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:

<SchemaExample id="components-data-display-breadcrumb/collapsed-trail" />

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
Expand All @@ -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;
}
```
21 changes: 19 additions & 2 deletions content/docs/components/navigation/header-bar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,35 @@ The Header Bar component provides a standard application header with sidebar tri

<SchemaExample id="components-navigation-header-bar/deep-navigation" />

## 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.

<SchemaExample id="components-navigation-header-bar/crumbs-with-icons" />

`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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions examples/schema-catalog/src/catalog-meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
33 changes: 33 additions & 0 deletions examples/schema-catalog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand Down Expand Up @@ -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' };
Expand Down Expand Up @@ -1320,6 +1323,26 @@ const REGISTRY: Record<string, Example> = {
},
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: {
Expand Down Expand Up @@ -2870,6 +2893,16 @@ const REGISTRY: Record<string, Example> = {
},
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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"type": "breadcrumb",
"separator": ">",
"items": [
{
"label": "Home",
"href": "/"
},
{
"label": "Products",
"href": "/products"
},
{
"label": "Category",
"href": "/products/category"
},
{
"label": "Item"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"type": "header-bar",
"crumbs": [
{
"label": "Home",
"href": "#",
"icon": "home"
},
{
"label": "Settings",
"href": "#",
"icon": "settings"
},
{
"label": "Profile",
"icon": "user"
}
]
}
44 changes: 30 additions & 14 deletions packages/components/src/__tests__/breadcrumb-item-icon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,23 @@
*
* ## The instrument's positive control
*
* `BreadcrumbSeparator` always draws a `ChevronRight`, in a SIBLING `<li>`. 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 `<li>` and names the glyph by the class lucide derives from the
* icon's own identity (`svg.lucide-<key>`), 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 `<li>` and names the glyph by the class
* lucide derives from the icon's own identity (`svg.lucide-<key>`). 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 `<li>`.
* 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
Expand Down Expand Up @@ -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());

Expand Down Expand Up @@ -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(<BreadcrumbSeparator />);
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 <li> 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 <li> and stay discriminating because nothing else puts an
// svg there.
renderCrumbs(TWO());
expect(crumbFor('Home').querySelector('svg')).toBeNull();
});
});
Expand Down
Loading
Loading