From e1d51d4da447d9179c147a10445e425f9c7b61a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 01:06:33 +0000 Subject: [PATCH] feat(components): honour BreadcrumbSchema separator/maxItems and header-bar crumb icons --- .changeset/6645-header-bar-crumb-icon.md | 42 +++ .../6646-breadcrumb-separator-max-items.md | 40 +++ .../components/data-display/breadcrumb.mdx | 25 +- .../docs/components/navigation/header-bar.mdx | 21 +- examples/schema-catalog/src/catalog-meta.json | 27 ++ examples/schema-catalog/src/index.ts | 33 ++ .../collapsed-trail.json | 25 ++ .../custom-separator.json | 21 ++ .../crumbs-with-icons.json | 19 ++ .../__tests__/breadcrumb-item-icon.test.tsx | 44 ++- .../breadcrumb-separator-max-items.test.tsx | 302 ++++++++++++++++++ .../__tests__/header-bar-crumb-icon.test.tsx | 286 +++++++++++++++++ .../src/renderers/data-display/breadcrumb.tsx | 89 +++++- .../src/renderers/navigation/header-bar.tsx | 45 ++- scripts/check-lucide-icon-record-names.mjs | 51 ++- 15 files changed, 1037 insertions(+), 33 deletions(-) create mode 100644 .changeset/6645-header-bar-crumb-icon.md create mode 100644 .changeset/6646-breadcrumb-separator-max-items.md create mode 100644 examples/schema-catalog/src/schemas/components-data-display-breadcrumb/collapsed-trail.json create mode 100644 examples/schema-catalog/src/schemas/components-data-display-breadcrumb/custom-separator.json create mode 100644 examples/schema-catalog/src/schemas/components-navigation-header-bar/crumbs-with-icons.json create mode 100644 packages/components/src/__tests__/breadcrumb-separator-max-items.test.tsx create mode 100644 packages/components/src/__tests__/header-bar-crumb-icon.test.tsx 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`