{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "smart-overflow",
  "type": "registry:ui",
  "title": "Smart Overflow",
  "description": "A priority-first responsive action group that moves lower-priority actions into an accessible three-dot menu as available width decreases.",
  "files": [
    {
      "path": "registry/sonaui/smart-overflow/smart-overflow.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { Menu } from \"@base-ui/react/menu\";\nimport { Ellipsis } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  MotionConfig,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  Children,\n  type CSSProperties,\n  Fragment,\n  isValidElement,\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/sona-utils\";\n\nexport interface SmartOverflowActionProps {\n  /** Stable identifier used for layout, focus recovery, and React rendering. */\n  id: string;\n  /** Visible label and optional rich content for the action. */\n  children: ReactNode;\n  /** Optional icon shown before the action label. @default undefined */\n  icon?: ReactNode;\n  /** Determines when the action moves into the three-dot menu. @default \"secondary\" */\n  priority?: \"primary\" | \"secondary\" | \"overflow\";\n  /** Applies destructive menu styling and separates the action from normal actions. @default false */\n  destructive?: boolean;\n  /** Whether the action is unavailable. @default false */\n  disabled?: boolean;\n  /** Called when the action is activated. @default undefined */\n  onSelect?: () => void;\n}\n\nexport interface SmartOverflowProps {\n  /** Priority-aware action declarations. Only direct SmartOverflowAction children are rendered. */\n  children: ReactNode;\n  /** Accessible label for the action group. @default \"Actions\" */\n  ariaLabel?: string;\n  /** Accessible label for the three-dot trigger. @default \"More actions\" */\n  moreLabel?: string;\n  /** Whether every action is unavailable. @default false */\n  disabled?: boolean;\n  /** Additional CSS classes for the group container. @default undefined */\n  className?: string;\n  /** Additional CSS classes applied to each visible action. @default undefined */\n  actionClassName?: string;\n  /** Additional CSS classes applied to the three-dot trigger. @default undefined */\n  moreButtonClassName?: string;\n  /** Additional CSS classes for the overflow menu. @default undefined */\n  menuClassName?: string;\n}\n\ninterface ResolvedAction extends SmartOverflowActionProps {}\n\nconst gap = 4;\nconst transition = { duration: 0.17, ease: [0.22, 1, 0.36, 1] } as const;\n\nfunction priorityWeight(priority: SmartOverflowActionProps[\"priority\"]) {\n  return priority === \"primary\" ? 1 : 0;\n}\n\nfunction ActionContents({ action }: { action: ResolvedAction }) {\n  return (\n    <>\n      {action.icon ? (\n        <span aria-hidden=\"true\" className=\"shrink-0 [&_svg]:size-4\">\n          {action.icon}\n        </span>\n      ) : null}\n      <span className=\"truncate\">{action.children}</span>\n    </>\n  );\n}\n\n/** Declares an action inside SmartOverflow. */\nexport function SmartOverflowAction(_props: SmartOverflowActionProps) {\n  return null;\n}\n\nexport default function SmartOverflow({\n  children,\n  ariaLabel = \"Actions\",\n  moreLabel = \"More actions\",\n  disabled = false,\n  className,\n  actionClassName,\n  moreButtonClassName,\n  menuClassName,\n}: SmartOverflowProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const rootRef = useRef<HTMLFieldSetElement>(null);\n  const measureRefs = useRef(new Map<string, HTMLSpanElement>());\n  const moreMeasureRef = useRef<HTMLSpanElement>(null);\n  const actions = useMemo<ResolvedAction[]>(\n    () =>\n      Children.toArray(children).flatMap((child) => {\n        if (!isValidElement<SmartOverflowActionProps>(child)) return [];\n        return child.type === SmartOverflowAction ? [child.props] : [];\n      }),\n    [children],\n  );\n  const [visibleIds, setVisibleIds] = useState(() =>\n    actions\n      .filter((action) => action.priority !== \"overflow\")\n      .map((action) => action.id),\n  );\n  const [isMeasured, setIsMeasured] = useState(false);\n  const [focusedActionId, setFocusedActionId] = useState<string | null>(null);\n  const layoutActions = useMemo(\n    () => actions.filter((action) => action.priority !== \"overflow\"),\n    [actions],\n  );\n\n  const updateLayout = useCallback(() => {\n    const root = rootRef.current;\n    if (!root || root.clientWidth === 0) return;\n\n    const actionWidths = new Map(\n      actions.map((action) => [\n        action.id,\n        measureRefs.current.get(action.id)?.offsetWidth ?? 0,\n      ]),\n    );\n    const visible = [...layoutActions];\n    const hidden = actions.filter((action) => action.priority === \"overflow\");\n    const moreWidth = moreMeasureRef.current?.offsetWidth ?? 36;\n    const requiredWidth = () => {\n      const visibleWidth = visible.reduce(\n        (total, action) => total + (actionWidths.get(action.id) ?? 0),\n        0,\n      );\n      const hasOverflow = hidden.length > 0;\n      const itemCount = visible.length + (hasOverflow ? 1 : 0);\n      return (\n        visibleWidth +\n        (hasOverflow ? moreWidth : 0) +\n        Math.max(0, itemCount - 1) * gap\n      );\n    };\n    const removable = [...visible].sort((a, b) => {\n      const priorityDifference =\n        priorityWeight(a.priority) - priorityWeight(b.priority);\n      return (\n        priorityDifference ||\n        layoutActions.indexOf(b) - layoutActions.indexOf(a)\n      );\n    });\n\n    while (requiredWidth() > root.clientWidth && removable.length > 0) {\n      const next = removable.shift();\n      if (!next) break;\n      const index = visible.findIndex((action) => action.id === next.id);\n      if (index >= 0) {\n        visible.splice(index, 1);\n        hidden.unshift(next);\n      }\n    }\n\n    const nextVisibleIds = visible.map((action) => action.id);\n    setVisibleIds((current) =>\n      current.length === nextVisibleIds.length &&\n      current.every((id, index) => id === nextVisibleIds[index])\n        ? current\n        : nextVisibleIds,\n    );\n    setIsMeasured(true);\n  }, [actions, layoutActions]);\n\n  useLayoutEffect(() => {\n    updateLayout();\n  }, [updateLayout]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const observer = new ResizeObserver(updateLayout);\n    observer.observe(root);\n    return () => observer.disconnect();\n  }, [updateLayout]);\n\n  useEffect(() => {\n    if (!focusedActionId || visibleIds.includes(focusedActionId)) return;\n    rootRef.current\n      ?.querySelector<HTMLButtonElement>('[data-slot=\"smart-overflow-trigger\"]')\n      ?.focus();\n  }, [focusedActionId, visibleIds]);\n\n  const visibleActions = actions.filter((action) =>\n    visibleIds.includes(action.id),\n  );\n  const hiddenActions = actions.filter(\n    (action) => !visibleIds.includes(action.id),\n  );\n  const motionTransition = shouldReduceMotion ? { duration: 0 } : transition;\n  if (actions.length === 0) return null;\n\n  return (\n    <MotionConfig reducedMotion=\"user\" transition={motionTransition}>\n      <fieldset\n        ref={rootRef}\n        aria-label={ariaLabel}\n        className={cn(\n          \"m-0 flex w-full min-w-0 items-center gap-1 border-0 p-0\",\n          className,\n        )}\n      >\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute -z-10 flex gap-1 opacity-0\"\n        >\n          {actions.map((action) => (\n            <span\n              key={action.id}\n              ref={(node) => {\n                if (node) measureRefs.current.set(action.id, node);\n                else measureRefs.current.delete(action.id);\n              }}\n              className={cn(\n                \"inline-flex h-9 items-center gap-2 whitespace-nowrap rounded-lg px-3 text-sm font-medium\",\n                actionClassName,\n              )}\n            >\n              <ActionContents action={action} />\n            </span>\n          ))}\n          <span\n            ref={moreMeasureRef}\n            className={cn(\n              \"grid size-9 place-items-center rounded-lg\",\n              moreButtonClassName,\n            )}\n          >\n            <Ellipsis aria-hidden=\"true\" className=\"size-4\" />\n          </span>\n        </div>\n\n        <div\n          className=\"flex min-w-0 items-center gap-1\"\n          style={\n            { visibility: isMeasured ? \"visible\" : \"hidden\" } as CSSProperties\n          }\n        >\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {visibleActions.map((action) => (\n              <motion.button\n                key={action.id}\n                layout=\"position\"\n                type=\"button\"\n                disabled={disabled || action.disabled}\n                data-slot=\"smart-overflow-action\"\n                data-action-id={action.id}\n                initial={\n                  shouldReduceMotion ? false : { opacity: 0, scale: 0.98 }\n                }\n                animate={{ opacity: 1, scale: 1 }}\n                exit={\n                  shouldReduceMotion ? undefined : { opacity: 0, scale: 0.98 }\n                }\n                onFocus={() => setFocusedActionId(action.id)}\n                onBlur={() => setFocusedActionId(null)}\n                onClick={action.onSelect}\n                className={cn(\n                  \"inline-flex h-9 min-w-0 shrink-0 items-center gap-2 rounded-lg px-3 text-sm font-medium text-foreground\",\n                  \"transition-[background-color,transform] duration-150 ease-out hover:bg-accent active:scale-[0.97]\",\n                  \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n                  \"disabled:pointer-events-none disabled:opacity-45 motion-reduce:transition-none\",\n                  actionClassName,\n                )}\n              >\n                <ActionContents action={action} />\n              </motion.button>\n            ))}\n            {hiddenActions.length > 0 ? (\n              <motion.div\n                key=\"smart-overflow-trigger\"\n                layout=\"position\"\n                initial={\n                  shouldReduceMotion ? false : { opacity: 0, scale: 0.96 }\n                }\n                animate={{ opacity: 1, scale: 1 }}\n                exit={\n                  shouldReduceMotion ? undefined : { opacity: 0, scale: 0.96 }\n                }\n              >\n                <Menu.Root>\n                  <Menu.Trigger\n                    aria-label={moreLabel}\n                    data-slot=\"smart-overflow-trigger\"\n                    disabled={disabled}\n                    className={cn(\n                      \"grid size-9 shrink-0 place-items-center rounded-lg text-muted-foreground\",\n                      \"transition-[background-color,transform] duration-150 ease-out hover:bg-accent hover:text-foreground active:scale-[0.97]\",\n                      \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n                      \"data-[popup-open]:bg-accent data-[popup-open]:text-foreground disabled:pointer-events-none disabled:opacity-45 motion-reduce:transition-none\",\n                      moreButtonClassName,\n                    )}\n                  >\n                    <Ellipsis aria-hidden=\"true\" className=\"size-4\" />\n                  </Menu.Trigger>\n                  <Menu.Portal>\n                    <Menu.Positioner\n                      side=\"bottom\"\n                      align=\"end\"\n                      sideOffset={6}\n                      className=\"z-50\"\n                    >\n                      <Menu.Popup\n                        className={cn(\n                          \"min-w-44 origin-[var(--transform-origin)] rounded-xl border border-border/60 bg-popover p-1 text-popover-foreground shadow-lg\",\n                          \"transition-[opacity,transform] starting:scale-95 starting:opacity-0 duration-150 motion-reduce:duration-0\",\n                          menuClassName,\n                        )}\n                      >\n                        {hiddenActions.map((action, index) => {\n                          const separatesDestructiveAction =\n                            action.destructive &&\n                            hiddenActions\n                              .slice(0, index)\n                              .some(\n                                (previousAction) => !previousAction.destructive,\n                              );\n                          return (\n                            <Fragment key={action.id}>\n                              {separatesDestructiveAction ? (\n                                <Menu.Separator className=\"mx-1 my-1 h-px bg-border/60\" />\n                              ) : null}\n                              <Menu.Item\n                                disabled={disabled || action.disabled}\n                                onClick={action.onSelect}\n                                className={cn(\n                                  \"flex cursor-pointer select-none items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm outline-none\",\n                                  \"data-[highlighted]:bg-accent disabled:cursor-not-allowed disabled:opacity-45\",\n                                  action.destructive\n                                    ? \"text-destructive data-[highlighted]:bg-destructive data-[highlighted]:text-destructive-foreground\"\n                                    : \"text-popover-foreground\",\n                                )}\n                              >\n                                <ActionContents action={action} />\n                              </Menu.Item>\n                            </Fragment>\n                          );\n                        })}\n                      </Menu.Popup>\n                    </Menu.Positioner>\n                  </Menu.Portal>\n                </Menu.Root>\n              </motion.div>\n            ) : null}\n          </AnimatePresence>\n        </div>\n      </fieldset>\n    </MotionConfig>\n  );\n}\n",
      "target": "components/ui/smart-overflow/smart-overflow.tsx"
    }
  ],
  "dependencies": [
    "@base-ui/react",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "@sona-ui/sona-utils"
  ]
}