Skip to content

Commit c75b928

Browse files
committed
fix(overlay): MenuItem becomes a discriminated union; all three menu renderers read the declared onClick
MenuItem (dropdown-menu/context-menu/menubar) is now MenuCommandItem | MenuDividerItem: a command item with a required label, or `{ separator: true }` with none. `type` is tombstoned (`?: never` / `z.never().optional()`) on both arms, retiring the undeclared `{ type: 'separator' }` / `{ type: 'label' }` dialect dropdown-menu and context-menu used to read instead of the declared `separator` boolean. dropdown-menu and context-menu now branch on `item.separator` (matching menubar, which always had this right) instead of the undeclared `item.type`; their registry defaultProps and description strings stop teaching the retired spelling. All three renderers now fire the declared `item.onClick` — dropdown-menu/context-menu used to read an undeclared `item.onSelect` instead, and menubar wired no handler at all. `renderMenuItems`/`renderContextMenuItems` tighten from `items: any[]` to `MenuItem[]`, closing the hole that let the mismatch type-check. menubar also gains `shortcut` rendering (parity with the other two containers, not new capability). Fixes #6523 Fixes #6346 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_8ca04858-ea8e-5b85-9182-de59aa49e00c
1 parent babe956 commit c75b928

17 files changed

Lines changed: 828 additions & 109 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/components': minor
4+
---
5+
6+
`MenuItem` is now a discriminated union, and all three menu renderers read the keys it
7+
declares (objectui#6523, objectui#6346, maintainer ruling 2026-08-27 — "one answer for the
8+
whole `MenuItem` family").
9+
10+
**The break, spelled out.** `MenuItem` (`@object-ui/types`, shared by `ui:dropdown-menu`,
11+
`ui:context-menu` and `ui:menubar`) used to be a single object with `label: string`
12+
required unconditionally. It is now `MenuCommandItem | MenuDividerItem`: a command item
13+
(`label` required, plus `icon`/`disabled`/`onClick`/`shortcut`/`children`) or a divider
14+
(`{ separator: true }`, nothing else). The union — not `label?: string` — is deliberate: it
15+
is what the data actually is, and it keeps the command arm's label protection intact rather
16+
than weakening it repo-wide to accommodate the divider. Both arms also tombstone `type`
17+
(`type?: never` / `z.never().optional()`): the retired `{ type: 'separator' }` (and its
18+
sibling `{ type: 'label' }`) is now a **declared refusal** at parse time, not a silent strip.
19+
A consumer's own `MenuItem[]` authored with either retired spelling now fails
20+
`MenuItemSchema.safeParse` and fails `tsc` under the published `.d.ts`; a consumer authoring
21+
the declared `{ separator: true }` divider now **succeeds** for the first time — before this
22+
change it failed a strict parse too, because `label` had no way to be omitted.
23+
24+
**Renderer accept behaviour changes to match.** `dropdown-menu` and `context-menu` used to
25+
branch on the undeclared `item.type === 'separator'`; an author who instead wrote the
26+
DECLARED `{ separator: true }` got a value that validated, published, and rendered a blank
27+
menu row (the divider fell through to the ordinary item branch with no `label`). Both
28+
renderers now branch on `item.separator`, matching `menubar` — which had this right all
29+
along and is the evidence the type, not those two renderers, was correct. Their registry
30+
`defaultProps` and `description` strings stop teaching the retired dialect; the 4 places it
31+
appeared in this repo (2 schema-catalog fixtures, 2 registry `defaultProps`) are migrated.
32+
33+
**The item handler moves to the declared key (objectui#6346).** All three renderers now
34+
fire `item.onClick` — the key `MenuItem` has always declared (TS source, built `.d.ts`, and
35+
the Zod mirror all agreed) but that `dropdown-menu`/`context-menu` never read (they read an
36+
undeclared `item.onSelect` instead) and that `menubar` wired nowhere at all. An author who
37+
followed the published type and set `onClick` got a value that validated, published, and
38+
never fired; that is fixed. `renderMenuItems`/`renderContextMenuItems` also tighten from
39+
`items: any[]` to `items: MenuItem[]` — the widening that let the mismatch type-check in the
40+
first place. Migration cost measured **zero** in this repo: no fixture, doc or test authored
41+
`onSelect` on a menu item before this change.
42+
43+
**Rider, recorded as parity not new capability.** `menubar` now also renders the declared
44+
`shortcut` string — `dropdown-menu` and `context-menu` already drew it, so this aligns the
45+
third container rather than expanding the surface.
46+
47+
Everything that rendered correctly before this change still renders the same way; the
48+
narrowing only refuses spellings that were already unprotected (silently stripped or never
49+
read at all).

‎content/docs/components/overlay/context-menu.mdx‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,34 @@ submenu trigger. A name that is not a live key of that record (an unknown or a
1818
retired spelling such as `edit`) renders **no glyph**, never a fallback glyph and
1919
never the literal name as text.
2020

21+
## Dividers
22+
23+
An item is either a command or a divider between groups of commands — never
24+
both, and a divider never carries a `label` (objectui#6523). Author a divider
25+
as `{ "separator": true }`; the retired `{ "type": "separator" }` spelling is
26+
refused at parse time rather than silently ignored.
27+
2128
## Schema
2229

2330
```plaintext
24-
interface ContextMenuItem {
25-
label?: string;
31+
interface ContextMenuCommandItem {
32+
label: string;
2633
value?: string;
2734
icon?: string; // kebab-case Lucide icon name (e.g. "trash")
28-
type?: 'separator';
2935
disabled?: boolean;
36+
onClick?: () => void; // Click handler
3037
}
3138
39+
interface ContextMenuDividerItem {
40+
separator: true; // renders as a divider — no label
41+
}
42+
43+
type ContextMenuItem = ContextMenuCommandItem | ContextMenuDividerItem;
44+
3245
interface ContextMenuSchema {
3346
type: 'context-menu';
3447
trigger: ComponentSchema; // Trigger element
3548
items: ContextMenuItem[]; // Menu items
36-
onSelect?: string | ActionConfig;
3749
className?: string;
3850
}
3951
```

‎content/docs/components/overlay/dropdown-menu.mdx‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,36 @@ resolve against. A name that is not a live key of that record (an unknown or a
2121
retired spelling such as `edit`) renders **no glyph**, never a fallback glyph
2222
and never the literal name as text.
2323

24+
## Dividers
25+
26+
An item is either a command or a divider between groups of commands — never
27+
both, and a divider never carries a `label` (objectui#6523). Author a divider
28+
as `{ "separator": true }`; the retired `{ "type": "separator" }` spelling is
29+
refused at parse time rather than silently ignored.
30+
2431
## Schema
2532

2633
```plaintext
27-
interface DropdownMenuItem {
28-
label?: string;
34+
interface DropdownMenuCommandItem {
35+
label: string;
2936
value?: string;
3037
icon?: string; // kebab-case Lucide icon name (e.g. "square-pen")
3138
variant?: 'default' | 'destructive';
32-
type?: 'separator';
3339
disabled?: boolean;
40+
onClick?: () => void; // Click handler
41+
}
42+
43+
interface DropdownMenuDividerItem {
44+
separator: true; // renders as a divider — no label
3445
}
3546
47+
type DropdownMenuItem = DropdownMenuCommandItem | DropdownMenuDividerItem;
48+
3649
interface DropdownMenuSchema {
3750
type: 'dropdown-menu';
3851
trigger: ComponentSchema; // Trigger component
3952
items: DropdownMenuItem[]; // Menu items
4053
41-
// Events
42-
onSelect?: string | ActionConfig;
43-
4454
// Styling
4555
className?: string;
4656
}

‎content/docs/components/overlay/menubar.mdx‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,30 @@ The Menubar component provides a horizontal menu bar similar to desktop applicat
99

1010
<SchemaExample id="components-overlay-menubar/application-menubar" />
1111

12+
## Dividers
13+
14+
An item is either a command or a divider between groups of commands — never
15+
both, and a divider never carries a `label`. Author a divider as
16+
`{ "separator": true }`.
17+
1218
## Schema
1319

1420
```plaintext
15-
interface MenubarItem {
16-
label?: string;
21+
interface MenubarCommandItem {
22+
label: string;
1723
value?: string;
1824
icon?: string;
19-
shortcut?: string[];
20-
type?: 'separator';
25+
shortcut?: string; // e.g. "Ctrl+T" — a single string, not an array
2126
disabled?: boolean;
27+
onClick?: () => void; // Click handler
2228
}
2329
30+
interface MenubarDividerItem {
31+
separator: true; // renders as a divider — no label
32+
}
33+
34+
type MenubarItem = MenubarCommandItem | MenubarDividerItem;
35+
2436
interface MenubarMenu {
2537
label: string;
2638
items: MenubarItem[];
@@ -29,11 +41,6 @@ interface MenubarMenu {
2941
interface MenubarSchema {
3042
type: 'menubar';
3143
menus: MenubarMenu[]; // Menu definitions
32-
33-
// Events
34-
onSelect?: string | ActionConfig;
35-
36-
// Styling
3744
className?: string;
3845
}
3946
```

‎examples/schema-catalog/src/schemas/components-overlay-context-menu/basic-context-menu.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"icon": "clipboard"
2323
},
2424
{
25-
"type": "separator"
25+
"separator": true
2626
},
2727
{
2828
"label": "Delete",

‎examples/schema-catalog/src/schemas/components-overlay-dropdown-menu/basic-dropdown-menu.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"value": "settings"
1515
},
1616
{
17-
"type": "separator"
17+
"separator": true
1818
},
1919
{
2020
"label": "Logout",

0 commit comments

Comments
 (0)