{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "native-action-dropdown-shadcnui", "type": "registry:component", "title": "Native Action Dropdown", "description": "A click-driven action picker whose multi-level submenu opens beside the row, jumps straight to the selected item, and drills in place with an animated breadcrumb at a fixed width.", "dependencies": [ "framer-motion", "react" ], "files": [ { "path": "@uitripled/react-shadcn/src/components/native/native-action-dropdown-shadcnui.tsx", "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\";\nimport { Check, ChevronDown, ChevronRight } from \"lucide-react\";\nimport {\n useCallback,\n useEffect,\n useId,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\n\nexport interface ActionDropdownNode {\n /** Unique id. */\n id: string;\n /** Display name. */\n name: string;\n /** Secondary line shown under the name. */\n detail?: string;\n /** Small pill badge next to the name. */\n meta?: string;\n /** Trigger subtitle shown when this leaf is the value. */\n summary?: string;\n /** Optional root-level group label (sections render in first-seen order). */\n group?: string;\n /** Nested children — a node with children is a submenu parent. */\n children?: ActionDropdownNode[];\n}\n\nexport interface NativeActionDropdownProps {\n /** The (recursive) option tree. */\n items: ActionDropdownNode[];\n /** Controlled selected id. */\n value?: string;\n /** Uncontrolled initial selected id. */\n defaultValue?: string;\n /** Fired with the selected leaf id. */\n onValueChange?: (id: string) => void;\n /** Panel header title. */\n label?: string;\n /** Panel header subtitle. */\n description?: string;\n /** Trigger text when nothing is selected. */\n placeholder?: string;\n className?: string;\n}\n\n// Walk the tree to the node with `id`, returning the root→node chain.\nfunction findChain(\n nodes: ActionDropdownNode[],\n id: string\n): ActionDropdownNode[] | null {\n for (const node of nodes) {\n if (node.id === id) return [node];\n if (node.children) {\n const sub = findChain(node.children, id);\n if (sub) return [node, ...sub];\n }\n }\n return null;\n}\n\nfunction resolveSelection(\n items: ActionDropdownNode[],\n id: string,\n placeholder: string\n): { name: string; summary: string } {\n const chain = findChain(items, id);\n if (!chain || chain.length === 0) return { name: placeholder, summary: \"\" };\n const leaf = chain[chain.length - 1];\n const parent = chain.length > 1 ? chain[chain.length - 2] : null;\n return {\n name: leaf.name,\n summary: parent ? parent.name : (leaf.summary ?? \"\"),\n };\n}\n\n// The selected value expressed as a column-stack (one active index per level).\nfunction colsForSelection(\n root: ActionDropdownNode[],\n items: ActionDropdownNode[],\n id: string\n): number[] {\n const chain = findChain(items, id);\n if (!chain) return [0];\n const cols: number[] = [];\n let level = root;\n for (const node of chain) {\n const idx = level.findIndex((item) => item.id === node.id);\n if (idx < 0) break;\n cols.push(idx);\n level = node.children ?? [];\n }\n return cols.length ? cols : [0];\n}\n\n// Resolve a column-stack into the concrete list + active index per level.\nfunction buildColumns(\n root: ActionDropdownNode[],\n cols: number[]\n): { items: ActionDropdownNode[]; active: number }[] {\n const result: { items: ActionDropdownNode[]; active: number }[] = [];\n let level = root;\n for (let d = 0; d < cols.length; d += 1) {\n if (!level.length) break;\n const active = Math.max(0, Math.min(level.length - 1, cols[d] ?? 0));\n result.push({ items: level, active });\n const node = level[active];\n if (!node?.children?.length) break;\n level = node.children;\n }\n if (!result.length) result.push({ items: root, active: 0 });\n return result;\n}\n\n// Motion spec — see animations.dev easing blueprint. Submenus enter/exit the\n// viewport → ease-out; springs only for the highlight + tactile bits.\nconst EASE_OUT = [0.22, 1, 0.36, 1] as const;\nconst PANEL_SPRING = { type: \"spring\", duration: 0.34, bounce: 0.16 } as const;\nconst PANEL_EXIT = { duration: 0.13, ease: EASE_OUT } as const;\nconst HIGHLIGHT_SPRING = {\n type: \"spring\",\n duration: 0.28,\n bounce: 0.2,\n} as const;\n\n// Beside-the-row submenu sizing, used for viewport collision checks.\nconst SUBMENU_W = 256; // w-64\nconst GAP = 8; // ml-2 / mr-2\ntype SubmenuSide = \"right\" | \"left\" | \"below\";\n\n// useLayoutEffect on the client (no flash), useEffect on the server.\nconst useIsoLayoutEffect =\n typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\nexport function NativeActionDropdown({\n items,\n value,\n defaultValue,\n onValueChange,\n label = \"Choose an option\",\n description,\n placeholder = \"Select…\",\n className,\n}: NativeActionDropdownProps) {\n const isControlled = value !== undefined;\n const [internalValue, setInternalValue] = useState(defaultValue ?? \"\");\n const selectedId = isControlled ? value : internalValue;\n\n const [isOpen, setIsOpen] = useState(false);\n const [cols, setCols] = useState([0]);\n // Collision-aware placement (recomputed on open / drill / resize).\n const [submenuSide, setSubmenuSide] = useState(\"right\");\n const [panelAbove, setPanelAbove] = useState(false);\n\n const listboxId = useId();\n const shouldReduceMotion = useReducedMotion();\n const reduce = shouldReduceMotion ?? false;\n\n const triggerRef = useRef(null);\n const listboxRef = useRef(null);\n\n // Root sections in first-seen order; flat root list keeps that order.\n const groupKeys = useMemo(() => {\n const keys: string[] = [];\n for (const item of items) {\n const key = item.group ?? \"\";\n if (!keys.includes(key)) keys.push(key);\n }\n return keys;\n }, [items]);\n const hasGroups = groupKeys.length > 1 || (groupKeys[0] ?? \"\") !== \"\";\n const orderedModes = useMemo(\n () =>\n groupKeys.flatMap((key) => items.filter((m) => (m.group ?? \"\") === key)),\n [groupKeys, items]\n );\n\n const selection = resolveSelection(items, selectedId, placeholder);\n const optionId = useCallback(\n (id: string) => `${listboxId}-${id}`,\n [listboxId]\n );\n\n const columns = useMemo(\n () => buildColumns(orderedModes, cols),\n [orderedModes, cols]\n );\n const base = useMemo(() => columns.map((column) => column.active), [columns]);\n const depth = columns.length - 1;\n const activeNode = columns[depth].items[base[depth]];\n\n // Ids on the path to the current value — flags which branches hold it.\n const selectedPath = useMemo(\n () => new Set((findChain(items, selectedId) ?? []).map((n) => n.id)),\n [items, selectedId]\n );\n\n // Cols for opening a root item's submenu: jump straight to the selected item\n // when it lives in this subtree, otherwise open its first level.\n const colsForRoot = useCallback(\n (rootIndex: number): number[] => {\n const chain = findChain(items, selectedId);\n if (\n chain &&\n chain.length > 1 &&\n chain[0].id === orderedModes[rootIndex]?.id\n ) {\n return colsForSelection(orderedModes, items, selectedId);\n }\n return [rootIndex, 0];\n },\n [items, orderedModes, selectedId]\n );\n\n const open = useCallback(() => {\n const chain = findChain(items, selectedId);\n const rootIdx = chain\n ? orderedModes.findIndex((mode) => mode.id === chain[0].id)\n : 0;\n setCols([rootIdx < 0 ? 0 : rootIdx]);\n setIsOpen(true);\n }, [items, orderedModes, selectedId]);\n\n const close = useCallback((returnFocus = true) => {\n setIsOpen(false);\n if (returnFocus) triggerRef.current?.focus();\n }, []);\n\n const handleSelect = useCallback(\n (id: string) => {\n if (!isControlled) setInternalValue(id);\n onValueChange?.(id);\n close();\n },\n [close, isControlled, onValueChange]\n );\n\n useEffect(() => {\n if (isOpen) listboxRef.current?.focus();\n }, [isOpen]);\n\n // Keep the panel and submenu inside the viewport: flip the panel above the\n // trigger near the bottom, and open the submenu left / below when there's no\n // room on the right (full-width \"below\" doubles as the small-screen layout).\n const computePlacement = useCallback(() => {\n if (typeof window === \"undefined\") return;\n const vw = window.innerWidth;\n const vh = window.innerHeight;\n\n const trigger = triggerRef.current?.getBoundingClientRect();\n if (trigger) {\n const roomBelow = vh - trigger.bottom;\n const roomAbove = trigger.top;\n setPanelAbove(\n roomBelow < Math.min(360, roomAbove) && roomAbove > roomBelow\n );\n }\n\n const panel = listboxRef.current?.getBoundingClientRect();\n if (panel) {\n const need = SUBMENU_W + GAP;\n if (vw < 480 || (vw - panel.right < need && panel.left < need)) {\n setSubmenuSide(\"below\");\n } else if (vw - panel.right >= need) {\n setSubmenuSide(\"right\");\n } else {\n setSubmenuSide(\"left\");\n }\n }\n }, []);\n\n useIsoLayoutEffect(() => {\n if (!isOpen) return;\n computePlacement();\n window.addEventListener(\"resize\", computePlacement);\n return () => window.removeEventListener(\"resize\", computePlacement);\n }, [isOpen, depth, computePlacement]);\n\n const handleTriggerKeyDown = (event: React.KeyboardEvent) => {\n if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n event.preventDefault();\n open();\n }\n };\n\n const handleListboxKeyDown = (event: React.KeyboardEvent) => {\n const last = depth;\n const levelItems = columns[last].items;\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n setCols([\n ...base.slice(0, last),\n Math.min(levelItems.length - 1, base[last] + 1),\n ]);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n setCols([...base.slice(0, last), Math.max(0, base[last] - 1)]);\n break;\n case \"Home\":\n event.preventDefault();\n setCols([...base.slice(0, last), 0]);\n break;\n case \"End\":\n event.preventDefault();\n setCols([...base.slice(0, last), levelItems.length - 1]);\n break;\n case \"ArrowRight\":\n if (activeNode?.children?.length) {\n event.preventDefault();\n setCols([...base, 0]);\n }\n break;\n case \"ArrowLeft\":\n if (depth > 0) {\n event.preventDefault();\n setCols(base.slice(0, -1));\n }\n break;\n case \"Enter\":\n case \" \":\n event.preventDefault();\n if (activeNode?.children?.length) setCols([...base, 0]);\n else if (activeNode) handleSelect(activeNode.id);\n break;\n case \"Escape\":\n event.preventDefault();\n if (depth > 0) setCols(base.slice(0, -1));\n else close();\n break;\n case \"Tab\":\n close(false);\n break;\n default:\n break;\n }\n };\n\n // Click drives everything: a parent opens its submenu (the root jumps\n // straight to the selected item if it lives in that subtree); a leaf selects.\n const handleClick = (columnIndex: number, itemIndex: number) => {\n const node = columns[columnIndex].items[itemIndex];\n if (node?.children?.length) {\n setCols(\n columnIndex === 0\n ? colsForRoot(itemIndex)\n : [...base.slice(0, columnIndex), itemIndex, 0]\n );\n } else if (node) {\n handleSelect(node.id);\n }\n };\n\n const renderOption = (\n node: ActionDropdownNode,\n columnIndex: number,\n itemIndex: number,\n staggerIndex?: number\n ) => {\n const hasChildren = (node.children?.length ?? 0) > 0;\n const isSelected = selectedId === node.id;\n const isActive = base[columnIndex] === itemIndex;\n const isOpenParent =\n isActive && hasChildren && columns.length > columnIndex + 1;\n const onSelectedPath = hasChildren && selectedPath.has(node.id);\n const staggered = staggerIndex !== undefined && !reduce;\n\n return (\n handleClick(columnIndex, itemIndex)}\n whileTap={reduce ? undefined : { scale: 0.99 }}\n initial={staggered ? { opacity: 0, y: 3 } : false}\n animate={staggered ? { opacity: 1, y: 0 } : undefined}\n transition={\n staggered\n ? {\n delay: 0.04 + staggerIndex * 0.03,\n duration: 0.16,\n ease: EASE_OUT,\n }\n : undefined\n }\n className=\"group relative block w-full cursor-pointer rounded-lg px-2.5 py-2 text-left outline-none transition-colors hover:bg-accent/40\"\n >\n {isActive ? (\n \n ) : null}\n\n \n \n \n \n {node.name}\n \n {node.meta ? (\n \n {node.meta}\n \n ) : null}\n \n {node.detail ? (\n \n {node.detail}\n \n ) : null}\n \n\n \n {hasChildren ? (\n <>\n {onSelectedPath ? (\n \n ) : null}\n \n \n ) : (\n \n {isSelected ? (\n \n \n \n ) : null}\n \n )}\n \n \n \n );\n };\n\n // One bounded submenu beside the open root row. It drills in place; the path\n // is shown as a breadcrumb (not stacked cards), so depth never widens it.\n // On small screens (\"below\") it renders IN FLOW as an accordion so it pushes\n // the remaining rows down instead of covering them.\n const renderSubmenu = () => {\n const parent =\n depth > 0 ? columns[depth - 1].items[base[depth - 1]] : undefined;\n const isInline = submenuSide === \"below\";\n\n if (isInline) {\n return (\n \n
\n {renderSubmenuInner()}\n
\n \n );\n }\n\n const sideClass =\n submenuSide === \"right\"\n ? \"left-full top-0 ml-2 w-64\"\n : \"right-full top-0 mr-2 w-64\";\n const sideOrigin = submenuSide === \"right\" ? \"left top\" : \"right top\";\n const enterX = reduce ? 0 : submenuSide === \"left\" ? 6 : -6;\n return (\n 0 ? 4 : -4,\n scale: reduce ? 1 : 0.98,\n transition: reduce\n ? { duration: 0 }\n : { duration: 0.1, ease: EASE_OUT },\n }}\n transition={\n reduce ? { duration: 0 } : { duration: 0.14, ease: EASE_OUT }\n }\n style={{ transformOrigin: sideOrigin }}\n className={cn(\n \"absolute z-50 rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-xl ring-1 ring-black/[0.02]\",\n sideClass\n )}\n >\n {renderSubmenuInner()}\n \n );\n };\n\n const renderSubmenuInner = () => (\n <>\n
\n \n {Array.from({ length: depth }).map((_, level) => {\n const crumb = columns[level].items[base[level]];\n const isLast = level === depth - 1;\n return (\n \n {level > 0 ? (\n \n ) : null}\n {isLast ? (\n \n {crumb?.name}\n \n ) : (\n setCols(base.slice(0, level + 2))}\n className=\"truncate rounded text-[11px] font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:text-foreground\"\n >\n {crumb?.name}\n \n )}\n \n );\n })}\n \n
\n\n
\n \n \n {columns[depth].items.map((node, itemIndex) =>\n renderOption(node, depth, itemIndex, itemIndex)\n )}\n \n \n
\n \n );\n\n const renderRootRow = (mode: ActionDropdownNode, flatIndex: number) => {\n const submenuOpen =\n base[0] === flatIndex && depth >= 1 && (mode.children?.length ?? 0) > 0;\n return (\n
\n {renderOption(mode, 0, flatIndex, flatIndex)}\n \n {submenuOpen ? renderSubmenu() : null}\n \n
\n );\n };\n\n return (\n
\n (isOpen ? close(false) : open())}\n onKeyDown={handleTriggerKeyDown}\n whileTap={reduce ? undefined : { scale: 0.985 }}\n transition={{ duration: 0.12, ease: EASE_OUT }}\n aria-expanded={isOpen}\n aria-haspopup=\"menu\"\n aria-controls={isOpen ? listboxId : undefined}\n className=\"flex w-full items-center gap-3 cursor-pointer rounded-lg border border-border bg-card px-4 py-2.5 text-left shadow-sm transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n >\n \n \n {selection.name}\n \n {selection.summary ? (\n \n {selection.summary}\n \n ) : null}\n \n \n \n \n \n\n \n {isOpen ? (\n <>\n close()}\n />\n\n \n
\n

{label}

\n {description ? (\n

\n {description}\n

\n ) : null}\n
\n\n {hasGroups ? (\n groupKeys.map((group) => (\n \n {group ? (\n \n {group}\n
\n ) : null}\n {orderedModes\n .map((mode, flatIndex) => ({ mode, flatIndex }))\n .filter(({ mode }) => (mode.group ?? \"\") === group)\n .map(({ mode, flatIndex }) =>\n renderRootRow(mode, flatIndex)\n )}\n \n ))\n ) : (\n
\n {orderedModes.map((mode, flatIndex) =>\n renderRootRow(mode, flatIndex)\n )}\n
\n )}\n\n \n \n Current:{\" \"}\n \n {selection.name}\n \n \n \n {[\"↑↓\", \"←\", \"→\", \"↵\"].map((key) => (\n \n {key}\n \n ))}\n \n \n \n \n ) : null}\n \n \n );\n}\n", "type": "registry:component", "target": "components/uitripled/native-action-dropdown-shadcnui.tsx" } ] }