Skip to content

feat(Breadcrumbs): menu-trigger breadcrumb item reusing the DropdownMenu item API (#4159)#4198

Draft
cixzhang wants to merge 1 commit into
mainfrom
feat/breadcrumb-menu-item
Draft

feat(Breadcrumbs): menu-trigger breadcrumb item reusing the DropdownMenu item API (#4159)#4198
cixzhang wants to merge 1 commit into
mainfrom
feat/breadcrumb-menu-item

Conversation

@cixzhang

Copy link
Copy Markdown
Contributor

What

Adds a menu capability to the existing BreadcrumbItem. When set, the crumb renders as a link-styled menu trigger (a <button> with a trailing chevron and aria-haspopup="menu") whose popover is a role="menu" container of sibling destinations.

const teams: DropdownMenuOption[] = [
  {label: 'Design', onClick: () => go('/team/design')},
  {label: 'Engineering', onClick: () => go('/team/eng')},
  {type: 'divider'},
  {label: 'Data', icon: 'chart', onClick: () => go('/team/data')},
];

<Breadcrumbs>
  <BreadcrumbItem href="/">Home</BreadcrumbItem>
  <BreadcrumbItem menu={teams}>Teams</BreadcrumbItem>   {/* data array */}
  <BreadcrumbItem isCurrent>Overview</BreadcrumbItem>
</Breadcrumbs>

Composed children work too (menu={<><BreadcrumbMenuItem .../></>}).

Why — reuse the DropdownMenu item API

The menu items are the exact DropdownMenu item API, so a consumer's existing menu-item definitions are portable across DropdownMenu / MoreMenu / ContextMenu / Breadcrumb with no rewrite. This is the same reuse ContextMenu already ships, so the breadcrumb menu joins that family rather than inventing a breadcrumb-specific item vocabulary.

Reuse (which imports):

  • renderDropdownItems — converts a DropdownMenuOption[] to items (array form).
  • DropdownMenuContext / DropdownMenuContextValue — provides closeMenu + menuSize so items (including selectable ones) coordinate and close on select.
  • MENU_ITEM_ROLES / MENU_ITEM_SELECTOR — shared roving-focus/typeahead selector.
  • useListFocus (+ useTypeahead) — keyboard navigation over the container.
  • Item components re-exported under Breadcrumb* aliases (mirroring ContextMenu/index.ts).

The trigger stays breadcrumb-side: the existing link-styled <button> path + usePopover + chevron. This follows the ContextMenu precedent (strategy b) — a thin breadcrumb-side role="menu" container consuming the public DropdownMenu items/context. No changes to DropdownMenu; extracting a shared container (strategy a) is a tracked follow-up.

Trigger / container wiring

  • usePopover({role: 'none', hasCloseButton: false, hasAutoFocus: false}) so the popup's own role="menu" is the exposed semantics.
  • Trigger button spreads popover.triggerProps, then overrides aria-haspopup="menu"; opens on click / Enter / Space / ArrowDown; ref={mergeRefs(contentRef, popover.triggerRef, buttonRef)} so the auto-current effect and focus-return still work.
  • Container runs useListFocus + useTypeahead over MENU_ITEM_SELECTOR; Escape closes and returns focus to the trigger; Tab closes (APG menu-button pattern).

ARIA

Attr Value Source
aria-haspopup "menu" trigger (overrides usePopover default)
aria-expanded / aria-controls open state / menu id trigger
role="menu" popover container breadcrumb container
role="menuitem" / menuitemcheckbox / menuitemradio items reused DropdownMenu items
aria-current="page" trigger, only when current existing effect / explicit isCurrent

Resolved decisions

  1. Accepts both a DropdownMenuOption[] array and composed children (matches DropdownMenu/ContextMenu).
  2. Item components re-exported under Breadcrumb* aliases for family coherence + discoverability.
  3. Strategy (b) now — thin container over the public DropdownMenu items/context; no DropdownMenu refactor.
  4. menu + isCurrent allowed together (keeps both aria-current="page" and aria-haspopup="menu").
  5. menu + href (and menu + onClick) disallowed in v1 → dev-warn, menu wins.
  6. menuSize default from breadcrumb variant: supportingsm, else md.

Render-mode precedence: isCurrentmenuhrefonClick → span.

Testing

  • Existing Breadcrumbs tests stay green.
  • New: portability (a DropdownMenuOption[] renders), composed children, selectable checkbox + radio group, keyboard (ArrowDown opens, arrow rove, Escape closes + returns focus), menu + isCurrent, menu + href warns & menu wins, mid-trail separators intact.
  • pnpm -F @astryxdesign/core build (incl. tsc), lint, check:sync, sync:exports --check, check:changesets all pass.

Closes #4159

@vercel

vercel Bot commented Jul 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview, Comment Jul 22, 2026 12:09pm

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Jul 22, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

Self-review — Quick Self-Review (updating an existing component)

  1. Icons from registry — chevron via getIcon('chevronDown'), no hardcoded SVG. ✅
  2. themeProps present — new trigger button carries themeProps('breadcrumb-item-menu-trigger'); menu container carries themeProps('breadcrumb-menu'), merged via mergeProps. ✅
  3. No wrapper divs — click/keydown handlers and mergeRefs go directly on the trigger <button>; no wrapping div around it. ✅
  4. No CSS shorthands — used flexShrink etc.; no bare flex. ✅
  5. ARIA correct — switched the trigger from the plain link/button path to a menu trigger: aria-haspopup="menu" (overriding usePopover's dialog/true default), aria-expanded, aria-controls; container is role="menu"; items keep their DropdownMenu roles; aria-current="page" still set when current. Covered by tests. ✅
  6. Tests pass locally — Breadcrumbs (36), ContextMenu + DropdownMenu + DropdownMenuSelectable (75) all green; build (incl. tsc), lint, check:sync, sync:exports --check, check:changesets pass. ✅
  7. Sandbox/stories updated — added two stories (data-array + composed-children menu crumb); doc.mjs updated with menu/menuSize (en/zh/dense). Additive prop, no callsite removals. ✅
  8. Popover background — uses usePopover's default surface (hasSurface on), same as DropdownMenu; no bare layer without background. ✅

Key protocol principles

  • Compose, don't rebuild — reuses renderDropdownItems, DropdownMenuContext, MENU_ITEM_SELECTOR/ROLES, useListFocus, useTypeahead, and the item components; only net-new code is the thin container (the same overlap ContextMenu already accepts). ✅
  • Family alignment — item API identical to DropdownMenu/MoreMenu/ContextMenu; menu?: DropdownMenuOption[] | ReactNode mirrors their data-or-children duality. ✅
  • Surface area — no new component/item exports; Breadcrumb* aliases re-export existing DropdownMenu items (mirrors ContextMenu). ✅
  • Spec compliance — matches the approved spec (strategy b, ContextMenu precedent, resolved decisions 1–7). No deviations.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jul 22, 2026
github-actions Bot added a commit that referenced this pull request Jul 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Breadcrumbs · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 608 -
Complexity N/A Very High (48) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.7KB 0B

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(breadcrumbs): breadcrumb item that triggers a dropdown menu

1 participant