Combobox
Searchable select built from Popover + Command.
Motion: Popover springs in; list filters instantly. Glass panel; selection haptic on pick.
Installation
npx shadcn@latest add @craft/comboboxSource
components/ui/command.tsx
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "cn"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { MagnifierIcon, CheckIcon } from "@solar-icons/react/linear"
import { haptic, useHapticHandler } from "@/lib/haptics"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-transparent p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<MagnifierIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
const onSelect = useHapticHandler(props.onSelect, haptic.toggle)
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
onSelect={onSelect}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
components/ui/popover.tsx
"use client"
import * as React from "react"
import { cn } from "cn"
import { Popover as PopoverPrimitive } from "radix-ui"
import { useMergedRef, useMorphOriginStore, useOriginMorph, type MorphOrigin } from "@/lib/morph"
type PopoverMorphContext = {
/** The trigger that opened the panel — it morphs out of it and back into it */
origin: MorphOrigin
open: boolean
/** Set by <PopoverAnchor>: the panel then opens beside the anchor instead of over the trigger */
anchored: boolean
setAnchored: (anchored: boolean) => void
}
const PopoverMorphContext = React.createContext<PopoverMorphContext | null>(null)
/**
* Glass panel that morphs out of its <PopoverTrigger>: by default it opens
* **in place**, over the trigger, which hides while it's open — the pill
* becomes the panel and folds back into it on close (lib/morph.ts). Radix
* keeps positioning: with no room below it grows upward, and it shifts to
* stay on screen. With a <PopoverAnchor> (e.g. DateInput) or
* `inPlace={false}` on the content, it opens beside the trigger instead.
*/
function Popover({
open: openProp,
defaultOpen = false,
onOpenChange,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
const origin = useMorphOriginStore()
// Tracked here so PopoverContent re-renders on open and can place itself over the trigger
const [inner, setInner] = React.useState(defaultOpen)
const open = openProp ?? inner
const [anchored, setAnchored] = React.useState(false)
const value = React.useMemo(() => ({ origin, open, anchored, setAnchored }), [origin, open, anchored])
return (
<PopoverMorphContext.Provider value={value}>
<PopoverPrimitive.Root
data-slot="popover"
open={open}
onOpenChange={(next) => {
setInner(next)
onOpenChange?.(next)
}}
{...props}
/>
</PopoverMorphContext.Provider>
)
}
function PopoverTrigger({
onClick,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
const ctx = React.useContext(PopoverMorphContext)
return (
<PopoverPrimitive.Trigger
data-slot="popover-trigger"
onClick={(e) => {
ctx?.origin.set(e.currentTarget)
onClick?.(e)
}}
{...props}
/>
)
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
inPlace = true,
ref,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
/** Open over the trigger (default) or beside it like a classic popover */
inPlace?: boolean
}) {
const ctx = React.useContext(PopoverMorphContext)
// `open` in context re-renders this on open; the trigger stays known after close, so the mode is stable while folding back
const trigger = ctx?.open !== undefined ? ctx.origin.get() : null
const overTrigger = inPlace && !ctx?.anchored && !!trigger?.isConnected
const morphRef = useOriginMorph(ctx?.origin ?? null, { waitForPosition: true, hideOrigin: overTrigger })
const mergedRef = useMergedRef(morphRef, ref)
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={mergedRef}
data-slot="popover-content"
data-in-place={overTrigger || undefined}
align={align}
// A negative offset of the trigger's height lays the panel over it (Radix flips this upward too)
sideOffset={overTrigger && trigger ? -trigger.offsetHeight : sideOffset}
className={cn(
"z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-2xl glass p-3 text-sm text-popover-foreground ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
const setAnchored = React.useContext(PopoverMorphContext)?.setAnchored
React.useEffect(() => {
setAnchored?.(true)
return () => setAnchored?.(false)
}, [setAnchored])
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverAnchor,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}