// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; declare const process: {env: {NODE_ENV?: string}}; /** * @file BreadcrumbItem.tsx * @input Uses React use/useRef/useEffect, stylex, theme tokens, BreadcrumbContext, * rtlStyles for the contextual default slash, and (for the menu trigger) * usePopover + the shared DropdownMenu item API (renderDropdownItems, * DropdownMenuContext, MENU_ITEM_SELECTOR, useListFocus) * @output Exports BreadcrumbItem component and BreadcrumbItemProps * @position Individual breadcrumb item; used inside Breadcrumbs * * A `menu` prop turns the item's link-styled button into a menu trigger whose * popover reuses DropdownMenu's item pipeline — the same wiring ContextMenu * uses — so menu-item definitions are portable across the menu family. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Breadcrumbs/Breadcrumbs.doc.mjs * - /packages/core/src/Breadcrumbs/Breadcrumbs.test.tsx * - /packages/core/src/Breadcrumbs/index.ts * - /apps/storybook/stories/Breadcrumbs.stories.tsx * - /packages/cli/assets/templates/blocks/components/Breadcrumbs/ (showcase blocks) */ import React, { use, useCallback, useId, useMemo, useRef, useEffect, type ReactNode, type MouseEvent } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, typeScaleVars, radiusVars, fontWeightVars } from "../theme/tokens.stylex.js"; import { BreadcrumbContext, type BreadcrumbsVariant } from "./Breadcrumbs.js"; import { useLinkComponent } from "../Link/useLinkComponent.js"; import type { LinkComponentType } from "../Link/types.js"; import { mergeProps, rtlStyles } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import { themeProps } from "../utils/themeProps.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { Icon } from "../Icon/index.js"; import { usePopover } from "../Popover/usePopover.js"; import { useListFocus } from "../hooks/useListFocus.js"; import { useTypeahead } from "../hooks/useTypeahead.js"; import { layerAnimations } from "../Layer/layerAnimations.stylex.js"; import { renderDropdownItems } from "../DropdownMenu/renderDropdownItems.js"; import { DropdownMenuContext, type DropdownMenuContextValue, type DropdownMenuSize } from "../DropdownMenu/DropdownMenuContext.js"; import { MENU_ITEM_ROLES, MENU_ITEM_SELECTOR, MENU_BOUNDARY_SELECTOR } from "../DropdownMenu/menuItemRoles.js"; import type { DropdownMenuOption } from "../DropdownMenu/DropdownMenu.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; // ============================================================================= // Props // ============================================================================= export interface BreadcrumbItemProps extends Omit, 'onClick'> { ref?: React.Ref; /** * Custom component to render instead of `` for breadcrumb links. * Overrides the provider-level default set by LinkProvider. * Only applies for non-current items. Must accept href, className, style, and children props. */ as?: LinkComponentType; /** * Label content of the breadcrumb item. */ children: ReactNode; /** * URL for the breadcrumb link. Omit for the current page. */ href?: string; /** * Click handler. Works with or without href. */ onClick?: (e: MouseEvent) => void; /** * Marks this item as the current page. Renders as a span with aria-current="page". * When omitted, the last item is auto-detected as current if no item is * explicitly current. Pass `false` to opt this item out of auto-detection. * @default undefined */ isCurrent?: boolean; /** * Optional icon rendered before the label. */ startIcon?: ReactNode; /** * Menu opened when the item is activated. Accepts the SAME item API as * DropdownMenu / MoreMenu / ContextMenu, so a consumer's existing menu-item * definitions are portable into a breadcrumb with no rewrite: * - a `DropdownMenuOption[]` data array (items, sections, dividers), or * - composed `DropdownMenuItem` / `DropdownMenuCheckboxItem` / * `DropdownMenuRadioGroup` children. * * When set, the item renders as a link-styled menu trigger (button + a * trailing chevron, `aria-haspopup="menu"` / `aria-expanded`) whose popover * is a `role="menu"` container that provides `DropdownMenuContext` and runs * `useListFocus`. Takes precedence over `href` / `onClick` (which are * ignored when `menu` is set — a dev-time warning is logged). */ menu?: DropdownMenuOption[] | ReactNode; /** * Size passed to the menu items via `DropdownMenuContext` (item * padding/typography). Defaults from the breadcrumb variant: * `'supporting'` → `'sm'`, otherwise `'md'`. */ menuSize?: DropdownMenuSize; } // ============================================================================= // Styles // ============================================================================= const itemStyles = { root: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "xzye2dw", kogj98: "x1ghz6dp", "--separator-display": "xkce8z9 x1ibt0lz", $$css: true as const }, defaultSize: { kGuDYH: "xjm74w1", kLWn49: "xw6l6zx", $$css: true as const }, supportingSize: { kGuDYH: "x141an7d", kLWn49: "x1ltkj2j", $$css: true as const }, link: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "xzye2dw", k8WAf4: "xu0wf1k", kybGjl: "x1hl2dhg x13nosk6", kkrTdU: "x1ypdohk x16khyan", $$css: true as const }, buttonReset: { kg3NbH: "xnjsko4", kogj98: "x1ghz6dp", kVVagm: "xln7xf2", $$css: true as const }, defaultLink: { kMwMTN: "xv1l7n4", $$css: true as const }, supportingLink: { kMwMTN: "xv1l7n4", $$css: true as const }, chevron: { k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kmuXW: "x2lah0s", kzqmXN: "x5livx5", kZKoxP: "x15rwvks", kGuDYH: "x141an7d", $$css: true as const } }; function BreadcrumbSeparator({ separator }: { separator: ReactNode; }) { return ; } const menuStyles = { popover: { k7Eaqz: "x5w4yej", kqGvvJ: "xsq74q5", $$css: true as const } }; // ============================================================================= // Component // ============================================================================= /** * An individual breadcrumb item. Renders as a link (``) or a span * depending on whether it represents the current page. * * Each item renders its own leading separator, hidden on :first-child via * CSS. Auto-current detection uses a post-render effect that checks the * DOM — no React child introspection. * * @example * ``` * Projects * My Project * ``` */ export function BreadcrumbItem({ ref, as, children, href, onClick, isCurrent: isCurrentProp, startIcon, menu, menuSize, xstyle, className, style, 'data-testid': testId, ...rest }: BreadcrumbItemProps) { const ctx = use(BreadcrumbContext); const LinkComponent = useLinkComponent(as); const isSupporting = ctx.variant === 'supporting'; const liRef = useRef(null); // Points at the element we render to hold the item's content (the // link/button/span in the auto-candidate path). Auto-current detection sets // aria-current on this instead of guessing the
  • 's last child. const contentRef = useRef(null); const isCurrent = isCurrentProp === true; const isAutoCandidate = isCurrentProp == null; const hasMenu = menu != null; const resolvedMenuSize: DropdownMenuSize = menuSize ?? (isSupporting ? 'sm' : 'md'); // `menu` owns the click interaction; a label can't both navigate/act and // open a menu on the same activation. Warn once so the (ignored) href/onClick // don't silently disappear on the consumer. useEffect(() => { if (process.env.NODE_ENV !== 'production' && hasMenu) { if (href != null) { console.warn('BreadcrumbItem: `menu` and `href` are mutually exclusive; `menu` ' + 'takes precedence and `href` is ignored.'); } else if (onClick != null) { console.warn('BreadcrumbItem: `menu` and `onClick` are mutually exclusive; ' + '`menu` takes precedence and `onClick` is ignored.'); } } }, [hasMenu, href, onClick]); // Auto-detect: if no sibling has aria-current="page" and this is the last // non-separator item, set aria-current on our content element. // Runs as useEffect (not layout) — only sets an aria attribute, no visual change. // Placed on the item's content element (the link/button/span after the // separator), matching where the explicit `isCurrent` path sets it, so // aria-current lands on the actual interactive element — including when the // last item is a link — rather than on the outer
  • (navigation-11). useEffect(() => { if (!isAutoCandidate) { return; } const li = liRef.current; if (!li) { return; } const ol = li.parentElement; if (!ol) { return; } // All breadcrumb items (li without aria-hidden are items, with aria-hidden are separators — but we no longer have separator lis) const items = Array.from(ol.children) as HTMLElement[]; const isLast = items.length > 0 && items[items.length - 1] === li; const hasExplicit = ol.querySelector('[aria-current="page"]'); if (isLast && !hasExplicit) { // We control the element that holds the content (see the auto-candidate // render path below), so set aria-current on that ref rather than // assuming a positional last child. Fall back to the
  • only if the // ref is somehow unresolved. const target = contentRef.current ?? li; target.setAttribute('aria-current', 'page'); return () => { target.removeAttribute('aria-current'); }; } }); const mergedItemRef = useMergedRefs(ref, liRef); const content = <> {startIcon && {startIcon}} {children} ; if (isCurrent) { return
  • {hasMenu ? // A current crumb can also open a sibling menu — the trigger keeps // both aria-current="page" and aria-haspopup="menu". {content} : {content} }
  • ; } // Items without isCurrent set render as links (if href) or plain spans. // The effect handles adding aria-current for auto-last detection. return
  • {hasMenu ? {content} : href != null ? {content} : onClick != null ? : {content} }
  • ; } BreadcrumbItem.displayName = 'BreadcrumbItem'; // ============================================================================= // Menu trigger // ============================================================================= interface BreadcrumbMenuTriggerProps { ref: React.Ref; /** The link-styled label content rendered inside the trigger button. */ children: ReactNode; menu: DropdownMenuOption[] | ReactNode; menuSize: DropdownMenuSize; variant: BreadcrumbsVariant; isCurrent?: boolean; } /** * The link-styled ` {popover.render(, { placement: 'below', alignment: 'start', xstyle: [menuStyles.popover, layerAnimations.below] })} ; } BreadcrumbMenuTrigger.displayName = 'BreadcrumbMenuTrigger';