{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"dropdown-menu","type":"registry:ui","title":"Dropdown menu","description":"A focused list of actions anchored to a trigger.","categories":["actions"],"dependencies":["@radix-ui/react-dropdown-menu","lucide-react","motion"],"registryDependencies":["https://ui.teamofsilicons.com/r/silicon-motion-tokens.json"],"files":[{"path":"registry/components/dropdown-menu/dropdown-menu.tsx","type":"registry:component","target":"@components/silicon-ui/dropdown-menu/dropdown-menu.tsx","content":"\"use client\";\n\n/**\n * Dropdown menu: a list of actions behind a trigger.\n *\n * Two ways to use it:\n *\n *   // Shorthand: a labelled trigger and a flat list.\n *   <DropdownMenu label=\"Manage silicon\" items={[{ label: \"Rename\", onSelect: rename }, …]} />\n *\n *   // Composable (experimental): any trigger, labels, checked and radio items, shortcuts and submenus.\n *   <DropdownMenuRoot>\n *     <DropdownMenuTrigger asChild><Button variant=\"ghost\" aria-label=\"Page options\"><Ellipsis /></Button></DropdownMenuTrigger>\n *     <DropdownMenuContent align=\"end\">\n *       <DropdownMenuLabel>Page</DropdownMenuLabel>\n *       <DropdownMenuItem icon={<Copy />} shortcut=\"⌘D\" onSelect={duplicate}>Duplicate</DropdownMenuItem>\n *       <DropdownMenuCheckboxItem checked={wide} onCheckedChange={setWide}>Full width</DropdownMenuCheckboxItem>\n *       <DropdownMenuSub>\n *         <DropdownMenuSubTrigger icon={<FolderInput />}>Move to</DropdownMenuSubTrigger>\n *         <DropdownMenuSubContent>…</DropdownMenuSubContent>\n *       </DropdownMenuSub>\n *       <DropdownMenuSeparator />\n *       <DropdownMenuItem destructive icon={<Trash2 />} onSelect={remove}>Delete</DropdownMenuItem>\n *     </DropdownMenuContent>\n *   </DropdownMenuRoot>\n *\n * Every part shares one look: the same surface, the gliding highlight, the entrance that grows from the trigger.\n */\nimport { Fragment, cloneElement, forwardRef, isValidElement, useImperativeHandle, useLayoutEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, ReactNode, Ref } from \"react\";\nimport * as DropdownPrimitive from \"@radix-ui/react-dropdown-menu\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, ChevronDown, ChevronRight } from \"lucide-react\";\nimport { MenuHighlight, useMenuHighlight } from \"../lib/menu-highlight\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./dropdown-menu.module.css\";\n\nconst ITEMS = '[role=\"menuitem\"], [role=\"menuitemcheckbox\"], [role=\"menuitemradio\"]';\nconst cx = (...names: Array<string | false | undefined>) => names.filter(Boolean).join(\" \");\n\n/** A menu surface's own ref, also handed to the caller's ref. It numbers the items in order, so their entrance\n *  staggers whatever groups and parts they sit in. */\nfunction useSurfaceRef(forwarded: Ref<HTMLDivElement>) {\n  const own = useRef<HTMLDivElement>(null);\n  useImperativeHandle(forwarded, () => own.current as HTMLDivElement, []);\n  useLayoutEffect(() => {\n    own.current?.querySelectorAll<HTMLElement>(ITEMS).forEach((item, index) => item.style.setProperty(\"--i\", String(index)));\n  });\n  return own;\n}\n\n/* ------------------------------------------------------------------------------------------------------------------ */\n/* Composable parts                                                                                                    */\n/* ------------------------------------------------------------------------------------------------------------------ */\n\n/** The menu's state. Uncontrolled by default; pass `open` and `onOpenChange` to control it. */\nexport function DropdownMenuRoot(props: ComponentPropsWithoutRef<typeof DropdownPrimitive.Root>) {\n  return <DropdownPrimitive.Root {...props} />;\n}\n\nexport interface DropdownMenuTriggerProps extends ComponentPropsWithoutRef<typeof DropdownPrimitive.Trigger> {\n  /** Render your own trigger (an icon-only Button with an aria-label, an avatar and name) instead of the default one. */\n  asChild?: boolean;\n}\n\n/** What opens the menu. Without `asChild` it is the default outlined trigger with a chevron. */\nexport const DropdownMenuTrigger = forwardRef<HTMLButtonElement, DropdownMenuTriggerProps>(function DropdownMenuTrigger({ asChild, className, children, ...props }, ref) {\n  if (asChild) return <DropdownPrimitive.Trigger {...props} ref={ref} asChild>{children}</DropdownPrimitive.Trigger>;\n  return (\n    <DropdownPrimitive.Trigger {...props} ref={ref} type=\"button\" className={cx(styles.trigger, className)}>\n      {children}\n      <ChevronDown className={styles.chevron} size={15} strokeWidth={1.8} aria-hidden=\"true\" />\n    </DropdownPrimitive.Trigger>\n  );\n});\n\nexport interface DropdownMenuContentProps extends ComponentPropsWithoutRef<typeof DropdownPrimitive.Content> {\n  /** Which side of the trigger it opens on (default bottom). */\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\";\n  /** How it lines up with the trigger (default end). */\n  align?: \"start\" | \"center\" | \"end\";\n}\n\n/** The menu itself. One highlight glides between items for the pointer and jumps instantly for the keyboard. */\nexport const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(function DropdownMenuContent({ className, children, side = \"bottom\", align = \"end\", sideOffset = 6, collisionPadding = 12, ...props }, ref) {\n  const { highlight, contentProps } = useMenuHighlight(ITEMS);\n  const surface = useSurfaceRef(ref);\n  return (\n    <DropdownPrimitive.Portal>\n      <DropdownPrimitive.Content {...props} {...contentProps} ref={surface} side={side} align={align} sideOffset={sideOffset} collisionPadding={collisionPadding} loop className={cx(styles.menu, className)}>\n        <MenuHighlight state={highlight} className={styles.highlight} />\n        {children}\n      </DropdownPrimitive.Content>\n    </DropdownPrimitive.Portal>\n  );\n});\n\nexport interface DropdownMenuItemProps extends ComponentPropsWithoutRef<typeof DropdownPrimitive.Item> {\n  /** A 16px icon before the label. */\n  icon?: ReactNode;\n  /** A keyboard shortcut hint after the label, such as \"⌘D\". Shown only; bind the keys yourself. */\n  shortcut?: string;\n  /** A destructive action (delete, remove): shown in the danger colour. */\n  destructive?: boolean;\n  /** Make your own element the item, such as a link: `<DropdownMenuItem asChild><a href=\"/q3\">Q3 2026</a></DropdownMenuItem>`.\n   *  Its children become the label, and Enter follows the link. */\n  asChild?: boolean;\n}\n\n/** One action, or with `asChild` one link. */\nexport const DropdownMenuItem = forwardRef<HTMLDivElement, DropdownMenuItemProps>(function DropdownMenuItem({ icon, shortcut, destructive, asChild, className, children, ...props }, ref) {\n  const content = (label: ReactNode) => <>\n    {icon ? <span className={styles.icon} aria-hidden=\"true\">{icon}</span> : null}\n    <span className={styles.itemLabel}>{label}</span>\n    {shortcut ? <kbd className={styles.shortcut}>{shortcut}</kbd> : null}\n  </>;\n  const own = asChild && isValidElement<{ children?: ReactNode }>(children) ? children : null;\n  return (\n    <DropdownPrimitive.Item {...props} ref={ref} asChild={!!own} className={cx(styles.item, destructive && styles.destructive, className)} data-tone={destructive ? \"danger\" : undefined}>\n      {own ? cloneElement(own, undefined, content(own.props.children)) : content(children)}\n    </DropdownPrimitive.Item>\n  );\n});\n\nexport interface DropdownMenuCheckboxItemProps extends ComponentPropsWithoutRef<typeof DropdownPrimitive.CheckboxItem> {\n  /** A keyboard shortcut hint after the label. */\n  shortcut?: string;\n}\n\n/** An on/off setting, such as \"Full width\". A check shows in the icon column while it is on. */\nexport const DropdownMenuCheckboxItem = forwardRef<HTMLDivElement, DropdownMenuCheckboxItemProps>(function DropdownMenuCheckboxItem({ shortcut, className, children, ...props }, ref) {\n  return (\n    <DropdownPrimitive.CheckboxItem {...props} ref={ref} className={cx(styles.item, className)}>\n      <span className={styles.icon} aria-hidden=\"true\"><DropdownPrimitive.ItemIndicator><Check size={16} strokeWidth={2} /></DropdownPrimitive.ItemIndicator></span>\n      <span className={styles.itemLabel}>{children}</span>\n      {shortcut ? <kbd className={styles.shortcut}>{shortcut}</kbd> : null}\n    </DropdownPrimitive.CheckboxItem>\n  );\n});\n\n/** One choice out of several (the current space, a sort order). Wrap DropdownMenuRadioItems in it. */\nexport const DropdownMenuRadioGroup = DropdownPrimitive.RadioGroup;\n\nexport interface DropdownMenuRadioItemProps extends ComponentPropsWithoutRef<typeof DropdownPrimitive.RadioItem> {\n  /** A 16px icon or avatar before the label; the check shows at the end while this is the current choice. */\n  icon?: ReactNode;\n}\n\n/** One of the choices in a DropdownMenuRadioGroup. The current choice shows a check at the end. */\nexport const DropdownMenuRadioItem = forwardRef<HTMLDivElement, DropdownMenuRadioItemProps>(function DropdownMenuRadioItem({ icon, className, children, ...props }, ref) {\n  return (\n    <DropdownPrimitive.RadioItem {...props} ref={ref} className={cx(styles.item, className)}>\n      {icon ? <span className={styles.icon} aria-hidden=\"true\">{icon}</span> : null}\n      <span className={styles.itemLabel}>{children}</span>\n      <span className={styles.indicator} aria-hidden=\"true\"><DropdownPrimitive.ItemIndicator><Check size={16} strokeWidth={2} /></DropdownPrimitive.ItemIndicator></span>\n    </DropdownPrimitive.RadioItem>\n  );\n});\n\n/** A section heading inside the menu, such as \"Spaces\". Not focusable. */\nexport const DropdownMenuLabel = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<typeof DropdownPrimitive.Label>>(function DropdownMenuLabel({ className, ...props }, ref) {\n  return <DropdownPrimitive.Label {...props} ref={ref} className={cx(styles.sectionLabel, className)} />;\n});\n\n/** A hairline between groups of items. */\nexport const DropdownMenuSeparator = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<typeof DropdownPrimitive.Separator>>(function DropdownMenuSeparator({ className, ...props }, ref) {\n  return <DropdownPrimitive.Separator {...props} ref={ref} className={cx(styles.separator, className)} />;\n});\n\n/** Groups items for assistive technology, without visual change. */\nexport const DropdownMenuGroup = DropdownPrimitive.Group;\n\n/** A nested menu: a DropdownMenuSubTrigger and its DropdownMenuSubContent. */\nexport const DropdownMenuSub = DropdownPrimitive.Sub;\n\nexport interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef<typeof DropdownPrimitive.SubTrigger> {\n  /** A 16px icon before the label. */\n  icon?: ReactNode;\n}\n\n/** The item that opens a submenu (on hover, click, or the right arrow key). */\nexport const DropdownMenuSubTrigger = forwardRef<HTMLDivElement, DropdownMenuSubTriggerProps>(function DropdownMenuSubTrigger({ icon, className, children, ...props }, ref) {\n  return (\n    <DropdownPrimitive.SubTrigger {...props} ref={ref} className={cx(styles.item, styles.subTrigger, className)}>\n      {icon ? <span className={styles.icon} aria-hidden=\"true\">{icon}</span> : null}\n      <span className={styles.itemLabel}>{children}</span>\n      <ChevronRight className={styles.subChevron} size={15} strokeWidth={1.8} aria-hidden=\"true\" />\n    </DropdownPrimitive.SubTrigger>\n  );\n});\n\n/** The submenu's own menu, opening beside its trigger. */\nexport const DropdownMenuSubContent = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<typeof DropdownPrimitive.SubContent>>(function DropdownMenuSubContent({ className, children, sideOffset = 6, collisionPadding = 12, ...props }, ref) {\n  const { highlight, contentProps } = useMenuHighlight(ITEMS);\n  const surface = useSurfaceRef(ref);\n  return (\n    <DropdownPrimitive.Portal>\n      <DropdownPrimitive.SubContent {...props} {...contentProps} ref={surface} sideOffset={sideOffset} collisionPadding={collisionPadding} loop className={cx(styles.menu, className)}>\n        <MenuHighlight state={highlight} className={styles.highlight} />\n        {children}\n      </DropdownPrimitive.SubContent>\n    </DropdownPrimitive.Portal>\n  );\n});\n\n/* ------------------------------------------------------------------------------------------------------------------ */\n/* Shorthand                                                                                                           */\n/* ------------------------------------------------------------------------------------------------------------------ */\n\nexport interface DropdownItem {\n  /** The item's text. */\n  label: string;\n  /** Runs when the item is chosen. */\n  onSelect?: () => void;\n  /** Shown but not choosable. */\n  disabled?: boolean;\n  /** A 16px icon before the label. */\n  icon?: ReactNode;\n  /** A destructive action, shown in the danger colour. */\n  destructive?: boolean;\n  /** Draws a separator above this item. */\n  separatorBefore?: boolean;\n}\n\nexport interface DropdownMenuProps {\n  /** The trigger's text; when it names the current choice, a new label rises in and the trigger springs to its width. */\n  label: string;\n  /** The actions, in order. */\n  items: DropdownItem[];\n  /** An icon before the trigger's text. */\n  icon?: ReactNode;\n}\n\n/** A new trigger label rises in while the old one leaves, and the trigger width springs to the measured text instead of snapping. */\nfunction TriggerLabel({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const measure = useRef<HTMLSpanElement>(null);\n  const measured = useRef<string | null>(null);\n  const [size, setSize] = useState<{ width: number | \"auto\"; animate: boolean }>({ width: \"auto\", animate: false });\n  useLayoutEffect(() => {\n    const node = measure.current;\n    if (!node) return;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const current = node.textContent;\n      // Only a text change morphs; the first measure and font swaps settle instantly.\n      const animate = measured.current !== null && measured.current !== current;\n      measured.current = current;\n      setSize({ width: Math.ceil(entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth), animate });\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return <motion.span className={styles.label} initial={false} animate={{ width: size.width }} transition={size.animate && !reduced ? motionTokens.spring.morph : { duration: 0 }}>\n    <span ref={measure} className={styles.labelMeasure} aria-hidden=\"true\">{text}</span>\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      <motion.span key={text} className={styles.labelText} initial={reduced ? false : { opacity: 0, y: \"0.3em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.3em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{text}</motion.span>\n    </AnimatePresence>\n  </motion.span>;\n}\n\n/** The shorthand: a labelled trigger and a flat list of actions, built from the parts above. */\nexport function DropdownMenu({ label, items, icon }: DropdownMenuProps) {\n  return (\n    <DropdownMenuRoot>\n      <DropdownMenuTrigger>\n        {icon ? <span className={styles.triggerIcon} aria-hidden=\"true\">{icon}</span> : null}\n        <TriggerLabel text={label} />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent>\n        {items.map(item => (\n          <Fragment key={item.label}>\n            {item.separatorBefore ? <DropdownMenuSeparator /> : null}\n            <DropdownMenuItem icon={item.icon} destructive={item.destructive} disabled={item.disabled} onSelect={item.onSelect}>{item.label}</DropdownMenuItem>\n          </Fragment>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenuRoot>\n  );\n}\n\nexport default DropdownMenu;\n"},{"path":"registry/components/dropdown-menu/dropdown-menu.module.css","type":"registry:component","target":"@components/silicon-ui/dropdown-menu/dropdown-menu.module.css","content":".trigger { display: inline-flex; max-width: 100%; min-height: var(--control-height-sm); align-items: center; gap: var(--space-related); padding: 0 var(--space-group); border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; cursor: pointer; transition: background-color var(--duration-instant) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .trigger:hover { border-color: var(--border-strong); background: var(--surface-muted); box-shadow: var(--shadow-resting); } }\n/* The trigger anchors the menu, so press feedback stays in color. A scaled rect would be measured on pointerdown and shift the menu. */\n.trigger[data-state=\"open\"] { border-color: var(--border-strong); background: var(--surface-muted); box-shadow: none; }\n.trigger:active { border-color: var(--border-strong); background: color-mix(in oklab, var(--surface-muted), var(--border) 55%); box-shadow: none; }\n.item:focus-visible { outline: none; }\n.triggerIcon { display: inline-flex; flex: none; color: var(--text-secondary); }\n.label { position: relative; display: inline-flex; min-width: 0; flex: 0 1 auto; overflow-x: clip; }\n.labelMeasure { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }\n.labelText { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.chevron { flex: none; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }\n.trigger[data-state=\"open\"] .chevron { transform: rotate(180deg); }\n/* The menu grows from the trigger edge: offset toward the trigger, scale from the Radix origin, fade quickly.\n   Transitions instead of keyframes, so a close that interrupts the open (or a reopen during the close) reverses from where it is. */\n.menu { --menu-x: 0px; --menu-y: -5px; position: relative; z-index: var(--layer-dropdown); min-width: min(12rem, var(--radix-dropdown-menu-content-available-width)); max-width: var(--radix-dropdown-menu-content-available-width); padding: var(--space-tight); border: 1px solid var(--border); border-radius: var(--radius-container); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: var(--radix-dropdown-menu-content-transform-origin); transition: opacity var(--duration-fast) var(--ease-enter), transform var(--duration-spring) var(--ease-spring); }\n.menu[data-side=\"top\"] { --menu-y: 5px; }\n.menu[data-side=\"left\"] { --menu-x: 5px; --menu-y: 0px; }\n.menu[data-side=\"right\"] { --menu-x: -5px; --menu-y: 0px; }\n@starting-style {\n  .menu[data-state=\"open\"] { opacity: 0; transform: translate(var(--menu-x), var(--menu-y)) scale(.97); }\n  .menu[data-state=\"open\"] .item { opacity: 0; transform: translate(calc(var(--menu-x) * .4), calc(var(--menu-y) * .4)); }\n}\n/* Radix unmounts when the exit animation ends, so a no-op keyframe times the exit while the transition does the visual work. */\n.menu[data-state=\"closed\"] { opacity: 0; transform: translate(calc(var(--menu-x) * .5), calc(var(--menu-y) * .5)) scale(.985); transition: opacity var(--duration-instant) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); animation: menu-exit var(--duration-instant) linear both; pointer-events: none; }\n.highlight { position: absolute; top: 0; right: var(--space-tight); left: var(--space-tight); border-radius: calc(var(--radius-container) - var(--space-related)); background: var(--surface-muted); opacity: 0; pointer-events: none; transition: background-color var(--duration-fast) var(--ease-standard); }\n.highlight[data-tone=\"danger\"] { background: color-mix(in oklab, var(--danger) 8%, var(--surface)); }\n/* An item may be a link (asChild), which keeps the item's look. */\n.item { position: relative; display: flex; min-height: 36px; align-items: center; gap: var(--space-group); padding: 0 var(--space-group); border-radius: calc(var(--radius-container) - var(--space-related)); color: var(--foreground); font-size: var(--text-sm); text-decoration: none; cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), opacity var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * var(--stagger-item)), transform var(--duration-standard) var(--ease-enter) calc(min(var(--i, 0), 4) * var(--stagger-item)); }\n/* At least the 16px icon's column; wider content (an avatar) takes the room it needs. */\n.icon { display: inline-flex; min-width: 17px; flex: none; color: var(--text-secondary); }\n/* The composable parts. The label takes the free width, so a shortcut or check sits at the end of the row. */\n.itemLabel { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.shortcut { flex: none; padding-left: var(--space-group); color: var(--text-muted); font: inherit; font-size: var(--text-xs); letter-spacing: var(--tracking-body); }\n.indicator { display: inline-flex; flex: none; color: var(--foreground); }\n.sectionLabel { padding: var(--space-related) var(--space-group) var(--space-tight); color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; }\n.subChevron { flex: none; color: var(--text-muted); }\n/* While its submenu is open, the item that opened it keeps a quiet fill, so the path stays visible. */\n.subTrigger[data-state=\"open\"] { background: var(--surface-muted); }\n.separator { height: 1px; margin: var(--space-tight) calc(var(--space-tight) * -1); background: var(--border-subtle); }\n.destructive { color: var(--danger); }\n.destructive .icon { color: var(--danger); }\n.item[data-disabled] { opacity: .45; cursor: default; }\n@keyframes menu-exit { to { --menu-exit: 1; } }\n@media (prefers-reduced-transparency: reduce) { .menu { background: var(--surface-raised); backdrop-filter: none; -webkit-backdrop-filter: none; } }\n@media (prefers-reduced-motion: reduce) {\n  .trigger, .chevron, .item, .highlight { transition: none; }\n  .menu, .menu[data-state=\"closed\"] { transform: none; transition: opacity var(--duration-instant) linear; }\n}\n"},{"path":"lib/menu-highlight.tsx","type":"registry:lib","target":"@components/silicon-ui/lib/menu-highlight.tsx","content":"\"use client\";\n\nimport { useRef } from \"react\";\nimport type { FocusEvent, MouseEvent } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { FluidHoverHighlight, useFluidHover, type FluidRect } from \"./fluid-hover\";\nimport { motionTokens } from \"./motion-tokens\";\n\n/** A fluid hover highlight in a menu: where it sits, whether it travels, its pointer session, and its tone. */\ntype FluidHighlight = { rect: FluidRect | null; glide: boolean; session: number; danger: boolean };\n/** A marker positioned by top and height only (a list's current-page marker). */\ntype BarHighlight = { top: number; height: number; danger: boolean; glide: boolean };\n\nconst ITEMS = '[role=\"menuitem\"], [role=\"menuitemcheckbox\"], [role=\"menuitemradio\"], [role=\"option\"]';\n\n/**\n * Fluid hover for a Radix menu or listbox (lib/fluid-hover): the item nearest the pointer is lit, gaps and padding\n * included, a click in a gap picks the lit item, and the keyboard moves the highlight with Radix's focus, without travel.\n * Spread `contentProps` on the content, call `reset` when it opens, and render `<MenuHighlight state={highlight} … />`\n * as its first child. Items mark a destructive tone with `data-tone=\"danger\"`.\n */\nexport function useMenuHighlight(itemSelector = ITEMS) {\n  // The content element is taken from its own events, so the hook never competes with the content's ref.\n  const content = useRef<HTMLElement | null>(null);\n  const hover = useFluidHover(content, { items: itemSelector });\n  const own = (event: { currentTarget: EventTarget }) => { content.current = event.currentTarget as HTMLElement; };\n  const { handlers } = hover;\n  const highlight: FluidHighlight = { rect: hover.rect, glide: hover.glide, session: hover.session, danger: hover.item?.dataset.tone === \"danger\" };\n  return {\n    highlight,\n    reset: hover.reset,\n    contentProps: {\n      onMouseEnter: (event: MouseEvent<HTMLElement>) => { own(event); handlers.onMouseEnter(); },\n      onMouseMove: (event: MouseEvent<HTMLElement>) => { own(event); handlers.onMouseMove(event); },\n      onMouseLeave: handlers.onMouseLeave,\n      onKeyDownCapture: handlers.onKeyDownCapture,\n      onFocus: (event: FocusEvent<HTMLElement>) => { own(event); handlers.onFocus(event); },\n      onBlur: handlers.onBlur,\n      onClick: (event: MouseEvent<HTMLElement>) => { own(event); handlers.onClick(event); },\n    },\n  };\n}\n\n/** The highlight itself: the fluid hover highlight, or a marker that moves by top and height (a current-page marker). */\nexport function MenuHighlight({ state, className }: { state: FluidHighlight | BarHighlight | null; className?: string }) {\n  if (state && \"session\" in state) return <FluidHoverHighlight hover={state} className={className} tone={state.danger ? \"danger\" : undefined} />;\n  return <BarMarker state={state} className={className} />;\n}\n\nfunction BarMarker({ state, className }: { state: BarHighlight | null; className?: string }) {\n  const reduced = useReducedMotion();\n  return <motion.span className={className} data-tone={state?.danger ? \"danger\" : undefined} aria-hidden=\"true\" initial={false}\n    animate={state ? { y: state.top, height: state.height, opacity: 1 } : { opacity: 0 }}\n    transition={{ default: state?.glide && !reduced ? motionTokens.spring.fast : { duration: 0 }, opacity: { duration: reduced ? 0 : motionTokens.duration.fast } }} />;\n}\n"},{"path":"lib/fluid-hover.tsx","type":"registry:lib","target":"@components/silicon-ui/lib/fluid-hover.tsx","content":"\"use client\";\n\n/**\n * Fluid hover: in a set of items (a menu, a list, tabs, a grid), the item nearest the pointer is lit, even while the\n * pointer is in a gap, the padding or past the last item, and one highlight springs between items. A click in a gap goes\n * to the lit item, so what is lit is what a click hits. A new pointer entry fades the highlight in where it lands instead\n * of sliding it over from the last item. The keyboard moves it without travel, and reduced motion keeps the fade but\n * drops the travel.\n *\n *   const list = useRef<HTMLUListElement>(null);\n *   const hover = useFluidHover(list, { items: \"[data-item]\" });\n *   <ul ref={list} className={styles.list} {...hover.handlers}>      // .list { position: relative }\n *     <FluidHoverHighlight hover={hover} className={styles.highlight} />\n *     …items…\n *   </ul>\n *\n * Adapted from Fluid Functionalism's use-fluid-hover and fluid-hover-highlight by Micka Touillaud (MIT),\n * https://github.com/mickadesign/fluid-functionalism. Items are found by a selector instead of registering themselves,\n * so a component adopts it without changing its items.\n */\nimport { useEffect, useEffectEvent, useRef, useState } from \"react\";\nimport type { FocusEvent, MouseEvent, PointerEvent, RefObject } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { motionTokens } from \"./motion-tokens\";\n\n/** An item's box in the container's own layout space (unaffected by transforms or scrolling). */\nexport interface FluidRect { top: number; left: number; width: number; height: number }\n\nexport interface FluidHoverState {\n  /** Where the highlight sits, or null when nothing is lit. */\n  rect: FluidRect | null;\n  /** The lit item. */\n  item: HTMLElement | null;\n  /** False when the highlight should jump (keyboard, a resize) rather than travel. */\n  glide: boolean;\n  /** Steps on each new pointer entry, so the highlight fades in where it lands. */\n  session: number;\n}\n\nexport interface FluidHoverOptions {\n  /** Selector for the items inside the container. */\n  items: string;\n  /** \"y\" for lists (default), \"x\" for a row of tabs, \"xy\" for grids. */\n  axis?: \"x\" | \"y\" | \"xy\";\n  /** Items the highlight skips (and a gap click never activates). Defaults to disabled or aria-disabled items. */\n  isItemDisabled?: (item: HTMLElement) => boolean;\n  /** \"skip\" (default) lights the nearest enabled item even over a disabled one; \"clear\" lights nothing over a disabled item (a calendar's unavailable days). */\n  disabled?: \"skip\" | \"clear\";\n  /** Route a click in a gap to the lit item. On by default; turn it off where empty space should stay inert. */\n  gapClick?: boolean;\n  /** Light the item that receives keyboard focus. On by default. */\n  followFocus?: boolean;\n}\n\nconst defaultDisabled = (item: HTMLElement) =>\n  item.matches(\":disabled, [aria-disabled='true'], [data-disabled]\");\n\n/** Controls that sit between items keep their own clicks. */\nconst CONTROL = \"input, textarea, select, button, a, summary, [contenteditable], [role='textbox'], [role='searchbox'], [role='button']\";\n\n/** The item's box relative to the container, summing offsets through positioned ancestors in between. */\nexport function rectOf(item: HTMLElement, container: HTMLElement): FluidRect {\n  let top = item.offsetTop, left = item.offsetLeft;\n  let parent = item.offsetParent as HTMLElement | null;\n  while (parent && parent !== container && container.contains(parent)) {\n    top += parent.offsetTop + parent.clientTop;\n    left += parent.offsetLeft + parent.clientLeft;\n    parent = parent.offsetParent as HTMLElement | null;\n  }\n  return { top, left, width: item.offsetWidth, height: item.offsetHeight };\n}\n\n/**\n * The item the pointer is inside, or else the one whose edge is nearest along the axis (both axes for a grid). Edges, not\n * centres, so a gap next to a wide item still lights the item the pointer is closer to.\n */\nexport function pickNearest(items: HTMLElement[], x: number, y: number, axis: \"x\" | \"y\" | \"xy\"): HTMLElement | null {\n  let nearest: HTMLElement | null = null, best = Infinity;\n  for (const item of items) {\n    const r = item.getBoundingClientRect();\n    if (!r.width && !r.height) continue;\n    if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return item;\n    const dx = Math.max(r.left - x, 0, x - r.right), dy = Math.max(r.top - y, 0, y - r.bottom);\n    const distance = axis === \"x\" ? dx : axis === \"y\" ? dy : Math.hypot(dx, dy);\n    if (distance < best) { best = distance; nearest = item; }\n  }\n  return nearest;\n}\n\n/** Hands a gap click to the item as a real mouse click (detail, modifiers and coordinates kept), so the item reads it as a pointer press, not a keyboard one. */\nfunction forwardClick(item: HTMLElement, event: MouseEvent<HTMLElement>) {\n  const { detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons } = event;\n  item.dispatchEvent(new globalThis.MouseEvent(\"click\", { bubbles: true, cancelable: true, composed: true, view: window, detail, clientX, clientY, screenX, screenY, ctrlKey, shiftKey, altKey, metaKey, button, buttons }));\n}\n\n/** Whether an event happened inside the element whose handler hears it, not in a portalled child (a submenu, a tooltip). */\nconst isOwn = (event: { currentTarget: EventTarget; target: EventTarget }) =>\n  !(event.currentTarget instanceof Node) || !(event.target instanceof Node) || event.currentTarget.contains(event.target);\n\nexport function useFluidHover<T extends HTMLElement>(container: RefObject<T | null>, options: FluidHoverOptions) {\n  const { items: selector, axis = \"y\", isItemDisabled = defaultDisabled, disabled = \"skip\", gapClick = true, followFocus = true } = options;\n  const [state, setState] = useState<FluidHoverState>({ rect: null, item: null, glide: false, session: 0 });\n  const current = useRef(state);\n  const pointer = useRef(false);\n  const entering = useRef(false);\n  const frame = useRef(0);\n\n  function commit(next: FluidHoverState) {\n    const prev = current.current;\n    if (prev.item === next.item && prev.session === next.session && sameRect(prev.rect, next.rect)) return;\n    current.current = next;\n    setState(next);\n  }\n\n  function light(item: HTMLElement | null, glide: boolean) {\n    const root = container.current;\n    if (!root || !item) return commit({ ...current.current, rect: null, item: null, glide });\n    let session = current.current.session;\n    if (entering.current) { entering.current = false; session += 1; }\n    commit({ rect: rectOf(item, root), item, glide: glide && session === current.current.session, session });\n  }\n\n  const enabledItems = () => {\n    const root = container.current;\n    return root ? [...root.querySelectorAll<HTMLElement>(selector)].filter(item => !isItemDisabled(item)) : [];\n  };\n\n  // The lit item can move under a still highlight (a resize, content above it changing): follow it without travel.\n  const follow = useEffectEvent(() => { const { item } = current.current; if (item?.isConnected) light(item, false); });\n  useEffect(() => {\n    const root = container.current;\n    if (!root || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => follow());\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [container]);\n  useEffect(() => () => cancelAnimationFrame(frame.current), []);\n\n  const handlers = {\n    onMouseEnter: () => { entering.current = true; },\n    onMouseMove: (event: MouseEvent<T>) => {\n      if (!isOwn(event)) return;\n      pointer.current = true;\n      const { clientX, clientY } = event;\n      cancelAnimationFrame(frame.current);\n      frame.current = requestAnimationFrame(() => {\n        const over = disabled === \"clear\" ? document.elementFromPoint(clientX, clientY)?.closest<HTMLElement>(selector) : null;\n        light(over && isItemDisabled(over) ? null : pickNearest(enabledItems(), clientX, clientY, axis), true);\n      });\n    },\n    onMouseLeave: () => { cancelAnimationFrame(frame.current); entering.current = false; light(null, false); },\n    onKeyDownCapture: () => { pointer.current = false; },\n    onFocus: (event: FocusEvent<T>) => {\n      if (!followFocus || pointer.current || !isOwn(event)) return;\n      const item = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(selector) : null;\n      if (item && container.current?.contains(item) && !isItemDisabled(item)) light(item, false);\n    },\n    onBlur: (event: FocusEvent<T>) => {\n      if (!pointer.current && !event.currentTarget.contains(event.relatedTarget as Node | null)) light(null, false);\n    },\n    onClick: (event: MouseEvent<T>) => {\n      if (!gapClick || !isOwn(event)) return;\n      const target = event.target as Element;\n      if (!target.isConnected || target.closest(selector) || target.closest(CONTROL)) return;\n      const { item } = current.current;\n      if (item?.isConnected && !isItemDisabled(item)) forwardClick(item, event);\n    },\n  };\n\n  /** Clears the highlight at once, such as when a menu opens again. */\n  const reset = () => { cancelAnimationFrame(frame.current); entering.current = true; light(null, false); };\n\n  return { ...state, handlers, reset, light };\n}\n\nexport interface NearestItemOptions extends Omit<FluidHoverOptions, \"followFocus\"> {\n  /** Called with the nearest enabled item as the mouse moves over the container. */\n  onPick: (item: HTMLElement) => void;\n}\n\n/**\n * Fluid hover's picking without its highlight, for a list that already keeps its own active item and highlight (a listbox\n * with aria-activedescendant, a roving list): the mouse picks the nearest enabled item, gaps and padding included, and a\n * click in a gap goes to that item. Spread the result on the container.\n */\n// A hook only so callbacks that touch refs can be passed during render; it keeps no state.\nexport function useNearestItem<T extends HTMLElement>({ items: selector, axis = \"y\", isItemDisabled = defaultDisabled, gapClick = true, onPick }: NearestItemOptions) {\n  const nearest = (event: MouseEvent<T>) => {\n    if (!isOwn(event)) return null;\n    const items = [...event.currentTarget.querySelectorAll<HTMLElement>(selector)].filter(item => !isItemDisabled(item));\n    return pickNearest(items, event.clientX, event.clientY, axis);\n  };\n  return {\n    onPointerMove: (event: PointerEvent<T>) => { if (event.pointerType !== \"mouse\") return; const item = nearest(event); if (item) onPick(item); },\n    onClick: (event: MouseEvent<T>) => {\n      if (!gapClick) return;\n      const target = event.target as Element;\n      if (!target.isConnected || target.closest(selector) || target.closest(CONTROL)) return;\n      const item = nearest(event);\n      if (item) forwardClick(item, event);\n    },\n  };\n}\n\nconst sameRect = (a: FluidRect | null, b: FluidRect | null) =>\n  a === b || (!!a && !!b && a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height);\n\nexport interface FluidHoverHighlightProps {\n  /** The state from useFluidHover (or any { rect, glide, session }). */\n  hover: Pick<FluidHoverState, \"rect\" | \"glide\" | \"session\">;\n  /** Radius, fill, z-index. The container must be position: relative; the highlight is placed at its top-left corner. */\n  className?: string;\n  /** Extra attributes for styling, such as data-tone. */\n  tone?: string;\n}\n\n/** The one highlight a fluid hover set renders: it springs between items on transforms, and fades in and out. */\nexport function FluidHoverHighlight({ hover, className, tone }: FluidHoverHighlightProps) {\n  const reduced = useReducedMotion() ?? false;\n  const { rect, glide, session } = hover;\n  const place = rect ? { x: rect.left, y: rect.top, width: rect.width, height: rect.height } : undefined;\n  return (\n    <AnimatePresence initial={false}>\n      {place ? (\n        <motion.span key={session} className={className} data-tone={tone} aria-hidden=\"true\"\n          style={{ position: \"absolute\", top: 0, left: 0, pointerEvents: \"none\" }}\n          initial={{ opacity: 0, ...place }}\n          animate={{ opacity: 1, ...place }}\n          exit={{ opacity: 0, transition: motionTokens.spring.fast.exit }}\n          transition={{ default: glide && !reduced ? motionTokens.spring.fast : { duration: 0 }, opacity: { duration: motionTokens.duration.fast } }} />\n      ) : null}\n    </AnimatePresence>\n  );\n}\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/dropdown-menu","meta":{"tier":"free","kind":"component","docs":"https://ui.teamofsilicons.com/dropdown-menu","markdown":"https://ui.teamofsilicons.com/dropdown-menu.md","tags":["menu","action"],"status":"stable","experimental":["DropdownMenuRoot","DropdownMenuTrigger","DropdownMenuContent","DropdownMenuItem","DropdownMenuCheckboxItem","DropdownMenuRadioGroup","DropdownMenuRadioItem","DropdownMenuLabel","DropdownMenuSeparator","DropdownMenuGroup","DropdownMenuSub","DropdownMenuSubTrigger","DropdownMenuSubContent"]}}