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
13 changes: 13 additions & 0 deletions .changeset/tidy-donkeys-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@object-ui/components": patch
---

`ui:breadcrumb` and `ui:command` now resolve a child item's authored `icon` to a glyph instead of drawing nothing.

`BreadcrumbItem.icon` and `CommandItem.icon` are declared keys of the protocol — present in `@object-ui/types`, mirrored in the Zod schemas, and documented on both components' pages — and neither renderer referenced `icon` at all. The catalog fixture literally named `components-data-display-breadcrumb/with-icons` rendered no icons, and the nine names the two `command` fixtures declare all drew nothing. Both names are now resolved through `resolveIcon`, the same lucide **record** surface `ui:button`, `action:*`, `ui:dropdown-menu` and `ui:context-menu` resolve against: a live name draws its glyph, and an unknown or retired spelling draws nothing rather than degrading to a wrong glyph.

`breadcrumb` resolves once per item and draws the glyph above the page/link split, so the last crumb (rendered as the current page) carries it as well as the linked crumbs before it.

Two fixtures declared retired lucide spellings that are absent from the runtime `icons` record and would therefore have drawn nothing: `components-data-display-breadcrumb/with-icons` declared `layout`, now `panels-top-left`, and `components-form-command/command-menu` declared `smile`, now `face-slightly-smiling` — in both cases the live key the retired export resolves to by identity.

`ui:button-group` is NOT included: its fixtures author `icon` on `buttons[]`, but `ButtonGroupButton` declares no such key in `@object-ui/types` or its Zod mirror, so wiring it would first mean widening the published item contract. Routed for a decision rather than chosen (objectui#5931).
11 changes: 10 additions & 1 deletion content/docs/components/data-display/breadcrumb.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,22 @@ The Breadcrumb component shows the current page's location within the site hiera

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

## Icons

An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's
runtime `icons` record — the same surface `ui:button` and the `action:*` family
resolve against. 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. The glyph is drawn for every crumb: the
final one (rendered as the current page) as well as the linked ones before it.

## Schema

```plaintext
interface BreadcrumbItem {
label: string;
href?: string;
icon?: string;
icon?: string; // kebab-case Lucide icon name (e.g. "panels-top-left")
}

interface BreadcrumbSchema {
Expand Down
10 changes: 9 additions & 1 deletion content/docs/components/form/command.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@ The Command component provides a fast, searchable command menu with keyboard nav

<SchemaExample id="components-form-command/command-menu" />

## Icons

An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's
runtime `icons` record — the same surface `ui:button` and the `action:*` family
resolve against. A name that is not a live key of that record (an unknown, or a
retired spelling such as `smile`) renders **no glyph**, never a fallback glyph
and never the literal name as text.

## Schema

```plaintext
interface CommandItem {
value: string;
label: string;
icon?: string;
icon?: string; // kebab-case Lucide icon name (e.g. "face-slightly-smiling")
}

interface CommandGroup {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
},
{
"label": "Components",
"icon": "layout"
"icon": "panels-top-left"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
{
"value": "search",
"label": "Search Emoji",
"icon": "smile"
"icon": "face-slightly-smiling"
},
{
"value": "calculator",
Expand Down
221 changes: 221 additions & 0 deletions packages/components/src/__tests__/breadcrumb-item-icon.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/**
* 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` resolves an item's authored `icon` to a glyph (objectui#5931).
*
* `BreadcrumbItem.icon` has been a declared, published key of the protocol
* (`packages/types/src/navigation.ts`, mirrored in `zod/navigation.zod.ts`,
* documented in `content/docs/components/data-display/breadcrumb.mdx`) and is
* authored by the catalog fixture literally named `with-icons.json` — while
* `renderers/data-display/breadcrumb.tsx` contained ZERO occurrences of `icon`.
* The page named "With Icons" rendered none.
*
* The sibling repairs are objectui#5930 (`dropdown-menu`) and objectui#6278
* (`context-menu`); this suite is that shape ported, with the differences below
* made explicit rather than copied over silently.
*
* ## Difference 1 — the defect is an ABSENT render, not a WRONG one
*
* `dropdown-menu` rendered the authored string into a text node, so its
* load-bearing assertion could be `queryByText('copy')` — the word was on
* screen. This renderer never referenced `icon` at all, so it drew NOTHING and
* `queryByText(name)` is null both before and after the repair. The
* DISCRIMINATING assertion here is therefore the presence of the RESOLVED
* GLYPH, and that is what every measurement row asserts. The text direction is
* still asserted, once, as a REGRESSION guard: it is the exact defect
* `dropdown-menu` shipped, and a future "repair" that printed the name instead
* of resolving it would be caught by it and by nothing else here.
*
* ## Difference 2 — nothing here mounts lazily, and that is a MEASUREMENT
*
* The twins pass `defaultOpen: true` / fire a `contextmenu` event because Radix
* mounts their content lazily; without it those suites render an empty
* container and prove nothing. `ui/breadcrumb.tsx` is plain `nav`/`ol`/`li`
* markup with no Radix root and no open state at all, so its items are in the
* tree on first render. The harness control below asserts that rather than
* assuming it — a vacuous container would make every "renders no glyph" row
* pass for the wrong reason.
*
* ## Difference 3 — BOTH arms, and where the icon sits relative to them
*
* The renderer splits on position: the LAST crumb is a `BreadcrumbPage`
* (`<span role="link" aria-current="page">`), every earlier one a
* `BreadcrumbLink` (`<a>`). Repairing only one would be "a narrower version of
* the same bug" (objectui#5930), so both are measured as their own rows. The
* fix resolves once per item and renders the glyph ABOVE that split, inside
* `BreadcrumbItem`, so neither arm can be forgotten.
*
* ## 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
* genuinely absent.
*
* ## Why lucide is NOT mocked
*
* The contract under test is "the authored name is resolved against lucide's
* runtime `icons` RECORD". Mocking the record would delete the half that
* matters: that a RETIRED spelling resolves to NOTHING rather than degrading to
* a wrong glyph. `layout` is that control — a deprecated lucide export whose
* key is absent from the runtime record (measured on lucide-react 1.31.0, 1767
* keys; `Layout === PanelsTopLeft` is TRUE, the retired alias is the very same
* object under a dead name). It is also the spelling this fixture shipped, now
* corrected to `panels-top-left`. That row is what rules out the `LazyIcon`
* surface, which would degrade `layout` to the `Database` glyph.
*
* ## 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).
*
* ## What this file does NOT own
*
* Fixture spelling drift. `with-icons.json`'s names are judged on every run by
* `scripts/check-lucide-icon-record-names.mjs`, whose census this card's PR
* extends with a `'breadcrumb'` entry. This suite pins the RENDERER; the gate
* pins the SPELLINGS.
*/

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';

afterEach(() => cleanup());

function renderCrumbs(items: any[]) {
const C = ComponentRegistry.get('breadcrumb') as React.ComponentType<any>;
return render(<C schema={{ type: 'breadcrumb', items }} />);
}

/** The crumb's own `<li>` (`BreadcrumbItem`). The separator is a DIFFERENT `<li>`. */
function crumbFor(label: string): HTMLElement {
const el = screen.getByText(label).closest('li');
if (!el) throw new Error(`no <li> 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: 'Docs', ...(icon ? { icon } : {}) },
];

describe('ui:breadcrumb item icon resolution (objectui#5931)', () => {
describe('harness controls', () => {
it('mounts every crumb on first render — nothing here is lazy', () => {
renderCrumbs(TWO());
expect(crumbFor('Home')).toBeTruthy();
expect(crumbFor('Docs')).toBeTruthy();
});

it('both arms are the ones the renderer is documented to produce', () => {
renderCrumbs(TWO());
// Non-last -> BreadcrumbLink (<a>); last -> BreadcrumbPage (aria-current).
expect(screen.getByText('Home').closest('a')).not.toBeNull();
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());
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.
expect(crumbFor('Home').querySelector('svg')).toBeNull();
});
});

describe('BreadcrumbLink arm (every crumb but the last)', () => {
it('renders the resolved glyph for a live icon name', () => {
renderCrumbs([{ label: 'Docs', href: '/docs', icon: 'book' }, { label: 'Components' }]);
// RED before the repair: the crumb contained no svg whatsoever.
expect(crumbFor('Docs').querySelector('svg.lucide-book')).not.toBeNull();
});

it('renders no glyph for a RETIRED spelling — the RECORD surface, not a fallback', () => {
// Rules out `LazyIcon`, which degrades an unknown name to `Database`.
renderCrumbs([{ label: 'Docs', href: '/docs', icon: 'layout' }, { label: 'Components' }]);
expect(crumbFor('Docs').querySelector('svg')).toBeNull();
});

it('renders no glyph for an UNKNOWN name', () => {
renderCrumbs([{ label: 'Docs', href: '/docs', icon: 'not-a-real-icon' }, { label: 'Components' }]);
expect(crumbFor('Docs').querySelector('svg')).toBeNull();
});
});

describe('BreadcrumbPage arm (the last crumb)', () => {
it('renders the resolved glyph for a live icon name', () => {
renderCrumbs([{ label: 'Docs', href: '/docs' }, { label: 'Components', icon: 'panels-top-left' }]);
// RED before the repair, and red again if only the link arm were fixed.
expect(crumbFor('Components').querySelector('svg.lucide-panels-top-left')).not.toBeNull();
});

it('renders no glyph for a RETIRED spelling', () => {
renderCrumbs([{ label: 'Docs', href: '/docs' }, { label: 'Components', icon: 'layout' }]);
expect(crumbFor('Components').querySelector('svg')).toBeNull();
});

it('renders no glyph when no icon is authored', () => {
renderCrumbs(TWO());
expect(crumbFor('Docs').querySelector('svg')).toBeNull();
});
});

describe('the authored NAME never reaches the DOM as text', () => {
// Not discriminating here — this renderer never printed the name, so this
// is null in both worlds. It is the regression guard against acquiring
// `dropdown-menu`'s defect (objectui#5930), which is the one failure mode a
// glyph-presence assertion cannot see.
it('draws the glyph and not the word', () => {
renderCrumbs([{ label: 'Docs', href: '/docs', icon: 'book' }, { label: 'Components', icon: 'panels-top-left' }]);
expect(crumbFor('Docs').querySelector('svg.lucide-book')).not.toBeNull();
expect(screen.queryByText('book')).toBeNull();
expect(screen.queryByText('panels-top-left')).toBeNull();
});
});

describe('the with-icons.json catalog fixture', () => {
// The fixture is a live specimen AND a declared AI few-shot retrieval
// source, so every name it ships must actually draw. `panels-top-left` is
// the identity-derived live key for the retired `layout` it carried.
it('draws a glyph for every icon name it declares', () => {
renderCrumbs([
{ label: 'Home', href: '/', icon: 'home' },
{ label: 'Docs', href: '/docs', icon: 'book' },
{ label: 'Components', icon: 'panels-top-left' },
]);
for (const [label, glyph] of [
// `home` is the ONE authored name whose glyph class is not its own
// spelling: `resolveIcon`'s `iconNameMap` sends it to lucide's `House`.
// Asserting the resolved class pins that indirection from the outside.
['Home', 'lucide-house'],
['Docs', 'lucide-book'],
['Components', 'lucide-panels-top-left'],
]) {
expect(
crumbFor(label).querySelector(`svg.${glyph}`),
`${label} should draw ${glyph}`,
).not.toBeNull();
}
});
});
});
Loading
Loading