{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "floating-shortcut-button",
  "title": "Floating Shortcut Button",
  "description": "A configurable floating action menu with coordinated size presets, a shrinking trigger, crossfading icon states, and staggered shortcuts.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "registry/base/ui/floating-shortcut-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion, type Variants } from \"motion/react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\n\ntype ButtonProps = React.ComponentProps<typeof Button>;\ntype ButtonClickEvent = Parameters<NonNullable<ButtonProps[\"onClick\"]>>[0];\ntype ButtonKeyDownEvent = Parameters<\n  NonNullable<ButtonProps[\"onKeyDown\"]>\n>[0];\nexport type FloatingShortcutTriggerProps = Omit<\n  ButtonProps,\n  | \"aria-controls\"\n  | \"aria-expanded\"\n  | \"aria-haspopup\"\n  | \"aria-label\"\n  | \"children\"\n  | \"ref\"\n  | \"role\"\n  | \"size\"\n  | \"style\"\n  | \"type\"\n  | \"variant\"\n>;\n\nexport type FloatingShortcutButtonSize = \"sm\" | \"md\" | \"lg\";\n\nexport type FloatingShortcutMetrics = {\n  triggerSize: number;\n  iconOnlyTriggerSize: number;\n  openTriggerSize: number;\n  actionSize: number;\n  triggerIconSize: number;\n  closeIconSize: number;\n  actionIconSize: number;\n  stackGap: number;\n  triggerGap: number;\n  rowGap: number;\n  captionGap: number;\n};\n\nexport type FloatingShortcutMotion = {\n  /** Total duration in milliseconds. Set to 0 to disable motion. */\n  duration: number;\n  /** Vertical travel used by action rows. */\n  distance: number;\n  /** Scale applied while an action is pressed. */\n  pressScale: number;\n};\n\nexport type FloatingShortcutButtonClassNames = {\n  root?: string;\n  menu?: string;\n  triggerSlot?: string;\n  trigger?: string;\n  triggerSurface?: string;\n  triggerFace?: string;\n  triggerIcon?: string;\n  caption?: string;\n  closeFace?: string;\n  actionRow?: string;\n  actionButton?: string;\n  actionIcon?: string;\n  actionLabel?: string;\n};\n\nexport const floatingShortcutSizePresets = {\n  sm: {\n    triggerSize: 48,\n    iconOnlyTriggerSize: 40,\n    openTriggerSize: 36,\n    actionSize: 40,\n    triggerIconSize: 18,\n    closeIconSize: 24,\n    actionIconSize: 18,\n    stackGap: 6,\n    triggerGap: 6,\n    rowGap: 6,\n    captionGap: 2,\n  },\n  md: {\n    triggerSize: 56,\n    iconOnlyTriggerSize: 48,\n    openTriggerSize: 40,\n    actionSize: 48,\n    triggerIconSize: 20,\n    closeIconSize: 28,\n    actionIconSize: 20,\n    stackGap: 8,\n    triggerGap: 8,\n    rowGap: 8,\n    captionGap: 2,\n  },\n  lg: {\n    triggerSize: 64,\n    iconOnlyTriggerSize: 56,\n    openTriggerSize: 44,\n    actionSize: 56,\n    triggerIconSize: 24,\n    closeIconSize: 32,\n    actionIconSize: 24,\n    stackGap: 10,\n    triggerGap: 10,\n    rowGap: 10,\n    captionGap: 3,\n  },\n} satisfies Record<FloatingShortcutButtonSize, FloatingShortcutMetrics>;\n\nexport const floatingShortcutMotionDefault = {\n  duration: 170,\n  distance: 8,\n  pressScale: 0.97,\n} satisfies FloatingShortcutMotion;\n\nconst ICON_SWAP_DELAY_RATIO = 0.5;\nconst ACTION_STAGGER_RATIO = 0.2;\nconst PRESS_DURATION_RATIO = 130 / 170;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\nfunction resolvePositive(value: number | undefined, fallback: number) {\n  return typeof value === \"number\" && Number.isFinite(value) && value > 0\n    ? value\n    : fallback;\n}\n\nfunction resolveNonNegative(value: number | undefined, fallback: number) {\n  return typeof value === \"number\" && Number.isFinite(value) && value >= 0\n    ? value\n    : fallback;\n}\n\nconst FOCUSABLE_SELECTOR = [\n  \"a[href]\",\n  \"button:not(:disabled)\",\n  \"input:not(:disabled)\",\n  \"select:not(:disabled)\",\n  \"textarea:not(:disabled)\",\n  '[tabindex]:not([tabindex=\"-1\"])',\n].join(\",\");\n\nfunction focusAdjacentToTrigger(\n  trigger: HTMLButtonElement | null,\n  direction: -1 | 1,\n) {\n  if (!trigger) return;\n\n  const focusableElements = Array.from(\n    document.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),\n  ).filter(\n    (element) =>\n      element.tabIndex >= 0 &&\n      !element.hasAttribute(\"hidden\") &&\n      !element.closest(\"[inert]\") &&\n      !element.closest('[aria-hidden=\"true\"]'),\n  );\n  const triggerIndex = focusableElements.indexOf(trigger);\n\n  if (triggerIndex === -1) {\n    trigger.focus();\n    return;\n  }\n\n  const target = focusableElements[triggerIndex + direction];\n\n  (target ?? trigger).focus();\n}\n\nfunction resolveMetrics(\n  size: FloatingShortcutButtonSize,\n  overrides?: Partial<FloatingShortcutMetrics>,\n): FloatingShortcutMetrics {\n  const preset = floatingShortcutSizePresets[size];\n\n  return {\n    triggerSize: resolvePositive(overrides?.triggerSize, preset.triggerSize),\n    iconOnlyTriggerSize: resolvePositive(\n      overrides?.iconOnlyTriggerSize,\n      preset.iconOnlyTriggerSize,\n    ),\n    openTriggerSize: resolvePositive(\n      overrides?.openTriggerSize,\n      preset.openTriggerSize,\n    ),\n    actionSize: resolvePositive(overrides?.actionSize, preset.actionSize),\n    triggerIconSize: resolvePositive(\n      overrides?.triggerIconSize,\n      preset.triggerIconSize,\n    ),\n    closeIconSize: resolvePositive(\n      overrides?.closeIconSize,\n      preset.closeIconSize,\n    ),\n    actionIconSize: resolvePositive(\n      overrides?.actionIconSize,\n      preset.actionIconSize,\n    ),\n    stackGap: resolveNonNegative(overrides?.stackGap, preset.stackGap),\n    triggerGap: resolveNonNegative(overrides?.triggerGap, preset.triggerGap),\n    rowGap: resolveNonNegative(overrides?.rowGap, preset.rowGap),\n    captionGap: resolveNonNegative(overrides?.captionGap, preset.captionGap),\n  };\n}\n\nfunction resolveMotion(\n  overrides?: Partial<FloatingShortcutMotion>,\n): FloatingShortcutMotion {\n  return {\n    duration: resolveNonNegative(\n      overrides?.duration,\n      floatingShortcutMotionDefault.duration,\n    ),\n    distance: resolveNonNegative(\n      overrides?.distance,\n      floatingShortcutMotionDefault.distance,\n    ),\n    pressScale: resolvePositive(\n      overrides?.pressScale,\n      floatingShortcutMotionDefault.pressScale,\n    ),\n  };\n}\n\nfunction createActionItemVariants(\n  motionConfig: FloatingShortcutMotion,\n  reduceMotion: boolean,\n  skipMotion = false,\n) {\n  const duration = skipMotion ? 0 : motionConfig.duration / 1000;\n  const openTransform = \"translate3d(0px, 0px, 0)\";\n  const closedTransform =\n    reduceMotion || skipMotion\n      ? openTransform\n      : `translate3d(0px, ${motionConfig.distance}px, 0)`;\n\n  return {\n    open: {\n      opacity: 1,\n      transform: openTransform,\n      transition: { duration, ease: EASE_OUT },\n    },\n    closed: {\n      opacity: 0,\n      transform: closedTransform,\n      transition: { duration, ease: EASE_OUT },\n    },\n  } satisfies Variants;\n}\n\nfunction createActionListVariants(duration: number) {\n  return {\n    open: {\n      transition: {\n        staggerChildren: (duration * ACTION_STAGGER_RATIO) / 1000,\n        staggerDirection: -1,\n      },\n    },\n    closed: {\n      transition: { staggerChildren: 0 },\n    },\n  } satisfies Variants;\n}\n\ntype FloatingShortcutContextValue = {\n  close: (skipMotion?: boolean) => void;\n  closeOnAction: boolean;\n  metrics: FloatingShortcutMetrics;\n  motion: FloatingShortcutMotion;\n  actionItemVariants: Variants;\n  classNames?: FloatingShortcutButtonClassNames;\n};\n\nconst FloatingShortcutContext = React.createContext<\n  FloatingShortcutContextValue | undefined\n>(undefined);\n\nfunction useFloatingShortcutContext() {\n  const context = React.useContext(FloatingShortcutContext);\n\n  if (!context) {\n    throw new Error(\n      \"FloatingShortcutAction must be used inside FloatingShortcutButton.\",\n    );\n  }\n\n  return context;\n}\n\nfunction ShortcutGridIcon() {\n  return (\n    <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" fill=\"none\">\n      <g transform=\"translate(2 2)\">\n        <path\n          fill=\"currentColor\"\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M6 11a3 3 0 0 1 3 3v3a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3v-3a3 3 0 0 1 3-3zm-3 2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm14-2a3 3 0 0 1 3 3v3a3 3 0 0 1-3 3h-3a3 3 0 0 1-3-3v-3a3 3 0 0 1 3-3zm-3 2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zM6.154.004A3 3 0 0 1 9 3v3a3 3 0 0 1-3 3H3A3 3 0 0 1 .004 6.154L0 6V3a3 3 0 0 1 3-3h3zM3 2a1 1 0 0 0-1 1v3l.005.103A1 1 0 0 0 3 7h3a1 1 0 0 0 1-1V3a1 1 0 0 0-.897-.995L6 2zM17.154.004A3 3 0 0 1 20 3v3a3 3 0 0 1-3 3h-3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h3zM14 2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V3a1 1 0 0 0-.898-.995L17 2z\"\n        />\n      </g>\n    </svg>\n  );\n}\n\nfunction ShortcutCloseIcon() {\n  return (\n    <svg\n      aria-hidden=\"true\"\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n    >\n      <path d=\"m5.5 5.5 13 13m0-13-13 13\" />\n    </svg>\n  );\n}\n\nexport type FloatingShortcutButtonProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"onChange\"\n> & {\n  /** FloatingShortcutAction elements revealed above the trigger. */\n  children: React.ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Initial open state for uncontrolled usage. */\n  defaultOpen?: boolean;\n  /** Called after the menu requests an open-state change. */\n  onOpenChange?: (open: boolean) => void;\n  /** Icon shown while the shortcut menu is closed. */\n  triggerIcon?: React.ReactNode;\n  /** Short caption shown under the trigger icon. Set to null to hide it. */\n  triggerCaption?: React.ReactNode;\n  /** Whether to render the trigger caption. Its text still labels the button. */\n  showTriggerCaption?: boolean;\n  /** Accessible name for the closed trigger. */\n  triggerLabel?: string;\n  /** Accessible name for the open trigger. */\n  closeLabel?: string;\n  /** Accessible name for the menu. */\n  menuLabel?: string;\n  /** Icon shown while the shortcut menu is open. */\n  closeIcon?: React.ReactNode;\n  /** Coordinated geometry preset. @default \"md\" */\n  size?: FloatingShortcutButtonSize;\n  /** Advanced geometry overrides. Invalid values fall back to the size preset. */\n  metrics?: Partial<FloatingShortcutMetrics>;\n  /** Motion overrides. Related delay and stagger values keep their ratios. */\n  motion?: Partial<FloatingShortcutMotion>;\n  /** Classes for styling individual component slots. */\n  classNames?: FloatingShortcutButtonClassNames;\n  /** Props forwarded to the trigger button. */\n  triggerProps?: FloatingShortcutTriggerProps;\n  /** Close the menu after an action runs. @default true */\n  closeOnAction?: boolean;\n};\n\nexport type FloatingShortcutActionProps = Omit<\n  ButtonProps,\n  | \"aria-label\"\n  | \"children\"\n  | \"role\"\n  | \"size\"\n  | \"style\"\n  | \"type\"\n  | \"variant\"\n> & {\n  /** Visible action label and accessible name. */\n  label: string;\n  /** Icon rendered inside the circular action button. */\n  icon: React.ReactNode;\n  /** Classes applied to the label-and-button row. */\n  rowClassName?: string;\n  /** Classes applied to this action's icon wrapper. */\n  iconClassName?: string;\n  /** Classes applied to this action's visible label. */\n  labelClassName?: string;\n  /** Override the root menu's close-on-action behavior. */\n  closeOnAction?: boolean;\n  /** Inline styles that do not replace geometry controlled by metrics. */\n  style?: React.CSSProperties;\n};\n\nexport function FloatingShortcutButton({\n  children,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  triggerIcon,\n  triggerCaption = \"Quick\",\n  showTriggerCaption = true,\n  triggerLabel,\n  closeLabel = \"Close shortcuts\",\n  menuLabel = \"Shortcuts\",\n  closeIcon,\n  size = \"md\",\n  metrics: metricsOverrides,\n  motion: motionOverrides,\n  classNames,\n  triggerProps,\n  closeOnAction = true,\n  className,\n  style,\n  onKeyDown,\n  ref,\n  ...props\n}: FloatingShortcutButtonProps) {\n  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n  const [skipMotion, setSkipMotion] = React.useState(false);\n  const triggerRef = React.useRef<HTMLButtonElement | null>(null);\n  const rootRef = React.useRef<HTMLDivElement | null>(null);\n  const pendingFocusRef = React.useRef<\"first\" | \"last\" | null>(null);\n  const menuId = React.useId();\n  const shouldReduceMotion = useReducedMotion() ?? false;\n  const resolvedMetrics = React.useMemo(\n    () => resolveMetrics(size, metricsOverrides),\n    [metricsOverrides, size],\n  );\n  const resolvedMotion = React.useMemo(\n    () => resolveMotion(motionOverrides),\n    [motionOverrides],\n  );\n  const actionListVariants = React.useMemo(\n    () => createActionListVariants(skipMotion ? 0 : resolvedMotion.duration),\n    [resolvedMotion.duration, skipMotion],\n  );\n  const actionItemVariants = React.useMemo(\n    () =>\n      createActionItemVariants(resolvedMotion, shouldReduceMotion, skipMotion),\n    [resolvedMotion, shouldReduceMotion, skipMotion],\n  );\n  const iconDelay = resolvedMotion.duration * ICON_SWAP_DELAY_RATIO;\n  const hasVisibleTriggerCaption =\n    showTriggerCaption && Boolean(triggerCaption);\n  const closedTriggerScale = hasVisibleTriggerCaption\n    ? 1\n    : Math.min(\n        resolvedMetrics.iconOnlyTriggerSize,\n        resolvedMetrics.triggerSize,\n      ) / resolvedMetrics.triggerSize;\n  const openScale =\n    resolvedMetrics.openTriggerSize / resolvedMetrics.triggerSize;\n  const controlled = openProp !== undefined;\n  const open = controlled ? openProp : internalOpen;\n  const captionLabel =\n    typeof triggerCaption === \"string\" ? triggerCaption.trim() : \"\";\n  const resolvedTriggerLabel =\n    triggerLabel?.trim() || captionLabel || \"Open shortcuts\";\n  const resolvedCloseLabel = closeLabel.trim() || \"Close shortcuts\";\n  const resolvedMenuLabel = menuLabel.trim() || \"Shortcuts\";\n  const {\n    className: triggerClassName,\n    disabled: triggerDisabled,\n    onClick: onTriggerClick,\n    onKeyDown: onTriggerKeyDown,\n    ...restTriggerProps\n  } = triggerProps ?? {};\n  const setOpen = React.useCallback(\n    (nextOpen: boolean, shouldSkipMotion = false) => {\n      setSkipMotion(shouldSkipMotion);\n\n      if (!controlled) {\n        setInternalOpen(nextOpen);\n      }\n\n      onOpenChange?.(nextOpen);\n    },\n    [controlled, onOpenChange],\n  );\n\n  React.useEffect(() => {\n    if (!skipMotion) return;\n\n    const frame = window.requestAnimationFrame(() => setSkipMotion(false));\n\n    return () => window.cancelAnimationFrame(frame);\n  }, [open, skipMotion]);\n\n  // The root node is needed internally (menu-item queries, outside-click) *and*\n  // by consumers, so the consumer's ref is merged in rather than overwritten.\n  const setRootRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      rootRef.current = node;\n\n      if (typeof ref === \"function\") {\n        ref(node);\n      } else if (ref) {\n        ref.current = node;\n      }\n    },\n    [ref],\n  );\n\n  const getEnabledItems = React.useCallback(\n    () =>\n      Array.from(\n        rootRef.current?.querySelectorAll<HTMLButtonElement>(\n          \"[role='menuitem']:not(:disabled)\",\n        ) ?? [],\n      ),\n    [],\n  );\n\n  const close = React.useCallback(\n    (shouldSkipMotion = false) => {\n      setOpen(false, shouldSkipMotion);\n      triggerRef.current?.focus();\n    },\n    [setOpen],\n  );\n\n  React.useEffect(() => {\n    if (!open) return;\n\n    // The ARIA menu-button pattern dismisses on outside interaction. Focus is\n    // intentionally left where the user clicked (unlike Escape, which returns\n    // it to the trigger).\n    const handlePointerDown = (event: PointerEvent) => {\n      const root = rootRef.current;\n\n      if (!root || !(event.target instanceof Node)) return;\n      if (root.contains(event.target)) return;\n\n      setOpen(false);\n    };\n\n    document.addEventListener(\"pointerdown\", handlePointerDown);\n\n    return () => document.removeEventListener(\"pointerdown\", handlePointerDown);\n  }, [open, setOpen]);\n\n  React.useEffect(() => {\n    if (!open || !pendingFocusRef.current) return;\n\n    const items = getEnabledItems();\n    const pendingFocus = pendingFocusRef.current;\n    pendingFocusRef.current = null;\n    const item = pendingFocus === \"first\" ? items[0] : items.at(-1);\n\n    item?.focus();\n  }, [getEnabledItems, open]);\n\n  const handleTriggerClick = (event: ButtonClickEvent) => {\n    onTriggerClick?.(event);\n\n    if (event.defaultPrevented || triggerDisabled) return;\n\n    if (!open) {\n      pendingFocusRef.current = \"first\";\n    }\n\n    setOpen(!open, event.detail === 0);\n  };\n\n  const handleTriggerKeyDown = (event: ButtonKeyDownEvent) => {\n    onTriggerKeyDown?.(event);\n\n    if (event.defaultPrevented || triggerDisabled) return;\n    if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") return;\n\n    event.preventDefault();\n    pendingFocusRef.current = event.key === \"ArrowDown\" ? \"first\" : \"last\";\n\n    if (open) {\n      const items = getEnabledItems();\n      const item = event.key === \"ArrowDown\" ? items[0] : items.at(-1);\n      pendingFocusRef.current = null;\n      item?.focus();\n      return;\n    }\n\n    setOpen(true, true);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n\n    if (event.defaultPrevented || !open) return;\n\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      close(true);\n      return;\n    }\n\n    if (event.key === \"Tab\") {\n      event.preventDefault();\n      setOpen(false, true);\n      focusAdjacentToTrigger(triggerRef.current, event.shiftKey ? -1 : 1);\n      return;\n    }\n\n    const items = getEnabledItems();\n    const activeIndex = items.indexOf(document.activeElement as HTMLButtonElement);\n\n    if (activeIndex === -1) return;\n\n    let nextIndex: number | undefined;\n\n    if (event.key === \"ArrowDown\") {\n      nextIndex = (activeIndex + 1) % items.length;\n    } else if (event.key === \"ArrowUp\") {\n      nextIndex = (activeIndex - 1 + items.length) % items.length;\n    } else if (event.key === \"Home\") {\n      nextIndex = 0;\n    } else if (event.key === \"End\") {\n      nextIndex = items.length - 1;\n    } else if (\n      event.key.length === 1 &&\n      !event.altKey &&\n      !event.ctrlKey &&\n      !event.metaKey\n    ) {\n      const query = event.key.toLocaleLowerCase();\n\n      for (let offset = 1; offset <= items.length; offset += 1) {\n        const candidateIndex = (activeIndex + offset) % items.length;\n        const candidateLabel =\n          items[candidateIndex]\n            ?.getAttribute(\"aria-label\")\n            ?.toLocaleLowerCase() ?? \"\";\n\n        if (candidateLabel.startsWith(query)) {\n          nextIndex = candidateIndex;\n          break;\n        }\n      }\n    }\n\n    if (nextIndex !== undefined) {\n      event.preventDefault();\n      items[nextIndex]?.focus();\n    }\n  };\n\n  const contextValue = React.useMemo<FloatingShortcutContextValue>(\n    () => ({\n      close,\n      closeOnAction,\n      metrics: resolvedMetrics,\n      motion: resolvedMotion,\n      actionItemVariants,\n      classNames,\n    }),\n    [\n      actionItemVariants,\n      classNames,\n      close,\n      closeOnAction,\n      resolvedMetrics,\n      resolvedMotion,\n    ],\n  );\n\n  return (\n    <FloatingShortcutContext.Provider value={contextValue}>\n      <div\n        {...props}\n        ref={setRootRef}\n        data-slot=\"floating-shortcut-button\"\n        data-state={open ? \"open\" : \"closed\"}\n        className={cn(\n          \"relative inline-flex flex-col items-end\",\n          classNames?.root,\n          className,\n        )}\n        style={{ gap: resolvedMetrics.triggerGap, ...style }}\n        onKeyDown={handleKeyDown}\n      >\n        <motion.div\n          id={menuId}\n          role=\"menu\"\n          aria-label={resolvedMenuLabel}\n          aria-hidden={!open}\n          inert={!open || undefined}\n          data-slot=\"floating-shortcut-menu\"\n          className={cn(\"flex flex-col items-end\", classNames?.menu)}\n          style={{ gap: resolvedMetrics.stackGap }}\n          variants={actionListVariants}\n          initial={false}\n          animate={open ? \"open\" : \"closed\"}\n        >\n          {children}\n        </motion.div>\n\n        <div\n          data-slot=\"floating-shortcut-trigger-slot\"\n          className={cn(\n            \"relative grid place-items-center\",\n            classNames?.triggerSlot,\n          )}\n          style={{\n            width: resolvedMetrics.triggerSize,\n            height: resolvedMetrics.triggerSize,\n          }}\n        >\n          <Button\n            {...restTriggerProps}\n            ref={triggerRef}\n            type=\"button\"\n            variant=\"link\"\n            size=\"icon-lg\"\n            disabled={triggerDisabled}\n            aria-label={open ? resolvedCloseLabel : resolvedTriggerLabel}\n            aria-expanded={open}\n            aria-haspopup=\"menu\"\n            aria-controls={menuId}\n            data-state={open ? \"open\" : \"closed\"}\n            onClick={handleTriggerClick}\n            onKeyDown={handleTriggerKeyDown}\n            className={cn(\n              // `touch-manipulation` opts out of double-tap-to-zoom, which is\n              // what makes mobile browsers hold a tap for ~300ms before firing\n              // click — the menu would otherwise open a beat after the press.\n              \"relative origin-center touch-manipulation rounded-full p-0 no-underline\",\n              classNames?.trigger,\n              triggerClassName,\n            )}\n            style={{\n              width: resolvedMetrics.triggerSize,\n              height: resolvedMetrics.triggerSize,\n            }}\n          >\n            <span\n              aria-hidden=\"true\"\n              data-slot=\"floating-shortcut-trigger-surface\"\n              className={cn(\n                \"pointer-events-none absolute inset-0 rounded-full bg-primary shadow-sm transition-[transform,background-color] group-hover/button:bg-primary/90 group-active/button:bg-primary/90 motion-reduce:transition-none\",\n                classNames?.triggerSurface,\n              )}\n              style={{\n                transform: `scale(${open ? openScale : closedTriggerScale})`,\n                transitionDuration: `${skipMotion ? 0 : resolvedMotion.duration}ms`,\n                transitionTimingFunction:\n                  \"cubic-bezier(0.77, 0, 0.175, 1)\",\n              }}\n            />\n          </Button>\n\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-0 grid place-items-center text-primary-foreground\"\n          >\n            <span\n              data-slot=\"floating-shortcut-trigger-face\"\n              className={cn(\n                \"col-start-1 row-start-1 flex flex-col items-center transition-[opacity,filter] motion-reduce:transition-none\",\n                open ? \"opacity-0 blur-xs\" : \"opacity-100 blur-none\",\n                classNames?.triggerFace,\n              )}\n              style={{\n                gap: resolvedMetrics.captionGap,\n                transitionDuration: `${skipMotion ? 0 : resolvedMotion.duration}ms`,\n                transitionDelay:\n                  skipMotion || open ? \"0ms\" : `${iconDelay}ms`,\n                transitionTimingFunction:\n                  \"cubic-bezier(0.23, 1, 0.32, 1)\",\n              }}\n            >\n              <span\n                data-slot=\"floating-shortcut-trigger-icon\"\n                className={cn(\n                  \"grid place-items-center [&_svg]:size-full\",\n                  classNames?.triggerIcon,\n                )}\n                style={{\n                  width: resolvedMetrics.triggerIconSize,\n                  height: resolvedMetrics.triggerIconSize,\n                }}\n              >\n                {triggerIcon ?? <ShortcutGridIcon />}\n              </span>\n              {showTriggerCaption && triggerCaption ? (\n                <span\n                  data-slot=\"floating-shortcut-caption\"\n                  className={cn(\n                    \"text-xs font-medium leading-none\",\n                    classNames?.caption,\n                  )}\n                >\n                  {triggerCaption}\n                </span>\n              ) : null}\n            </span>\n            <span\n              data-slot=\"floating-shortcut-close-face\"\n              className={cn(\n                \"col-start-1 row-start-1 grid place-items-center transition-[opacity,filter] motion-reduce:transition-none [&_svg]:size-full\",\n                open ? \"opacity-100 blur-none\" : \"opacity-0 blur-xs\",\n                classNames?.closeFace,\n              )}\n              style={{\n                width: resolvedMetrics.closeIconSize,\n                height: resolvedMetrics.closeIconSize,\n                transitionDuration: `${skipMotion ? 0 : resolvedMotion.duration}ms`,\n                transitionDelay:\n                  skipMotion || !open ? \"0ms\" : `${iconDelay}ms`,\n                transitionTimingFunction:\n                  \"cubic-bezier(0.23, 1, 0.32, 1)\",\n              }}\n            >\n              {closeIcon ?? <ShortcutCloseIcon />}\n            </span>\n          </div>\n        </div>\n      </div>\n    </FloatingShortcutContext.Provider>\n  );\n}\n\nexport function FloatingShortcutAction({\n  label,\n  icon,\n  rowClassName,\n  iconClassName,\n  labelClassName,\n  closeOnAction: closeOnActionProp,\n  className,\n  style,\n  onClick,\n  ...props\n}: FloatingShortcutActionProps) {\n  const context = useFloatingShortcutContext();\n  const metrics = context.metrics;\n  const motionConfig = context.motion;\n  const actionOffset = (metrics.triggerSize - metrics.actionSize) / 2;\n  const pressDuration = motionConfig.duration * PRESS_DURATION_RATIO;\n  const resolvedStyle: React.CSSProperties & {\n    \"--floating-shortcut-press-scale\": number;\n  } = {\n    ...style,\n    width: metrics.actionSize,\n    height: metrics.actionSize,\n    marginInlineEnd: actionOffset,\n    transitionDuration: `${pressDuration}ms`,\n    transitionTimingFunction: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n    \"--floating-shortcut-press-scale\": motionConfig.pressScale,\n  };\n\n  const handleClick = (event: ButtonClickEvent) => {\n    onClick?.(event);\n\n    if (\n      !event.defaultPrevented &&\n      (closeOnActionProp ?? context.closeOnAction)\n    ) {\n      context.close(event.detail === 0);\n    }\n  };\n\n  return (\n    <motion.div\n      role=\"none\"\n      data-slot=\"floating-shortcut-action-row\"\n      variants={context.actionItemVariants}\n      className={cn(\n        \"flex items-center\",\n        context.classNames?.actionRow,\n        rowClassName,\n      )}\n      style={{ gap: metrics.rowGap }}\n    >\n      <span\n        data-slot=\"floating-shortcut-action-label\"\n        className={cn(\n          \"text-sm font-medium text-foreground\",\n          context.classNames?.actionLabel,\n          labelClassName,\n        )}\n      >\n        {label}\n      </span>\n      <Button\n        {...props}\n        type=\"button\"\n        role=\"menuitem\"\n        variant=\"ghost\"\n        size=\"icon-lg\"\n        aria-label={label}\n        tabIndex={-1}\n        onClick={handleClick}\n        className={cn(\n          \"touch-manipulation rounded-full border-border bg-card text-card-foreground shadow-sm transition-[transform,background-color] hover:bg-accent active:scale-[var(--floating-shortcut-press-scale)] active:bg-accent/70 disabled:opacity-40 motion-reduce:transform-none motion-reduce:transition-none\",\n          context.classNames?.actionButton,\n          className,\n        )}\n        style={resolvedStyle}\n      >\n        <span\n          aria-hidden=\"true\"\n          data-icon\n          data-slot=\"floating-shortcut-action-icon\"\n          className={cn(\n            \"grid place-items-center [&_svg]:size-full\",\n            context.classNames?.actionIcon,\n            iconClassName,\n          )}\n          style={{ width: metrics.actionIconSize, height: metrics.actionIconSize }}\n        >\n          {icon}\n        </span>\n      </Button>\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@ui/floating-shortcut-button.tsx"
    }
  ],
  "meta": {
    "tags": [
      "floating-action-button",
      "shortcut-menu",
      "quick-actions",
      "customizable",
      "size-presets",
      "optional-caption",
      "controlled",
      "keyboard-navigation",
      "typeahead",
      "custom-close-icon",
      "per-action-close",
      "button",
      "menu"
    ],
    "effects": [
      "scale",
      "icon-swap",
      "stagger",
      "fade",
      "reduced-motion"
    ]
  },
  "categories": [
    "overlay"
  ],
  "type": "registry:ui"
}