From ad54f7587b56570bdce248fc83e46117e32c2de9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 12:30:48 +0000 Subject: [PATCH] fix(components): resolve ui:context-menu item icons on both arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderContextMenuItems` never referenced `icon` at all — 0 occurrences in the file, against 2 for `shortcut` and 12 for `label` — so the catalog fixture `basic-context-menu.json` shipped four live lucide names (`copy`, `scissors`, `clipboard`, `trash`) that drew nothing beside their labels. Route the authored name through `resolveIcon`, the lucide RECORD surface `ui:button` and the `action:*` family already resolve against, on BOTH arms: the leaf `ContextMenuItem` and the `ContextMenuSubTrigger`. Repairing only the leaf would be a narrower version of the same bug. The dynamic `LazyIcon` surface stays ruled out — it degrades an unknown name to the `Database` glyph, trading a no-icon failure for a wrong-icon one. This is objectui#5930's diff ported to the file next door, with one difference: the twin rendered the name as raw TEXT, so its load-bearing assertion could be `queryByText(name)`. Here the defect was an ABSENT render, which makes that assertion a ghost — every measurement row asserts the resolved glyph by the identity lucide gives it instead. Also adds the `'context-menu'` census entry to `scripts/check-lucide-icon-record-names.mjs`, so the fixture's four names come under the gate's judgement as the card's own note asks. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011SfZeFWrhGLHmfq61xbz4q --- .changeset/6278-context-menu-item-icon.md | 9 + .../docs/components/overlay/context-menu.mdx | 11 +- .../__tests__/context-menu-item-icon.test.tsx | 188 ++++++++++++++++++ .../src/renderers/overlay/context-menu.tsx | 23 ++- .../check-lucide-icon-record-names.test.ts | 9 + scripts/check-lucide-icon-record-names.mjs | 14 ++ 6 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 .changeset/6278-context-menu-item-icon.md create mode 100644 packages/components/src/__tests__/context-menu-item-icon.test.tsx diff --git a/.changeset/6278-context-menu-item-icon.md b/.changeset/6278-context-menu-item-icon.md new file mode 100644 index 0000000000..080e0bf78f --- /dev/null +++ b/.changeset/6278-context-menu-item-icon.md @@ -0,0 +1,9 @@ +--- +"@object-ui/components": patch +--- + +`ui:context-menu` now resolves a menu item's authored `icon` to a glyph. It previously never read the key at all. + +Both arms of `renderContextMenuItems` — the leaf `ContextMenuItem` and the `ContextMenuSubTrigger` — ignored `icon` entirely, so an item authored as `{ "label": "Copy", "icon": "copy" }` drew its label and nothing else. The name is now resolved through `resolveIcon`, the same lucide **record** surface `ui:button`, `ui:dropdown-menu` and the `action:*` family already resolve against: a live name draws its glyph, and an unknown or retired spelling draws nothing rather than degrading to a wrong glyph. This mirrors the repair `ui:dropdown-menu` received for the identical defect. + +The `components-overlay-context-menu/basic-context-menu` catalog fixture already declared four live names — `copy`, `scissors`, `clipboard`, `trash` — which drew nothing before this change and draw their glyphs now. Those names are also brought under `check:lucide-icon-record-names` by a new `context-menu` census entry, so a future retired spelling fails the gate instead of silently drawing nothing. diff --git a/content/docs/components/overlay/context-menu.mdx b/content/docs/components/overlay/context-menu.mdx index 56d3564692..2ca05f8d6f 100644 --- a/content/docs/components/overlay/context-menu.mdx +++ b/content/docs/components/overlay/context-menu.mdx @@ -9,13 +9,22 @@ The Context Menu component displays a menu when right-clicking on an element. +## Icons + +An item's `icon` is a **kebab-case Lucide icon name**, resolved against lucide's +runtime `icons` record — the same surface `ui:button`, `ui:dropdown-menu` and the +`action:*` family resolve against. It is read on **both** arms: a leaf item and a +submenu trigger. A name that is not a live key of that record (an unknown or a +retired spelling such as `edit`) renders **no glyph**, never a fallback glyph and +never the literal name as text. + ## Schema ```plaintext interface ContextMenuItem { label?: string; value?: string; - icon?: string; + icon?: string; // kebab-case Lucide icon name (e.g. "trash") type?: 'separator'; disabled?: boolean; } diff --git a/packages/components/src/__tests__/context-menu-item-icon.test.tsx b/packages/components/src/__tests__/context-menu-item-icon.test.tsx new file mode 100644 index 0000000000..6e078989ae --- /dev/null +++ b/packages/components/src/__tests__/context-menu-item-icon.test.tsx @@ -0,0 +1,188 @@ +/** + * 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:context-menu` resolves an item's authored `icon` to a glyph (objectui#6278). + * + * The twin repair is objectui#5930 on `renderers/overlay/dropdown-menu.tsx`; + * this file is that suite ported to the file next door, with the two + * differences below made explicit rather than copied over silently. + * + * ## Difference 1 — the defect here 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('trash')` — the word was on + * screen. `context-menu` never referenced `icon` at all (0 occurrences in the + * file at `090927f4f`, against 2 for `shortcut` and 12 for `label` on the same + * pathspec), so it drew NOTHING. `queryByText(name)` is therefore a GHOST here: + * it is null before the fix and null after it. The discriminating assertion is + * the presence of the RESOLVED GLYPH, and that is what every measurement row + * below asserts. + * + * ## Difference 2 — the submenu trigger already contains an svg + * + * `ContextMenuSubTrigger` renders its own `ChevronRight` unconditionally (see + * `src/ui/context-menu.tsx`). A bare `querySelector('svg')` on that arm is + * therefore GREEN IN BOTH WORLDS — the blind instrument this suite must not + * use. Each assertion names the glyph by the identity lucide gives it, + * `svg.lucide-`, which is derived from the AUTHORED name (the + * independent input) and not from the renderer under test. The chevron is + * asserted alongside it as a positive control ON THE INSTRUMENT: if the + * `lucide-trash` row is red while the chevron row is green, the query works and + * the authored icon is genuinely missing. + * + * ## Why Radix's lazy mount is handled by an EVENT here, not `defaultOpen` + * + * The twin passes `defaultOpen: true`. `@radix-ui/react-context-menu`'s root + * has no such prop — its `ContextMenuProps` is `{ children, open, onOpenChange, + * dir, modal }` — and a context menu opens on the trigger's `contextmenu` + * event. Without firing it this suite would render an EMPTY container and prove + * nothing, which is why `expectsMenuOpen` is asserted as a harness control + * before any glyph is read. + * + * ## 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). + * + * ## 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. `edit` is that control — a deprecated lucide export whose key + * is absent from the runtime record (measured on lucide-react 1.31.0, 1767 + * keys), so it must draw no glyph while `copy`/`trash` must draw one. That row + * is what rules out the `LazyIcon` surface, which would degrade `edit` to the + * `Database` glyph. + * + * ## What this file does NOT own + * + * Fixture drift. The catalog's own four names live in + * `examples/schema-catalog/src/schemas/components-overlay-context-menu/basic-context-menu.json` + * and are judged on every run by `scripts/check-lucide-icon-record-names.mjs`, + * whose census this card's PR extends with a `'context-menu'` entry. This suite + * pins the RENDERER; the gate pins the SPELLINGS. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } 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()); + +/** + * Render the menu and OPEN it. Radix mounts `ContextMenuContent` only after the + * trigger sees a `contextmenu` event — see the header note. + */ +function renderMenu(items: any[]) { + const C = ComponentRegistry.get('context-menu') as React.ComponentType; + const { container } = render( + , + ); + fireEvent.contextMenu(container.firstElementChild as HTMLElement); + return container; +} + +/** The menu item element carrying `label`. Both arms render `role="menuitem"`. */ +function itemFor(label: string): HTMLElement { + const el = screen.getByText(label).closest('[role="menuitem"]'); + if (!el) throw new Error(`no [role="menuitem"] ancestor for ${label}`); + return el as HTMLElement; +} + +describe('ui:context-menu item icon resolution (objectui#6278)', () => { + describe('harness control — the menu actually opens', () => { + // Without this every "renders no glyph" row below would pass vacuously + // against a container that rendered nothing at all. + it('mounts the content and its items after the contextmenu event', () => { + renderMenu([{ label: 'Copy', icon: 'copy' }, { label: 'Plain' }]); + expect(screen.getByRole('menu')).toBeTruthy(); + expect(itemFor('Copy')).toBeTruthy(); + expect(itemFor('Plain')).toBeTruthy(); + }); + }); + + describe('ContextMenuItem arm (leaf)', () => { + it('renders the resolved glyph for a live icon name', () => { + renderMenu([{ label: 'Copy', icon: 'copy' }]); + // RED before the repair: the leaf item contained no svg whatsoever. + expect(itemFor('Copy').querySelector('svg.lucide-copy')).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`. + renderMenu([{ label: 'Edit', icon: 'edit' }]); + expect(itemFor('Edit').querySelector('svg')).toBeNull(); + }); + + it('renders no glyph when no icon is authored', () => { + renderMenu([{ label: 'Plain' }]); + expect(itemFor('Plain').querySelector('svg')).toBeNull(); + }); + }); + + describe('ContextMenuSubTrigger arm (submenu)', () => { + // Repairing only the leaf arm would be "a narrower version of the same + // bug" (objectui#5930), so this arm is measured as its own row. + const submenu = [{ label: 'More', icon: 'trash', children: [{ label: 'Nested' }] }]; + + it('positive control on the instrument — the arm DOES contain a queryable svg', () => { + // Green in both worlds BY DESIGN: `ContextMenuSubTrigger` always draws a + // chevron. It exists so a red `lucide-trash` row cannot be misread as a + // broken query. + renderMenu(submenu); + expect(itemFor('More').querySelector('svg.lucide-chevron-right')).not.toBeNull(); + }); + + it('renders the resolved glyph for a live icon name', () => { + renderMenu(submenu); + // RED before the repair: the chevron was the arm's ONLY svg. + expect(itemFor('More').querySelector('svg.lucide-trash')).not.toBeNull(); + }); + + it('renders no glyph beside the chevron for a RETIRED spelling', () => { + renderMenu([{ label: 'More', icon: 'edit', children: [{ label: 'Nested' }] }]); + const svgs = Array.from(itemFor('More').querySelectorAll('svg')); + expect(svgs.map((s) => s.getAttribute('class'))).toHaveLength(1); + expect(itemFor('More').querySelector('svg.lucide-chevron-right')).not.toBeNull(); + }); + }); + + describe('the basic-context-menu.json catalog fixture', () => { + // The four names the catalog actually authors. The fixture is a live + // specimen AND a declared AI few-shot retrieval source, so every name it + // ships must draw. Spelling drift is the gate's job (see the header); + // this row is the renderer's half of that contract. + it('draws a distinct glyph for each of the four authored names', () => { + renderMenu([ + { label: 'Copy', value: 'copy', icon: 'copy' }, + { label: 'Cut', value: 'cut', icon: 'scissors' }, + { label: 'Paste', value: 'paste', icon: 'clipboard' }, + { type: 'separator' }, + { label: 'Delete', value: 'delete', icon: 'trash' }, + ]); + for (const [label, name] of [ + ['Copy', 'copy'], + ['Cut', 'scissors'], + ['Paste', 'clipboard'], + ['Delete', 'trash'], + ]) { + expect( + itemFor(label).querySelector(`svg.lucide-${name}`), + `${label} should draw the ${name} glyph`, + ).not.toBeNull(); + } + }); + }); +}); diff --git a/packages/components/src/renderers/overlay/context-menu.tsx b/packages/components/src/renderers/overlay/context-menu.tsx index 943f184aaf..7aa466e63d 100644 --- a/packages/components/src/renderers/overlay/context-menu.tsx +++ b/packages/components/src/renderers/overlay/context-menu.tsx @@ -21,6 +21,21 @@ import { ContextMenuShortcut } from '../../ui'; import { renderChildren } from '../../lib/utils'; +// Same-package sibling import, the path `renderers/complex/data-table.tsx` +// already uses. `icon` on a menu item is an authored lucide NAME. The twin +// `dropdown-menu.tsx` rendered it into a text node; THIS renderer never +// referenced the key at all, so the catalog fixture +// `basic-context-menu.json` shipped four live names — `copy`, `scissors`, +// `clipboard`, `trash` — that drew NOTHING (objectui#6278). +// +// Routed through the RECORD surface (`icons` from 'lucide-react'), which is +// what `action:*` and `ui:button` next door resolve against, so a retired +// spelling renders NOTHING rather than a word. The dynamic surface +// (`LazyIcon`) is deliberately NOT used: it degrades an unknown name to the +// `Database` glyph, trading a no-icon failure for a WRONG-icon one, recorded +// as ruled out for authored icon fields by objectui#5622 and #5633 and +// re-affirmed for this exact shape by objectui#5930. +import { resolveIcon } from '../action/resolve-icon'; // Reuse helper for recursive menu items if I could share it, but for now duplicate concise logic const renderContextMenuItems = (items: any[]) => { @@ -28,10 +43,15 @@ const renderContextMenuItems = (items: any[]) => { return items.map((item: any, i: number) => { if (item.type === 'separator') return ; if (item.type === 'label') return {item.label}; + // Resolved once per item and read by BOTH arms below. The submenu-trigger + // arm carries the identical defect; repairing only the leaf would be a + // narrower version of the same bug (objectui#5930, objectui#6278). + const Icon = resolveIcon(item.icon); if (item.children) { return ( + {Icon && } {item.label} @@ -43,6 +63,7 @@ const renderContextMenuItems = (items: any[]) => { return ( + {Icon && } {item.label} {item.shortcut && {item.shortcut}} @@ -83,7 +104,7 @@ ComponentRegistry.register('context-menu', name: 'items', type: 'array', label: 'Items', - description: 'Recursive structure: { type?: "separator"|"label", label, shortcut, children }' + description: 'Recursive structure: { type?: "separator"|"label", label, icon, shortcut, children }. `icon` is a kebab-case Lucide icon name resolved against lucide\'s runtime `icons` record; an unknown or retired spelling renders no glyph.' }, { name: 'className', type: 'string', label: 'Content CSS Class' } ], diff --git a/scripts/__tests__/check-lucide-icon-record-names.test.ts b/scripts/__tests__/check-lucide-icon-record-names.test.ts index d046927279..e542e4977d 100644 --- a/scripts/__tests__/check-lucide-icon-record-names.test.ts +++ b/scripts/__tests__/check-lucide-icon-record-names.test.ts @@ -586,6 +586,11 @@ describe('this repository', () => { .filter(([, spec]) => 'descendants' in spec && spec.descendants) .map(([type]) => type); expect(declaring).toContain('dropdown-menu'); + // objectui#6278 — the same shape in `renderers/overlay/context-menu.tsx`. + // Its four catalog names (`copy`, `scissors`, `clipboard`, `trash`) are + // judged only through this declaration; drop the entry and the fixture goes + // back to being unjudged while the repo stays green. + expect(declaring).toContain('context-menu'); }); it('did NOT grow part 1 in the process — descent is a part-2 rule', () => { @@ -596,6 +601,10 @@ describe('this repository', () => { expect(repoResult.discovered.record).toHaveLength(8); expect(repoResult.discovered.record).not.toContain('packages/components/src/renderers/overlay/dropdown-menu.tsx'); expect(RECORD_READING_TYPES['dropdown-menu'].resolver).toContain('renderers/action/resolve-icon.ts'); + // objectui#6278 routes the twin the same way, so it must not move the + // part-1 count above either. + expect(repoResult.discovered.record).not.toContain('packages/components/src/renderers/overlay/context-menu.tsx'); + expect(RECORD_READING_TYPES['context-menu'].resolver).toContain('renderers/action/resolve-icon.ts'); }); it('really judges the `ui:icon` nodes objectui#6009 opened up', () => { diff --git a/scripts/check-lucide-icon-record-names.mjs b/scripts/check-lucide-icon-record-names.mjs index 53a9bf451b..92f827a242 100644 --- a/scripts/check-lucide-icon-record-names.mjs +++ b/scripts/check-lucide-icon-record-names.mjs @@ -205,6 +205,20 @@ export const RECORD_READING_TYPES = { 'action:group': { paths: ['icon', 'actions[].icon'], resolver: 'packages/components/src/renderers/action/resolve-icon.ts' }, 'action:icon': { paths: ['icon'], resolver: 'packages/components/src/renderers/action/resolve-icon.ts' }, 'action:menu': { paths: ['icon', 'actions[].icon'], resolver: 'packages/components/src/renderers/action/resolve-icon.ts' }, + // The twin of the `dropdown-menu` entry below, and it earns its own line for + // the same reason: the container's OWN `icon` is never read (`paths: []`), + // while its item icons sit on untyped children, recursively, and every one of + // them goes through the single `resolveIcon(item.icon)` call that serves BOTH + // the leaf arm and the submenu-trigger arm. The renderer did not read the key + // at all until objectui#6278, which is why this entry could not have been + // added by objectui#5992 — a census entry declares that a type's names REACH + // a vocabulary, and until the repair landed they reached nothing. + 'context-menu': { + paths: [], + descendants: true, + min: 1, + resolver: 'packages/components/src/renderers/action/resolve-icon.ts (via renderers/overlay/context-menu.tsx)', + }, 'data-table': { paths: ['rowActionDefs[].icon'], resolver: 'packages/components/src/renderers/complex/data-table.tsx' }, // The container's OWN `icon` is never read (`paths: []`); its item icons sit // on untyped children, recursively, and every one of them goes through the