{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-dropdown-menu","type":"registry:component","title":"Dropdown Menu (Motion)","description":"DropdownMenu with motion: it springs out of its trigger, its items rise in one after another, and one highlight glides from item to item after the pointer or the arrow keys.","dependencies":["lucide-react","motion","radix-ui"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-portal.json","https://dashral.com/r/motion-hover.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/dropdown-menu.json"],"files":[{"path":"registry/dashral/motion/dropdown-menu.tsx","type":"registry:file","target":"components/dashral/motion/dropdown-menu.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { DropdownMenu as DropdownMenuPrimitive } from \"radix-ui\"\nimport { ArrowUpRightIcon, ChevronRightIcon } from \"lucide-react\"\nimport { AnimatePresence, LayoutGroup, motion, type MotionValue } from \"motion/react\"\nimport type { DropdownMenuProps, MenuItem } from \"../dropdown-menu\"\nimport { cn } from \"../lib/utils\"\nimport { usePortalContainer } from \"../lib/portal\"\n\nimport { useHoverPill } from \"../lib/motion-hover\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\n\nexport type { DropdownMenuProps, MenuItem } from \"../dropdown-menu\"\n\n// As the kit's menu items, but the highlight is this component's: a pill that glides from item to item, so the item\n// itself keeps a transparent background. The panel, not each item, is the stacking context: the pill sits behind every\n// item's text, so gliding down past one never covers it.\nconst itemClass =\n  \"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[variant=destructive]:text-destructive-text [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:[&_svg:not([class*='text-'])]:text-destructive-text\"\nconst indicatorItemClass =\n  \"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pe-2 ps-8 text-sm outline-hidden select-none focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\"\nconst panelClass =\n  \"isolate z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none\"\n\n// How far the panel starts back toward its trigger, by side.\nconst offsets = { top: { x: 0, y: 6 }, bottom: { x: 0, y: -6 }, left: { x: 6, y: 0 }, right: { x: -6, y: 0 } } as const\n\n/** The item the pointer or keyboard is on, shared by a panel's items so one pill can glide between them. */\nconst HotContext = React.createContext<{\n  hot: string | null\n  enter: (key: string) => void\n  leave: () => void\n  opacity: MotionValue<number> | null\n  reduced: boolean\n}>({ hot: null, enter: () => {}, leave: () => {}, opacity: null, reduced: false })\n\n/** The highlight under the item that's on: one element per panel, gliding from item to item. */\nfunction Pill({ id, destructive }: { id: string; destructive?: boolean }) {\n  const { hot, opacity, reduced } = React.useContext(HotContext)\n  if (hot !== id || !opacity) return null\n  return (\n    <motion.span\n      layoutId=\"hot\"\n      aria-hidden\n      className={cn(\"absolute inset-0 -z-10 rounded-sm transition-colors\", destructive ? \"bg-destructive/10\" : \"bg-accent\")}\n      // Its opacity is the panel's, so it doesn't start from nothing at each new item.\n      style={{ opacity }}\n      transition={reduced ? { duration: 0 } : spring.move}\n    />\n  )\n}\n\n/** What an item does to be the one that's on: takes the pill on focus (pointer or keys) and gives it up on blur. */\nfunction useHot(id: string) {\n  const { enter, leave } = React.useContext(HotContext)\n  return { onFocus: () => enter(id), onBlur: leave }\n}\n\n/** An item's content, rising in after the ones above it as the panel opens. */\nfunction Rise({ index, children, className }: { index: number; children: React.ReactNode; className?: string }) {\n  const { reduced } = React.useContext(HotContext)\n  return (\n    <motion.span\n      className={cn(\"flex min-w-0 flex-1 items-center gap-[inherit]\", className)}\n      initial={reduced ? false : { opacity: 0, y: 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: duration.standard, ease: ease.enter, delay: Math.min(index, 8) * 0.025 }}\n    >\n      {children}\n    </motion.span>\n  )\n}\n\nconst Shortcut = ({ children }: { children: string }) => <span className=\"ms-auto ps-3 text-xs tracking-widest text-muted-foreground\">{children}</span>\n\n/** A tick that draws itself when checked and back out when cleared. */\nfunction Tick({ on }: { on: boolean }) {\n  const { reduced } = React.useContext(HotContext)\n  return (\n    <svg viewBox=\"0 0 24 24\" aria-hidden className=\"size-4 fill-none stroke-current stroke-2 [stroke-linecap:round] [stroke-linejoin:round]\">\n      <motion.path\n        d=\"M4 12l5 5L20 6\"\n        initial={false}\n        animate={reduced ? { opacity: on ? 1 : 0 } : { pathLength: on ? 1 : 0, opacity: on ? 1 : 0 }}\n        transition={\n          on\n            ? { pathLength: { duration: duration.standard, ease: ease.enter }, opacity: { duration: 0.01 } }\n            : { pathLength: { duration: duration.fast, ease: ease.exit }, opacity: { duration: 0.01, delay: duration.fast } }\n        }\n      />\n    </svg>\n  )\n}\n\n/** A panel's items, with its own pill. */\nfunction MenuPanel({ items, linkAs, prefix }: { items: MenuItem[]; linkAs?: React.ElementType; prefix: string }) {\n  const reduced = useReducedMotion()\n  const { hot, enter, leave, opacity } = useHoverPill<string>(reduced)\n  const group = React.useId()\n  return (\n    <HotContext value={{ hot, enter, leave, opacity, reduced }}>\n      <LayoutGroup id={group}>\n        <MenuItems items={items} linkAs={linkAs} prefix={prefix} />\n      </LayoutGroup>\n    </HotContext>\n  )\n}\n\nfunction MenuItems({ items, linkAs: Link = \"a\", prefix }: { items: MenuItem[]; linkAs?: React.ElementType; prefix: string }) {\n  const P = DropdownMenuPrimitive\n  return items.map((item, i) => {\n    const id = `${prefix}-${i}`\n    switch (item.type) {\n      case \"separator\":\n        return <P.Separator key={i} className=\"-mx-1 my-1 h-px bg-border\" />\n      case \"label\":\n        return (\n          <P.Label key={i} className={cn(\"px-2 py-1.5 text-sm font-medium\", item.className)}>\n            <Rise index={i}>{item.label}</Rise>\n          </P.Label>\n        )\n      case \"row\":\n        return (\n          <div key={i} role=\"group\" aria-label={item.label} className=\"flex justify-between px-1 py-1\">\n            {item.items.map((entry, j) => (\n              <RowItem key={j} id={`${id}-${j}`} index={i} entry={entry} />\n            ))}\n          </div>\n        )\n      case \"checkbox\":\n        return <CheckboxItem key={i} id={id} index={i} item={item} />\n      case \"radio\":\n        return <RadioItems key={i} id={id} index={i} item={item} />\n      case \"submenu\":\n        return <Submenu key={i} id={id} index={i} item={item} linkAs={Link} />\n      default:\n        return <PlainItem key={i} id={id} index={i} item={item} linkAs={Link} />\n    }\n  })\n}\n\ntype Plain = Extract<MenuItem, { type?: \"item\" }>\n\nfunction PlainItem({ id, index, item, linkAs: Link }: { id: string; index: number; item: Plain; linkAs: React.ElementType }) {\n  const hot = useHot(id)\n  const inner = (\n    <Rise index={index}>\n      {item.icon}\n      {item.label}\n      {item.shortcut && <Shortcut>{item.shortcut}</Shortcut>}\n      {item.external && <ArrowUpRightIcon className=\"ms-auto\" aria-hidden />}\n    </Rise>\n  )\n  return (\n    <DropdownMenuPrimitive.Item\n      asChild={item.href !== undefined}\n      disabled={item.disabled}\n      data-variant={item.color ?? \"default\"}\n      onSelect={(event) => {\n        if (item.keepOpen) event.preventDefault()\n        item.onSelect?.(event)\n      }}\n      {...hot}\n      className={cn(itemClass, item.active && \"font-medium\", item.className)}\n    >\n      {item.href === undefined ? (\n        <>\n          <Pill id={id} destructive={item.color === \"destructive\"} />\n          {/* The page you're on keeps a soft mark of its own, under the pill. */}\n          {item.active && <span aria-hidden className=\"absolute inset-0 -z-20 rounded-sm bg-accent/60\" />}\n          {inner}\n        </>\n      ) : item.external ? (\n        <a href={item.href} target=\"_blank\" rel=\"noreferrer\">\n          <Pill id={id} />\n          {inner}\n        </a>\n      ) : (\n        <Link href={item.href} aria-current={item.active ? \"page\" : undefined}>\n          <Pill id={id} />\n          {item.active && <span aria-hidden className=\"absolute inset-0 -z-20 rounded-sm bg-accent/60\" />}\n          {inner}\n        </Link>\n      )}\n    </DropdownMenuPrimitive.Item>\n  )\n}\n\ntype RowEntry = Extract<MenuItem, { type: \"row\" }>[\"items\"][number]\n\nfunction RowItem({ id, index, entry }: { id: string; index: number; entry: RowEntry }) {\n  const hot = useHot(id)\n  const { reduced } = React.useContext(HotContext)\n  return (\n    <DropdownMenuPrimitive.Item\n      aria-label={entry[\"aria-label\"]}\n      disabled={entry.disabled}\n      onSelect={entry.onSelect}\n      {...hot}\n      className={cn(itemClass, \"size-8 justify-center p-0 text-lg\")}\n    >\n      <Pill id={id} />\n      {/* A reaction pops in, and grows a little under the pointer. */}\n      <motion.span\n        initial={reduced ? false : { opacity: 0, scale: 0.5 }}\n        animate={{ opacity: 1, scale: 1 }}\n        whileHover={reduced ? undefined : { scale: 1.2 }}\n        transition={{ ...spring.pop, delay: Math.min(index, 8) * 0.025 }}\n      >\n        {entry.label}\n      </motion.span>\n    </DropdownMenuPrimitive.Item>\n  )\n}\n\nfunction CheckboxItem({ id, index, item }: { id: string; index: number; item: Extract<MenuItem, { type: \"checkbox\" }> }) {\n  const hot = useHot(id)\n  return (\n    <DropdownMenuPrimitive.CheckboxItem\n      checked={item.checked}\n      onCheckedChange={(checked) => item.onCheckedChange(checked === true)}\n      onSelect={item.closeOnSelect ? undefined : (event) => event.preventDefault()}\n      disabled={item.disabled}\n      {...hot}\n      className={cn(indicatorItemClass, item.className)}\n    >\n      <Pill id={id} />\n      <span className=\"pointer-events-none absolute start-2 flex size-3.5 items-center justify-center\">\n        <Tick on={item.checked} />\n      </span>\n      <Rise index={index}>\n        {item.icon}\n        {item.label}\n        {item.shortcut && <Shortcut>{item.shortcut}</Shortcut>}\n      </Rise>\n    </DropdownMenuPrimitive.CheckboxItem>\n  )\n}\n\nfunction RadioItems({ id, index, item }: { id: string; index: number; item: Extract<MenuItem, { type: \"radio\" }> }) {\n  return (\n    <DropdownMenuPrimitive.RadioGroup value={item.value} onValueChange={item.onValueChange}>\n      {item.options.map((option, j) => (\n        <RadioItem key={option.value} id={`${id}-${j}`} index={index + j} option={option} picked={item.value === option.value} />\n      ))}\n    </DropdownMenuPrimitive.RadioGroup>\n  )\n}\n\nfunction RadioItem({\n  id,\n  index,\n  option,\n  picked,\n}: {\n  id: string\n  index: number\n  option: Extract<MenuItem, { type: \"radio\" }>[\"options\"][number]\n  picked: boolean\n}) {\n  const hot = useHot(id)\n  const { reduced } = React.useContext(HotContext)\n  return (\n    <DropdownMenuPrimitive.RadioItem value={option.value} disabled={option.disabled} {...hot} className={indicatorItemClass}>\n      <Pill id={id} />\n      <span className=\"pointer-events-none absolute start-2 flex size-3.5 items-center justify-center\">\n        {/* The dot springs into the picked one and shrinks out of the one left. */}\n        <motion.span\n          className=\"block size-2 rounded-full bg-current\"\n          initial={false}\n          animate={{ scale: picked ? 1 : 0, opacity: picked ? 1 : 0 }}\n          transition={reduced ? { duration: 0 } : picked ? spring.pop : { duration: duration.fast, ease: ease.exit }}\n        />\n      </span>\n      <Rise index={index}>\n        {option.icon}\n        {option.label}\n      </Rise>\n    </DropdownMenuPrimitive.RadioItem>\n  )\n}\n\nfunction Submenu({ id, index, item, linkAs }: { id: string; index: number; item: Extract<MenuItem, { type: \"submenu\" }>; linkAs: React.ElementType }) {\n  const portalContainer = usePortalContainer()\n  const hot = useHot(id)\n  const { reduced } = React.useContext(HotContext)\n  return (\n    <DropdownMenuPrimitive.Sub>\n      <DropdownMenuPrimitive.SubTrigger disabled={item.disabled} {...hot} className={cn(itemClass, \"data-[state=open]:[&>[data-open-mark]]:opacity-100\")}>\n        <Pill id={id} />\n        {/* While its submenu is open, the trigger keeps a mark even when the pill has moved on into the submenu. */}\n        <span data-open-mark aria-hidden className=\"absolute inset-0 -z-20 rounded-sm bg-accent opacity-0\" />\n        <Rise index={index}>\n          {item.icon}\n          {item.label}\n          <ChevronRightIcon className=\"ms-auto rtl:-scale-x-100\" />\n        </Rise>\n      </DropdownMenuPrimitive.SubTrigger>\n      <DropdownMenuPrimitive.Portal container={portalContainer}>\n        <DropdownMenuPrimitive.SubContent asChild sideOffset={2}>\n          <motion.div\n            className={cn(panelClass, \"shadow-lg\")}\n            style={{ transformOrigin: \"var(--radix-dropdown-menu-content-transform-origin)\" }}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, x: -6 }}\n            animate={{ opacity: 1, scale: 1, x: 0 }}\n            transition={reduced ? { duration: duration.fast } : { ...spring.appear, opacity: { duration: duration.fast, ease: ease.enter } }}\n          >\n            <MenuPanel items={item.items} linkAs={linkAs} prefix={id} />\n          </motion.div>\n        </DropdownMenuPrimitive.SubContent>\n      </DropdownMenuPrimitive.Portal>\n    </DropdownMenuPrimitive.Sub>\n  )\n}\n\n/**\n * The kit's DropdownMenu with motion: the same items (plain, links, checkboxes, radios, rows, labels, separators,\n * submenus) and props. It springs out of its trigger from the side it opens on, its items rising in one after another;\n * the highlight is one pill gliding from item to item after the pointer or the arrow keys instead of jumping; a tick\n * draws itself, a radio's dot springs in, a reaction grows under the pointer, and a submenu slides out from its side.\n * It leaves quickly. With reduced motion it only fades and the highlight moves at once.\n */\nexport function DropdownMenu({ trigger, items, side = \"bottom\", align, sideOffset = 4, linkAs, onCloseAutoFocus, className, open, defaultOpen, onOpenChange, ...props }: DropdownMenuProps) {\n  const portalContainer = usePortalContainer()\n  const reduced = useReducedMotion()\n  // Kept here, so the panel can stay on screen while it leaves.\n  const [own, setOwn] = React.useState(defaultOpen ?? false)\n  const isOpen = open ?? own\n  const change = (next: boolean) => {\n    if (open === undefined) setOwn(next)\n    onOpenChange?.(next)\n  }\n  const from = reduced ? { opacity: 0 } : { opacity: 0, scale: 0.94, ...offsets[side] }\n\n  return (\n    <DropdownMenuPrimitive.Root data-slot=\"dropdown-menu\" open={isOpen} onOpenChange={change} {...props}>\n      <DropdownMenuPrimitive.Trigger asChild>{trigger}</DropdownMenuPrimitive.Trigger>\n      <AnimatePresence>\n        {isOpen && (\n          <DropdownMenuPrimitive.Portal forceMount container={portalContainer}>\n            <DropdownMenuPrimitive.Content\n              forceMount\n              asChild\n              side={side}\n              align={align}\n              sideOffset={sideOffset}\n              onCloseAutoFocus={onCloseAutoFocus}\n            >\n              <motion.div\n                data-slot=\"dropdown-menu-content\"\n                className={cn(panelClass, className)}\n                // It grows from the point facing its trigger (Radix works it out).\n                style={{ transformOrigin: \"var(--radix-dropdown-menu-content-transform-origin)\" }}\n                initial={from}\n                animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}\n                exit={{ ...from, transition: { duration: duration.fast, ease: ease.exit } }}\n                transition={reduced ? { duration: duration.fast } : { ...spring.appear, opacity: { duration: duration.fast, ease: ease.enter } }}\n              >\n                <MenuPanel items={items} linkAs={linkAs} prefix=\"m\" />\n              </motion.div>\n            </DropdownMenuPrimitive.Content>\n          </DropdownMenuPrimitive.Portal>\n        )}\n      </AnimatePresence>\n    </DropdownMenuPrimitive.Root>\n  )\n}\n"}],"cssVars":{"theme":{"color-destructive-foreground":"var(--destructive-foreground)","color-destructive-text":"var(--destructive-text)"},"light":{"destructive-foreground":"oklch(0.985 0 0)","destructive-text":"#bd2800"},"dark":{"destructive-foreground":"oklch(0.2 0 0)","destructive-text":"#ff8a5c"}}}