fluid
uidropdownsource: ok · hit
Dropdown
Dropdown menu with fluid hover, animated selection indicator, inline panel or triggered popup with collision-aware positioning, typeahead, and close-on-select. Includes MenuItem, DropdownSearch (a search field inside the popup), and multiple selection with merged backgrounds. Radix flavor.
npx shadcn add "https://www.fluidfunctionalism.com/r/dropdown.json"free
Interaction
modaldropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumui-primitives@radix-ui/react-dropdown-menu
animationframer-motion
Registry dependencies
utilshttps://www.fluidfunctionalism.com/r/springs.jsonhttps://www.fluidfunctionalism.com/r/font-weight.jsonhttps://www.fluidfunctionalism.com/r/shape-context.jsonhttps://www.fluidfunctionalism.com/r/surface-context.jsonhttps://www.fluidfunctionalism.com/r/surface-classes.jsonhttps://www.fluidfunctionalism.com/r/icon-context.jsonhttps://www.fluidfunctionalism.com/r/use-fluid-hover.jsonhttps://www.fluidfunctionalism.com/r/use-merge-split.jsonhttps://www.fluidfunctionalism.com/r/elevated.jsonhttps://www.fluidfunctionalism.com/r/scroll-area.jsonhttps://www.fluidfunctionalism.com/r/popup.jsonhttps://www.fluidfunctionalism.com/r/size-context.jsonhttps://www.fluidfunctionalism.com/r/tokens.jsonhttps://www.fluidfunctionalism.com/r/type-scale.json
Files4 · source cached 10/15/2026, 12:02:39 AM
registry/radix/dropdown.tsx
"use client";
import {
useRef,
useState,
useEffect,
useCallback,
useMemo,
createContext,
useContext,
forwardRef,
cloneElement,
type ReactNode,
type ReactElement,
type HTMLAttributes,
type ComponentPropsWithoutRef,
} from "react";
import { motion, AnimatePresence } from "motion/react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
import { spring, exitFallbackMs } from "@/lib/springs";
import { useFluidHover, isOwnEvent } from "@/hooks/use-fluid-hover";
import {
useMergeSplitBlocks,
useSelectionRuns,
SelectionBackgrounds,
} from "@/hooks/use-merge-split";
import { shapeMap } from "@/lib/shape-context";
import { SizeProvider, useSize, typeClass, type SizeVariant } from "@/lib/size-context";
import { Elevated } from "@/lib/elevated";
import {
popupMotionClass,
popupScrollAreaClass,
popupViewportClass,
isDisabledRow,
} from "@/lib/popup";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
DropdownSearch,
DropdownEmpty,
DropdownSearchHostContext,
useDropdownSearchHost,
type DropdownSearchProps,
} from "@/components/ui/dropdown-search";
import {
DropdownContext,
MenuItem,
useControllableOpen,
useDropdown,
useDropdownMaybe,
type DropdownContextValue,
type MenuItemRenderOptions,
} from "@/components/ui/menu-item";
import { FluidHoverHighlight } from "@/components/ui/fluid-hover-highlight";
import {
SUBMENU_SIDE_OFFSET,
SUBMENU_ALIGN_OFFSET,
useSubmenuHost,
useReportSubmenu,
SubmenuChevron,
type DropdownSubTriggerProps,
} from "@/components/ui/dropdown-sub";
// Dropdown opts out of the global pill/rounded shape context — popover surfaces
// look cleaner with the smaller "rounded" radii regardless of how the rest of
// the UI is shaped (the heavy pill bubbling distorts perceived padding at this
// scale and produces the corner-shadow asymmetry).
const shape = shapeMap.rounded;
// ---------------------------------------------------------------------------
// Panel context — shared by the inline Dropdown and the popup DropdownContent.
//
// The context object itself lives in menu-item.tsx so MenuItem resolves
// whichever dropdown provider actually wraps it, even when dropdowns built
// on different primitives render side by side. Re-exported here so the
// public dropdown API is unchanged.
// ---------------------------------------------------------------------------
export { useDropdown, useDropdownMaybe };
export type { DropdownContextValue, MenuItemRenderOptions };
// ---------------------------------------------------------------------------
// Dropdown (inline panel)
//
// An always-rendered panel — no trigger, positioning, or dismissal. Because it
// sits statically in the page it does NOT claim popup menu semantics: the
// container is a plain role="group" (pass `aria-label` to name it). The real
// role="menu" lives on the popup DropdownContent below, which Radix wires to
// a trigger. Consumers who hand-roll a trigger around the inline panel get
// grouping semantics rather than a falsely-announced popup menu.
// ---------------------------------------------------------------------------
interface DropdownProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
checkedIndex?: number;
/** Multiple selection: the checked rows. Rows become checkbox items and
* contiguous runs share one merged background (see CheckboxGroup). */
checkedIndices?: number[];
/** Pins the panel's rows to one step of the size ladder (default 36px,
* compact 28px — see /docs/sizes). Omitted, they follow the surrounding
* SizeProvider. */
size?: SizeVariant;
}
const Dropdown = forwardRef<HTMLDivElement, DropdownProps>(
({ children, checkedIndex, checkedIndices, size, className, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const hover = useFluidHover(containerRef, { isItemDisabled: isDisabledRow });
const {
activeIndex,
setActiveIndex,
itemRects,
handlers,
registerItem,
} = hover;
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const multiple = checkedIndices != null;
const checkedRect =
!multiple && checkedIndex != null ? itemRects[checkedIndex] : null;
const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;
// Multiple: one merged block per contiguous run of checked rows.
const runs = useSelectionRuns(checkedIndices ?? []);
const blocks = useMergeSplitBlocks(runs, itemRects, shape.bgRadius);
const panelCtx = useMemo(
() => ({ registerItem, activeIndex, checkedIndex, multiple, checkedIndices }),
[registerItem, activeIndex, checkedIndex, multiple, checkedIndices]
);
const panel = (
<DropdownContext.Provider value={panelCtx}>
<Elevated
offset={2}
shadowLevel={3}
ref={(node) => {
(containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}}
onMouseEnter={handlers.onMouseEnter}
onMouseMove={handlers.onMouseMove}
onMouseLeave={handlers.onMouseLeave}
onClick={handlers.onClick}
onFocus={(e) => {
const indexAttr = (e.target as HTMLElement)
.closest("[data-fluid-hover-index]")
?.getAttribute("data-fluid-hover-index");
if (indexAttr != null) {
const idx = Number(indexAttr);
setActiveIndex(idx);
setFocusedIndex(
(e.target as HTMLElement).matches(":focus-visible") ? idx : null
);
}
}}
onBlur={(e) => {
if (containerRef.current?.contains(e.relatedTarget as Node)) return;
setFocusedIndex(null);
setActiveIndex(null);
}}
onKeyDown={(e) => {
const items = Array.from(
containerRef.current?.querySelectorAll(
'[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]'
) ?? []
) as HTMLElement[];
const currentIdx = items.indexOf(e.target as HTMLElement);
if (currentIdx === -1) return;
if (["ArrowDown", "ArrowUp", "ArrowRight", "ArrowLeft"].includes(e.key)) {
e.preventDefault();
const next = ["ArrowDown", "ArrowRight"].includes(e.key)
? (currentIdx + 1) % items.length
: (currentIdx - 1 + items.length) % items.length;
items[next].focus();
} else if (e.key === "Home") {
e.preventDefault();
items[0]?.focus();
} else if (e.key === "End") {
e.preventDefault();
items[items.length - 1]?.focus();
}
}}
role="group"
className={cn(
`relative flex flex-col w-72 max-w-full ${shape.container} p-1 select-none`,
className
)}
{...props}
>
{/* Selected backgrounds — merged runs in multiple mode */}
{multiple && <SelectionBackgrounds blocks={blocks} />}
{/* Selected background */}
<AnimatePresence>
{checkedRect && (
<motion.div
className={`absolute ${shape.bg} bg-active pointer-events-none`}
initial={false}
animate={{
top: checkedRect.top,
left: checkedRect.left,
width: checkedRect.width,
height: checkedRect.height,
opacity: 1,
}}
exit={{ opacity: 0, transition: spring.moderate.exit }}
transition={{
...spring.moderate,
opacity: { duration: 0.08 },
}}
/>
)}
</AnimatePresence>
{/* Hover background */}
<FluidHoverHighlight
hover={hover}
from={checkedRect}
className={shape.bg}
/>
{/* Focus ring */}
<AnimatePresence>
{focusRect && (
<motion.div
className={`absolute ${shape.focusRing} pointer-events-none z-20 border border-[color:var(--focus-ring,#6B97FF)]`}
initial={false}
animate={{
left: focusRect.left - 2,
top: focusRect.top - 2,
width: focusRect.width + 4,
height: focusRect.height + 4,
}}
exit={{ opacity: 0, transition: spring.fast.exit }}
transition={{
...spring.fast,
opacity: { duration: 0.08 },
}}
/>
)}
</AnimatePresence>
{children}
</Elevated>
</DropdownContext.Provider>
);
// A size prop pins every row in the panel to one ladder step.
return size ? <SizeProvider size={size}>{panel}</SizeProvider> : panel;
}
);
Dropdown.displayName = "Dropdown";
// ---------------------------------------------------------------------------
// DropdownMenu (popup root)
//
// Built on Radix's DropdownMenu primitive, which owns the trigger wiring,
// positioning (collision flipping, anchor tracking), dismissal (outside
// press, focus-out, Escape), roving highlight, typeahead, and close-on-select.
// This layer keeps the fluid-hover overlays and the
// spring open/close animation. Radix has no actionsRef-style deferred unmount,
// so the portal lifetime is managed with local `mounted` state (the same
// pattern the Dialog and MobileDrawer components use).
// ---------------------------------------------------------------------------
interface DropdownMenuContextValue {
open: boolean;
disabled: boolean;
/** Set by DropdownSub: the content is a submenu. */
sub: boolean;
}
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
function useDropdownMenuContext() {
const ctx = useContext(DropdownMenuContext);
if (!ctx)
throw new Error(
"DropdownMenu compound components must be inside <DropdownMenu>"
);
return ctx;
}
interface DropdownMenuProps {
children: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
disabled?: boolean;
/** Pins trigger-side content and the portalled popup rows to one step of
* the size ladder (default 36px, compact 28px — see /docs/sizes).
* Omitted, they follow the surrounding SizeProvider. */
size?: SizeVariant;
}
function DropdownMenu({
children,
open: openProp,
defaultOpen = false,
onOpenChange,
disabled = false,
size,
}: DropdownMenuProps) {
const [open, handleOpenChange] = useControllableOpen(openProp, defaultOpen, onOpenChange);
const ctx = useMemo(() => ({ open, disabled, sub: false }), [open, disabled]);
// A size prop pins the whole compound (trigger content + portalled popup —
// React context crosses portals) to one ladder step.
const root = (
<DropdownMenuContext.Provider value={ctx}>
{/* Root is always controlled by `open` (defaultOpen seeds local state
instead of being forwarded), so DropdownContent can drive the exit
animation before the portal unmounts. Non-modal: the page keeps
scrolling and the popup tracks its anchor instead of detaching. */}
<DropdownMenuPrimitive.Root
open={open}
onOpenChange={handleOpenChange}
modal={false}
>
{children}
</DropdownMenuPrimitive.Root>
</DropdownMenuContext.Provider>
);
return size ? <SizeProvider size={size}>{root}</SizeProvider> : root;
}
DropdownMenu.displayName = "DropdownMenu";
// ---------------------------------------------------------------------------
// DropdownTrigger
//
// Radix's DropdownMenu.Trigger behind a Base-UI-style `render` prop, so
// any element can be the trigger with the same public API:
//
// <DropdownTrigger render={<Button variant="secondary">Open</Button>} />
//
// `render` maps to Radix's asChild composition. Root-level `disabled` parity
// with Base UI's Menu.Root: the flag flows through context onto the trigger.
// ---------------------------------------------------------------------------
interface DropdownTriggerProps
extends Omit<
ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>,
"asChild"
> {
/** Element to render as the trigger (Base-UI-style `render` composition
* API). */
render?: ReactElement;
}
const DropdownTrigger = forwardRef<HTMLButtonElement, DropdownTriggerProps>(
({ render, children, disabled, ...props }, ref) => {
const { disabled: rootDisabled } = useDropdownMenuContext();
const isDisabled = disabled || rootDisabled;
if (render) {
return (
<DropdownMenuPrimitive.Trigger
ref={ref}
asChild
disabled={isDisabled}
{...props}
>
{render}
</DropdownMenuPrimitive.Trigger>
);
}
return (
<DropdownMenuPrimitive.Trigger ref={ref} disabled={isDisabled} {...props}>
{children}
</DropdownMenuPrimitive.Trigger>
);
}
);
DropdownTrigger.displayName = "DropdownTrigger";
// ---------------------------------------------------------------------------
// DropdownContent (popup panel)
//
// Portal > Content carrying the exact inline-panel visuals: Elevated surface,
// fluid-hover overlays, animated selected background, and animated focus
// ring. Children are wrapped in a RadioGroup so radio-style MenuItems
// (boolean `checked`) get correct aria-checked from `checkedIndex` (Radix
// radio values are strings, so the index maps through String()).
// ---------------------------------------------------------------------------
type RadixContentProps = ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.Content
>;
type LitBy = "open" | "pointer" | "keyboard";
interface DropdownContentProps {
children: ReactNode;
className?: string;
/** Index of the checked item. Drives the animated selected background and
* the radio-group value announced to assistive tech. */
checkedIndex?: number;
/** Multiple selection: the checked rows. Rows become checkbox items that
* keep the menu open when toggled, and contiguous runs share one merged
* background (see CheckboxGroup). */
checkedIndices?: number[];
side?: RadixContentProps["side"];
align?: RadixContentProps["align"];
sideOffset?: number;
/** Shift along the trigger edge, in px. */
alignOffset?: number;
}
const DropdownContent = forwardRef<HTMLDivElement, DropdownContentProps>(
(
{
className,
children,
checkedIndex,
checkedIndices,
side = "bottom",
align = "start",
sideOffset = 6,
alignOffset = 0,
},
ref
) => {
const { open, sub } = useDropdownMenuContext();
const containerRef = useRef<HTMLDivElement>(null);
const hover = useFluidHover(containerRef, { isItemDisabled: isDisabledRow });
const {
activeIndex,
setActiveIndex,
itemRects,
handlers,
registerItem,
remeasure,
} = hover;
// Submenus opened from these rows: their trigger stays lit while the
// pointer crosses to them (see dropdown-sub.tsx).
const submenus = useSubmenuHost(containerRef, hover, open);
// An optional DropdownSearch child: typing on a focused row is
// redirected into the field. (The field takes focus itself, a frame
// after the primitive's own open autofocus.)
const {
host: searchHost,
hasSearch,
searchTakesFocus,
searchMounted,
onKeyDownCapture: redirectTypingToSearch,
} = useDropdownSearchHost(open);
// Open ready to act: focus the first enabled row (a mounted search field
// takes focus itself instead). A frame after the primitive's own open
// autofocus, which lands on the popup for pointer opens. Not for a
// submenu: hovering its trigger must leave focus in the parent, and a
// keyboard open already lands on its first row.
useEffect(() => {
if (!open || sub) return;
let inner: number | undefined;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => {
if (hasSearch()) return;
const container = containerRef.current;
if (!container || container.contains(document.activeElement) && document.activeElement !== container) return;
const first = container.querySelector<HTMLElement>(
'[role="menuitem"]:not([aria-disabled="true"]), [role="menuitemradio"]:not([aria-disabled="true"]), [role="menuitemcheckbox"]:not([aria-disabled="true"])'
);
first?.focus();
});
});
return () => {
cancelAnimationFrame(outer);
if (inner !== undefined) cancelAnimationFrame(inner);
};
}, [open, sub, hasSearch]);
// Portal lifetime: mounts as soon as `open` flips true; on close it stays
// mounted (forceMount below) until the exit tween finishes.
const [mounted, setMounted] = useState(false);
// Set during the render that opens it, so the portal mounts in the same
// commit as `open` rather than one after.
if (open && !mounted) setMounted(true);
// Fallback release for the deferred unmount: onAnimationComplete on the
// motion.div is the primary signal, but rAF-driven animation callbacks
// can stall in throttled/background tabs. The popup exits with
// spring.fast, so the fallback tracks that tier's exit duration plus a
// safety buffer.
useEffect(() => {
if (open) return;
const id = setTimeout(() => setMounted(false), exitFallbackMs(spring.fast));
return () => clearTimeout(id);
}, [open]);
// The popup keeps its rows registered between opens, so their rects
// were taken while it was hidden: re-measure once it is open and laid out.
useEffect(() => {
if (!open || !mounted) return;
remeasure();
}, [open, mounted, remeasure]);
const multiple = checkedIndices != null;
const checkedRect =
!multiple && checkedIndex != null ? itemRects[checkedIndex] : null;
// What lights the highlight: the open itself (its first-row focus), the
// pointer, or the keyboard. A highlight that appears from nothing fades
// in at `from` and glides to its row. That suits the pointer entering the
// list (it rises from the checked row toward the cursor), not a row lit
// by the open or by arrowing in from the search field: those would slide
// over from the checked row, so they fade in where they are.
// The handlers read the ref: a key press updates it before the focus it
// moves arrives, in the same event. The render reads the state, which
// the highlight's `from` depends on. Each open starts over as "open".
const litByRef = useRef<LitBy>("open");
const [litBy, setLitBy] = useState<LitBy>("open");
const [litByOpen, setLitByOpen] = useState(open);
if (litByOpen !== open) {
setLitByOpen(open);
if (open) setLitBy("open");
}
useEffect(() => {
if (open) litByRef.current = "open";
}, [open]);
const markLitBy = (by: LitBy) => {
litByRef.current = by;
setLitBy(by);
};
// Multiple: one merged block per contiguous run of checked rows.
const runs = useSelectionRuns(checkedIndices ?? []);
const blocks = useMergeSplitBlocks(runs, open ? itemRects : [], shape.bgRadius);
// Inside the popup, Radix's Item / RadioItem own the role, aria-checked,
// tabIndex, roving highlight, typeahead, and Enter/Space/click activation
// (keyboard activation synthesizes a click, so onActivate on the item also
// fires for keyboard). The styled div composes via asChild, with the row
// content cloned back in as its children.
const renderMenuItem = useCallback(
({
radio,
checkbox,
checked,
value,
disabled,
label,
closeOnClick,
onActivate,
element,
children,
}: MenuItemRenderOptions) => {
const commonProps = {
asChild: true,
disabled,
textValue: label,
onClick: onActivate,
// Radix closes the menu on select by default; preventing the select
// event keeps it open — Base UI's closeOnClick={false} parity.
onSelect: closeOnClick
? undefined
: (event: Event) => event.preventDefault(),
};
const item = cloneElement(element, {}, children);
if (checkbox) {
// onActivate toggles the consumer state; the primitive only owns
// the role, aria-checked, and keyboard activation.
return (
<DropdownMenuPrimitive.CheckboxItem checked={!!checked} {...commonProps}>
{item}
</DropdownMenuPrimitive.CheckboxItem>
);
}
return radio ? (
<DropdownMenuPrimitive.RadioItem value={String(value)} {...commonProps}>
{item}
</DropdownMenuPrimitive.RadioItem>
) : (
<DropdownMenuPrimitive.Item {...commonProps}>
{item}
</DropdownMenuPrimitive.Item>
);
},
[]
);
const contentCtx = useMemo(
() => ({
registerItem,
activeIndex,
checkedIndex,
multiple,
checkedIndices,
inMenu: true,
renderMenuItem,
onSubmenuOpenChange: submenus.onSubmenuOpenChange,
}),
[
registerItem,
activeIndex,
checkedIndex,
multiple,
checkedIndices,
renderMenuItem,
submenus.onSubmenuOpenChange,
]
);
if (!mounted) return null;
// A submenu's content is Radix's SubContent, which takes the same
// props but places itself: the reading direction's end, aligned to the
// start of its row (it overrides side and align).
const Content = (
sub ? DropdownMenuPrimitive.SubContent : DropdownMenuPrimitive.Content
) as typeof DropdownMenuPrimitive.Content;
return (
<DropdownMenuPrimitive.Portal forceMount>
<Content
asChild
forceMount
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
>
<motion.div
className={cn("z-50 outline-none", popupMotionClass)}
initial={{ opacity: 0, y: "var(--popup-enter-y)", scaleY: 0.96 }}
animate={
open
? { opacity: 1, y: 0, scaleY: 1 }
: { opacity: 0, y: "var(--popup-enter-y)", scaleY: 0.96 }
}
transition={open ? spring.fast : spring.fast.exit}
// Release the deferred unmount once the exit spring has finished
// so the close animation fully plays.
onAnimationComplete={() => {
if (!open) setMounted(false);
}}
>
<DropdownContext.Provider value={contentCtx}>
<DropdownSearchHostContext.Provider value={searchHost}>
<Elevated
offset={2}
shadowLevel={3}
ref={ref}
// The handlers below skip events from an open submenu: it
// renders through a portal, so its events bubble here
// through React, and focus on a submenu row would light the
// parent row with the same index. The gap click already
// ignores them in the hook.
onKeyDownCapture={(e) => {
if (!isOwnEvent(e)) return;
markLitBy("keyboard");
redirectTypingToSearch(e);
}}
onMouseEnter={(e) => {
if (!isOwnEvent(e)) return;
markLitBy("pointer");
submenus.onMouseEnter();
}}
onMouseMove={(e) => {
if (!isOwnEvent(e)) return;
markLitBy("pointer");
submenus.onMouseMove(e);
}}
onClick={handlers.onClick}
onMouseLeave={(e) => {
if (!isOwnEvent(e)) return;
submenus.onMouseLeave();
}}
onFocus={(e) => {
if (!isOwnEvent(e)) return;
const indexAttr = (e.target as HTMLElement)
.closest("[data-fluid-hover-index]")
?.getAttribute("data-fluid-hover-index");
// Keyboard navigation moves the hover background only — no
// ring: in a menu the highlighted row is the focus indicator.
if (indexAttr != null) {
// With an autofocusing search field, the primitive's open
// autofocus lands on the first row a frame before the
// field takes over: that row stays unlit.
if (litByRef.current === "open" && searchTakesFocus()) return;
setActiveIndex(Number(indexAttr));
} else if (e.target !== e.currentTarget) {
// Focus moved to a non-row inside the popup, such as the
// search field: no row is highlighted any more. The field
// is a stop like a row, it just draws no background. The
// popup focusing itself (pointer leaving a row) doesn't
// count.
setActiveIndex(null);
}
}}
onBlur={(e) => {
if (!isOwnEvent(e)) return;
// The popup itself takes focus when the pointer leaves a row; only a
// departure from the whole popup ends the hover session.
if (e.currentTarget.contains(e.relatedTarget as Node))
return;
// Focus moving into an open submenu keeps its trigger lit.
if (submenus.holding()) return;
setActiveIndex(null);
}}
className={cn(
// min-w tracks the trigger; the available-height guard maps
// Base UI's --available-height to Radix's equivalent var.
// A submenu's anchor is a row, so it keeps its own
// narrower width instead.
`flex flex-col ${sub ? "w-56" : "w-72 min-w-[var(--radix-dropdown-menu-trigger-width)]"} max-w-full max-h-[min(480px,var(--radix-dropdown-menu-content-available-height))] overflow-hidden ${shape.container} select-none outline-none`,
className
)}
>
{/* The list scrolls inside a ScrollArea; this wrapper is the rows'
offsetParent, so the overlays scroll with them. */}
<ScrollArea className={popupScrollAreaClass} viewportClassName={cn(popupViewportClass, !searchMounted && "scroll-fade")}>
<div
ref={containerRef}
className="relative flex flex-col p-1"
>
{/* Selected backgrounds — merged runs in multiple mode */}
{multiple && <SelectionBackgrounds blocks={blocks} />}
{/* Selected background */}
<AnimatePresence>
{checkedRect && (
<motion.div
className={`absolute ${shape.bg} bg-active pointer-events-none`}
initial={false}
animate={{
top: checkedRect.top,
left: checkedRect.left,
width: checkedRect.width,
height: checkedRect.height,
opacity: 1,
}}
exit={{ opacity: 0, transition: spring.moderate.exit }}
transition={{
...spring.moderate,
opacity: { duration: 0.08 },
}}
/>
)}
</AnimatePresence>
{/* Hover background */}
<FluidHoverHighlight
hover={hover}
from={litBy === "pointer" ? checkedRect : null}
className={shape.bg}
/>
{/* display: contents keeps items direct flex children of the
wrapper so fluid hover measurement and gap layout still work,
while the group provides the radio value context. */}
<DropdownMenuPrimitive.RadioGroup
value={checkedIndex != null ? String(checkedIndex) : undefined}
className="contents"
>
{children}
</DropdownMenuPrimitive.RadioGroup>
</div>
</ScrollArea>
</Elevated>
</DropdownSearchHostContext.Provider>
</DropdownContext.Provider>
</motion.div>
</Content>
</DropdownMenuPrimitive.Portal>
);
}
);
DropdownContent.displayName = "DropdownContent";
// ---------------------------------------------------------------------------
// DropdownSub, DropdownSubTrigger, DropdownSubContent (submenus)
//
// <DropdownSub>
// <DropdownSubTrigger index={2} icon={Palette} label="Theme" />
// <DropdownSubContent checkedIndex={theme}>
// <MenuItem index={0} label="Light" checked={theme === 0} … />
// </DropdownSubContent>
// </DropdownSub>
//
// Radix's Sub owns hover-to-open, the safe area, →/← and Escape.
// DropdownSub gives its content its own open state, so DropdownSubContent
// is the same popup as DropdownContent (mounted through its exit tween),
// rendered as Radix's SubContent beside its trigger row. The trigger is a
// MenuItem with a trailing chevron, indexed among its parent's rows; the
// submenu's rows index from 0 again.
// ---------------------------------------------------------------------------
interface DropdownSubProps {
children: ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
function DropdownSub({
children,
open: openProp,
defaultOpen = false,
onOpenChange,
}: DropdownSubProps) {
const [open, handleOpenChange] = useControllableOpen(openProp, defaultOpen, onOpenChange);
const { disabled } = useDropdownMenuContext();
const ctx = useMemo(() => ({ open, disabled, sub: true }), [open, disabled]);
return (
<DropdownMenuContext.Provider value={ctx}>
<DropdownMenuPrimitive.Sub open={open} onOpenChange={handleOpenChange}>
{children}
</DropdownMenuPrimitive.Sub>
</DropdownMenuContext.Provider>
);
}
DropdownSub.displayName = "DropdownSub";
const DropdownSubTrigger = forwardRef<HTMLDivElement, DropdownSubTriggerProps>(
({ index, ...props }, ref) => {
const parent = useDropdown();
const { open } = useDropdownMenuContext();
useReportSubmenu(parent.onSubmenuOpenChange, index, open);
const lit = parent.activeIndex === index;
// The row renders through the parent's MenuItem, wrapped in the submenu
// trigger primitive instead of a plain item.
const renderMenuItem = useCallback(
({ disabled, label, onActivate, element, children }: MenuItemRenderOptions) => (
<DropdownMenuPrimitive.SubTrigger
asChild
disabled={disabled}
textValue={label}
onClick={onActivate}
>
{cloneElement(
element,
{},
<>
{children}
<SubmenuChevron lit={lit} />
</>
)}
</DropdownMenuPrimitive.SubTrigger>
),
[lit]
);
const ctx = useMemo(() => ({ ...parent, renderMenuItem }), [parent, renderMenuItem]);
return (
<DropdownContext.Provider value={ctx}>
<MenuItem ref={ref} index={index} {...props} />
</DropdownContext.Provider>
);
}
);
DropdownSubTrigger.displayName = "DropdownSubTrigger";
type DropdownSubContentProps = Omit<DropdownContentProps, "side" | "align">;
/** The submenu popup: DropdownContent beside its trigger row, the first row
* level with the trigger. */
const DropdownSubContent = forwardRef<HTMLDivElement, DropdownSubContentProps>(
(
{
sideOffset = SUBMENU_SIDE_OFFSET,
alignOffset = SUBMENU_ALIGN_OFFSET,
...props
},
ref
) => (
<DropdownContent
ref={ref}
sideOffset={sideOffset}
alignOffset={alignOffset}
{...props}
/>
)
);
DropdownSubContent.displayName = "DropdownSubContent";
// ---------------------------------------------------------------------------
// DropdownLabel
// ---------------------------------------------------------------------------
const DropdownLabel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
// Group labels are the caption role of the type scale — see /docs/sizes.
const compact = useSize().variant === "compact";
return (
<div
ref={ref}
className={cn(
"px-2 py-1.5 shrink-0 text-muted-foreground",
typeClass("caption", compact ? "compact" : "default"),
className
)}
{...props}
/>
);
}
);
DropdownLabel.displayName = "DropdownLabel";
// ---------------------------------------------------------------------------
// DropdownSeparator
// ---------------------------------------------------------------------------
const DropdownSeparator = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
role="separator"
className={cn("my-1 -mx-1 h-px shrink-0 bg-border/60", className)}
{...props}
/>
));
DropdownSeparator.displayName = "DropdownSeparator";
export {
Dropdown,
DropdownLabel,
DropdownSeparator,
DropdownMenu,
DropdownTrigger,
DropdownContent,
DropdownSub,
DropdownSubTrigger,
DropdownSubContent,
DropdownSearch,
DropdownEmpty,
};
export type {
DropdownProps,
DropdownMenuProps,
DropdownTriggerProps,
DropdownContentProps,
DropdownSubProps,
DropdownSubTriggerProps,
DropdownSubContentProps,
DropdownSearchProps,
};
export default Dropdown;
registry/default/menu-item.tsx
"use client";
import {
createContext,
useCallback,
useContext,
useRef,
useState,
forwardRef,
type HTMLAttributes,
type ReactElement,
type ReactNode,
} from "react";
import type { IconComponent } from "@/lib/icon-context";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";
import { fontWeights } from "@/lib/font-weight";
import { shapeMap } from "@/lib/shape-context";
import { useSize } from "@/lib/size-context";
import { useRegisterFluidHoverItem } from "@/hooks/use-fluid-hover";
// MenuItem is only used inside Dropdown, which opts out of the global pill
// shape — see dropdown.tsx for the rationale.
const shape = shapeMap.rounded;
// ---------------------------------------------------------------------------
// Dropdown context — the single shared context for every Dropdown build.
//
// It lives here rather than in the dropdown module so that (a) MenuItem stays
// primitive-free and self-contained, and (b) dropdowns built on different
// primitives (Radix, Base UI) can render side by side — each provides this
// same context object, so MenuItem resolves whichever provider actually
// wraps it. The dropdown module re-exports useDropdown from here, keeping
// its public API unchanged.
// ---------------------------------------------------------------------------
/** What MenuItem hands to the popup's primitive wrapper. `element` is the
* styled row div (visuals + fluid hover registration, no children); `children`
* is the row content (icon, label, check). The dropdown wraps them in its
* own Item / RadioItem primitive, so MenuItem itself stays primitive-free. */
export interface MenuItemRenderOptions {
/** Radio-style option (boolean `checked` on MenuItem) vs plain action item. */
radio: boolean;
/** Checkbox-style option: a boolean `checked` inside a multiple-selection
* dropdown (`checkedIndices`). Takes precedence over `radio`. */
checkbox: boolean;
/** The item's checked state (radio and checkbox items). */
checked?: boolean;
/** The item's index — doubles as the radio value. */
value: number;
disabled?: boolean;
label: string;
closeOnClick: boolean;
/** The row's activation (consumer onClick, then onSelect). The dropdown
* hands it to its primitive item as `onClick`, never to `element`: Base
* UI's Enter/Space runs the item's own onClick prop without dispatching a
* DOM click, so a handler on the rendered div would only hear the mouse. */
onActivate?: (e: React.MouseEvent<HTMLDivElement>) => void;
element: ReactElement;
children: ReactNode;
}
export interface DropdownContextValue {
registerItem: (index: number, element: HTMLElement | null) => void;
activeIndex: number | null;
checkedIndex?: number;
/** Multiple selection (`checkedIndices` on the dropdown): rows are
* checkbox items and activating one keeps the menu open by default. */
multiple?: boolean;
checkedIndices?: number[];
/** True when items render inside a Menu popup (DropdownContent), where the
* primitive's Item / RadioItem own roles, roving highlight, typeahead,
* and activation. MenuItem switches its rendering accordingly. */
inMenu?: boolean;
/** Popup-only: wraps a MenuItem's styled div in the dropdown's menu-item
* primitive. Absent in the inline Dropdown panel, where MenuItem renders
* its own ARIA menuitem div. */
renderMenuItem?: (opts: MenuItemRenderOptions) => ReactElement;
/** Popup-only: a DropdownSubTrigger reports its submenu opening and
* closing here, so the menu keeps that row lit while the pointer crosses
* to the submenu and while it is inside it. */
onSubmenuOpenChange?: (index: number, open: boolean) => void;
}
export const DropdownContext = createContext<DropdownContextValue | null>(null);
export function useDropdown() {
const ctx = useContext(DropdownContext);
if (!ctx) throw new Error("useDropdown must be used within a Dropdown");
return ctx;
}
/** Null-safe context read for callers that render outside a provider. */
export function useDropdownMaybe() {
return useContext(DropdownContext);
}
/**
* Open state for DropdownMenu and DropdownSub: controlled by `open` when it
* is passed, else local state seeded by `defaultOpen`. Either way the
* primitive is always handed a controlled value, so the popup can play its
* exit tween before it unmounts.
*/
export function useControllableOpen(
openProp: boolean | undefined,
defaultOpen: boolean,
onOpenChange?: (open: boolean) => void
) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const open = openProp !== undefined ? openProp : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (openProp === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[openProp, onOpenChange]
);
return [open, setOpen] as const;
}
export interface MenuItemProps extends HTMLAttributes<HTMLDivElement> {
/** Optional leading icon. When omitted, the row renders text-only with no
* reserved icon column. */
icon?: IconComponent;
label: string;
index: number;
/** When a boolean, the item is a radio-style option (role="menuitemradio"
* with aria-checked). When undefined, it is a plain action item
* (role="menuitem", no checked state announced). */
checked?: boolean;
onSelect?: () => void;
disabled?: boolean;
/** Popup-only (inside DropdownContent): whether activating the item closes
* the menu. Ignored in the inline Dropdown panel. @default true */
closeOnClick?: boolean;
}
const MenuItem = forwardRef<HTMLDivElement, MenuItemProps>(
(
{
icon: Icon,
label,
index,
checked,
onSelect,
disabled,
closeOnClick,
className,
onClick,
...props
},
ref
) => {
const internalRef = useRef<HTMLDivElement>(null);
const {
registerItem,
activeIndex,
checkedIndex,
multiple,
checkedIndices,
renderMenuItem,
} = useDropdown();
const isCheckbox = !!multiple && typeof checked === "boolean";
useRegisterFluidHoverItem(registerItem, index, internalRef);
const isActive = activeIndex === index;
const sizeClasses = useSize();
const mergeRef = (node: HTMLDivElement | null) => {
(internalRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
};
const handleActivate = disabled
? undefined
: (e: React.MouseEvent<HTMLDivElement>) => {
onClick?.(e);
onSelect?.();
};
const itemClassName = cn(
// Fixed height (was py-2 around a 19.5px line box ≈ 35.5px) so the
// text-box trim on the label doesn't shrink the row. shrink-0 because
// menu popups are max-height flex columns — without it a long list
// compresses rows to fit instead of scrolling.
`relative z-10 flex ${sizeClasses.control} shrink-0 items-center ${sizeClasses.gap} ${shape.item} ${sizeClasses.itemPx} cursor-pointer outline-none`,
disabled && "opacity-50 pointer-events-none",
className
);
const content = (
<>
{Icon && (
<span className="inline-grid">
<span className="col-start-1 row-start-1 invisible">
<Icon size={sizeClasses.icon} strokeWidth={2} />
</span>
<Icon
size={sizeClasses.icon}
strokeWidth={isActive || checked ? 2 : 1.5}
className={cn(
"col-start-1 row-start-1 transition-[color,stroke-width] duration-80",
isActive || checked
? "text-foreground"
: "text-muted-foreground"
)}
/>
</span>
)}
{/* Both stacked spans carry the text-box trim so the invisible bold
sizer and the visible label keep identical boxes. */}
<span className={cn("inline-grid flex-1", sizeClasses.text)}>
<span
className="col-start-1 row-start-1 invisible [text-box:trim-both_cap_alphabetic]"
style={{ fontVariationSettings: fontWeights.semibold }}
aria-hidden="true"
>
{label}
</span>
<span
className={cn(
"col-start-1 row-start-1 transition-[color,font-variation-settings] duration-80 [text-box:trim-both_cap_alphabetic]",
isActive || checked
? "text-foreground"
: "text-muted-foreground"
)}
style={{
fontVariationSettings: checked
? fontWeights.semibold
: fontWeights.normal,
}}
>
{label}
</span>
</span>
{/* initial={false}: a row checked at mount shows the finished
check instead of drawing it, so default selections don't
animate on page load. */}
<AnimatePresence initial={false}>
{checked && (
<motion.svg
key="check"
width={sizeClasses.icon}
height={sizeClasses.icon}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className="text-foreground shrink-0"
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 1 }}
>
<motion.path
d="M4 12L9 17L20 6"
initial={{ pathLength: 0 }}
animate={{
pathLength: 1,
transition: { duration: 0.08, ease: "easeOut" },
}}
exit={{
pathLength: 0,
transition: { duration: 0.04, ease: "easeIn" },
}}
/>
</motion.svg>
)}
</AnimatePresence>
</>
);
if (renderMenuItem) {
// Inside DropdownContent, the menu-item primitive (supplied by the
// surrounding DropdownContent through context) owns the role,
// aria-checked, tabIndex, roving highlight, typeahead, and Enter/Space/
// click activation. handleActivate rides on the primitive as onActivate,
// not on the div: that is the handler keyboard activation calls. The
// styled div carries the Fluid Functionalism visuals and the
// fluid-hover registration; MenuItem itself imports no primitive.
return renderMenuItem({
radio: !isCheckbox && typeof checked === "boolean",
checkbox: isCheckbox,
checked,
value: index,
disabled,
label,
// Toggling one of several stays open; picking one of one closes.
closeOnClick: closeOnClick ?? !multiple,
onActivate: handleActivate,
element: (
<div
ref={mergeRef}
data-fluid-hover-index={index}
aria-label={label}
className={itemClassName}
{...props}
/>
),
children: content,
});
}
return (
<div
ref={mergeRef}
data-fluid-hover-index={index}
// Disabled items are never the roving tab stop.
tabIndex={
!disabled && index === (checkedIndex ?? checkedIndices?.[0] ?? 0) ? 0 : -1
}
role={
isCheckbox
? "menuitemcheckbox"
: typeof checked === "boolean"
? "menuitemradio"
: "menuitem"
}
aria-checked={typeof checked === "boolean" ? checked : undefined}
aria-disabled={disabled || undefined}
aria-label={label}
onClick={handleActivate}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
onSelect?.();
}
}}
className={itemClassName}
{...props}
>
{content}
</div>
);
}
);
MenuItem.displayName = "MenuItem";
export { MenuItem };
export default MenuItem;
registry/default/dropdown-search.tsx
"use client";
import {
createContext,
forwardRef,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type HTMLAttributes,
type InputHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { cn } from "@/lib/utils";
import { useIcons } from "@/lib/icon-context";
import { useSize } from "@/lib/size-context";
import { useSurface } from "@/lib/surface-context";
import { SURFACE_BG } from "@/lib/surface-classes";
// ---------------------------------------------------------------------------
// Search inside a dropdown menu.
//
// DropdownSearch is a text field pinned to the top of a DropdownContent popup.
// It is controlled: the consumer filters the MenuItems it renders against
// `value`, so the list can be re-indexed freely (fluid hover keys on the
// indices of whatever is currently mounted). Two things make a field inside a
// menu behave:
//
// 1. The menu primitive's typeahead must not steal keystrokes from the
// field — every typing key stops propagating at the input.
// 2. Typing while a ROW is focused must land in the field: the popup
// hosts a capture-phase keydown handler (useDropdownSearchHost) that
// refocuses the input and appends the character.
//
// Arrow keys leave the field for the list (first / last row) and arrowing
// off either end of the list comes back to it, so the field is one stop in
// the ring of rows. While the field has focus no row is highlighted: the
// field is the active stop, like a row, it just draws no background of its
// own. So Enter in the field picks nothing (only a row you arrowed onto
// activates), and Escape closes the menu as usual.
// ---------------------------------------------------------------------------
const useIsoLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
interface SearchHandle {
input: HTMLInputElement | null;
/** Whether the field takes focus when the popup opens. */
autoFocus: boolean;
append: (text: string) => void;
deleteBackward: () => void;
}
interface DropdownSearchHostValue {
register: (handle: SearchHandle) => () => void;
/** Whether the popup is open. Popups stay mounted through their exit
* animation, so the field watches this rather than its own mount. */
open: boolean;
}
export const DropdownSearchHostContext =
createContext<DropdownSearchHostValue | null>(null);
const ROW_SELECTOR = [
'[role="menuitem"]:not([aria-disabled="true"])',
'[role="menuitemradio"]:not([aria-disabled="true"])',
'[role="menuitemcheckbox"]:not([aria-disabled="true"])',
].join(", ");
const ANY_ROW =
'[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]';
/** Whether `a` comes before `b` in the document. */
function precedes(a: Node, b: Node) {
return (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
}
function menuRows(from: HTMLElement | null): HTMLElement[] {
const menu = from?.closest<HTMLElement>('[role="menu"]');
return menu ? Array.from(menu.querySelectorAll<HTMLElement>(ROW_SELECTOR)) : [];
}
/** Scroll the menu's scrolling list back to its top. The walk stops at the
* menu, so a list that fits never scrolls the page. */
function scrollToTop(row: HTMLElement) {
const menu = row.closest<HTMLElement>('[role="menu"]');
for (let el = row.parentElement; el && menu?.contains(el); el = el.parentElement) {
const { overflowY } = getComputedStyle(el);
if (
(overflowY === "auto" || overflowY === "scroll") &&
el.scrollHeight > el.clientHeight
) {
el.scrollTop = 0;
return;
}
}
}
/**
* Hosts a DropdownSearch inside a popup. Returns the context value to
* provide, a capture-phase keydown handler for the popup element, and
* `hasSearch()` / `searchMounted` for decisions that depend on a field
* being present.
*/
export function useDropdownSearchHost(open: boolean) {
const handleRef = useRef<SearchHandle | null>(null);
// Reactive twin of the ref, for render-time decisions (the popup drops its
// scroll fade while a field is pinned at the top).
const [searchMounted, setSearchMounted] = useState(false);
const register = useCallback((handle: SearchHandle) => {
handleRef.current = handle;
setSearchMounted(true);
return () => {
if (handleRef.current === handle) {
handleRef.current = null;
setSearchMounted(false);
}
};
}, []);
const host = useMemo(() => ({ register, open }), [register, open]);
// Typing on a focused row: redirect the character (or Backspace) into the
// field. Space is left alone: on a row it activates the item, which is
// what a menu user expects once they have arrowed down. Arrowing off the
// first or last row returns to the field instead of stopping (Radix) or
// wrapping past it (Base UI), mirroring the field's own ↓ / ↑.
const onKeyDownCapture = useCallback((e: ReactKeyboardEvent<HTMLElement>) => {
const handle = handleRef.current;
if (!handle?.input) return;
if (e.target === handle.input) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const target = e.target instanceof HTMLElement ? e.target : null;
if (!target?.matches(ANY_ROW)) return;
const up = e.key === "ArrowUp";
const rows = menuRows(target);
const edge = up ? rows[0] : rows[rows.length - 1];
// At or past the first / last enabled row. Base UI lets arrows land on
// a disabled row (Radix skips them), so a disabled row beyond the edge
// leads back to the field too.
const pastEdge =
!edge || edge === target || (up ? precedes(target, edge) : precedes(edge, target));
if (pastEdge) {
e.preventDefault();
e.stopPropagation();
handle.input.focus();
// The field sits before the first row, so coming back to it from the
// bottom of a long list returns the list to its top, where the next
// ↓ lands.
scrollToTop(target);
}
} else if (e.key.length === 1 && e.key !== " ") {
e.preventDefault();
e.stopPropagation();
handle.input.focus();
handle.append(e.key);
} else if (e.key === "Backspace") {
e.preventDefault();
e.stopPropagation();
handle.input.focus();
handle.deleteBackward();
}
}, []);
/** Whether a search field is mounted in the popup right now. */
const hasSearch = useCallback(() => handleRef.current !== null, []);
/** Whether a mounted field takes focus when the popup opens, a frame after
* the primitive's own open autofocus. */
const searchTakesFocus = useCallback(
() => handleRef.current?.autoFocus ?? false,
[]
);
return {
host,
hasSearch,
searchTakesFocus,
searchMounted,
onKeyDownCapture,
};
}
// ---------------------------------------------------------------------------
// DropdownSearch
// ---------------------------------------------------------------------------
export interface DropdownSearchProps
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange" | "size" | "defaultValue"
> {
/** The query. Filter the MenuItems you render against it. */
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
/** Reset the query to "" when the popup closes (the field unmounts with
* it), so the menu reopens unfiltered. @default true */
clearOnClose?: boolean;
/** Take focus when the popup opens. @default true */
autoFocus?: boolean;
}
const DropdownSearch = forwardRef<HTMLInputElement, DropdownSearchProps>(
(
{
value,
onValueChange,
placeholder = "Search…",
clearOnClose = true,
autoFocus = true,
className,
onKeyDown,
...props
},
ref
) => {
const icons = useIcons();
const SearchIcon = icons.search;
const sizeClasses = useSize();
const compact = sizeClasses.variant === "compact";
const host = useContext(DropdownSearchHostContext);
// Outside a popup host (an inline panel) the field is simply open.
const open = host?.open ?? true;
// The popup surface, painted explicitly: rows scroll underneath the sticky
// field, and the field's parent is often a display:contents group (the
// menu's radio group) that `bg-inherit` would resolve to transparent.
const surface = useSurface();
const inputRef = useRef<HTMLInputElement | null>(null);
// Latest value / callback for the imperative handle and the unmount
// cleanup, without re-registering on every keystroke. Refreshed after
// each commit, before any effect or handler reads them.
const valueRef = useRef(value);
const onValueChangeRef = useRef(onValueChange);
const clearOnCloseRef = useRef(clearOnClose);
const autoFocusRef = useRef(autoFocus);
useIsoLayoutEffect(() => {
valueRef.current = value;
onValueChangeRef.current = onValueChange;
clearOnCloseRef.current = clearOnClose;
autoFocusRef.current = autoFocus;
});
useEffect(() => {
if (!host) return;
return host.register({
get input() {
return inputRef.current;
},
get autoFocus() {
return autoFocusRef.current;
},
append: (text) => onValueChangeRef.current(valueRef.current + text),
deleteBackward: () =>
onValueChangeRef.current(valueRef.current.slice(0, -1)),
});
}, [host]);
// On every open (mount, or a reopen while the popup was still playing
// its exit): reset the query so the list starts unfiltered, then take
// focus. The popup's own initial focus (first row on a keyboard open,
// the panel on a pointer open) is queued a frame after mount; the field
// takes over one frame later so a menu with a search opens ready to type.
useEffect(() => {
if (!open) return;
if (clearOnCloseRef.current && valueRef.current !== "") {
onValueChangeRef.current("");
}
if (!autoFocus) return;
let inner: number | undefined;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => inputRef.current?.focus());
});
return () => {
cancelAnimationFrame(outer);
if (inner !== undefined) cancelAnimationFrame(inner);
};
}, [open, autoFocus]);
// Unmount (the popup finished closing): reset the query so the next
// open shows the full list.
useEffect(
() => () => {
if (clearOnCloseRef.current && valueRef.current !== "") {
onValueChangeRef.current("");
}
},
[]
);
const handleKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
// Escape and Tab belong to the menu (close / leave). Everything else is
// either typing, which the menu's typeahead must not see, or list
// navigation handled right here.
if (e.key === "Escape" || e.key === "Tab") return;
e.stopPropagation();
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
const rows = menuRows(e.currentTarget);
if (rows.length === 0) return;
e.preventDefault();
(e.key === "ArrowDown" ? rows[0] : rows[rows.length - 1]).focus();
} else if (e.key === "Enter") {
// No row has focus, so there is nothing to pick. preventDefault
// keeps a surrounding form from submitting.
e.preventDefault();
}
};
return (
<div
// Sticky at the top of the scrolling popup, bleeding into its 4px
// padding so the divider runs edge to edge and rows scroll underneath
// it; margin + the list gap put the first row 4px below the divider —
// the same inset as the popup's side padding.
className={cn(
"group/search sticky top-0 z-20 -mx-1 -mt-1 mb-0.5 flex shrink-0 items-center border-b border-border/60",
SURFACE_BG[surface],
sizeClasses.control,
sizeClasses.gap,
compact ? "px-2.5" : "px-3",
className
)}
>
<SearchIcon
size={sizeClasses.icon}
strokeWidth={1.5}
className="shrink-0 text-muted-foreground transition-[color,stroke-width] duration-80 group-focus-within/search:text-foreground group-focus-within/search:stroke-[2]"
/>
<input
ref={(node) => {
inputRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
type="text"
role="searchbox"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
value={value}
onChange={(e) => onValueChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
// rounded-none: the site's base :focus-visible rule hands focused
// elements the shape radius, and a text input clips its caret to
// its own corners (pill mode nicks the caret at the left edge).
className={cn(
"min-w-0 flex-1 rounded-none bg-transparent text-foreground placeholder:text-muted-foreground outline-none font-[inherit]",
sizeClasses.field,
// The line box: Safari runs the caret its full height, so the
// ladder's leading keeps it in proportion to the row. (Chrome
// sizes the caret to the font itself, whatever the leading.)
compact ? "leading-5" : "leading-6"
)}
{...props}
/>
</div>
);
}
);
DropdownSearch.displayName = "DropdownSearch";
// ---------------------------------------------------------------------------
// DropdownEmpty — the "no results" row a filtered menu shows instead of items.
// ---------------------------------------------------------------------------
const DropdownEmpty = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const sizeClasses = useSize();
return (
<div
ref={ref}
role="status"
aria-live="polite"
className={cn(
"px-2 py-6 text-center text-muted-foreground",
sizeClasses.text,
className
)}
{...props}
/>
);
}
);
DropdownEmpty.displayName = "DropdownEmpty";
export { DropdownSearch, DropdownEmpty };
registry/default/dropdown-sub.tsx
"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
type RefObject,
} from "react";
import { cn } from "@/lib/utils";
import { useIcons } from "@/lib/icon-context";
import { useSize } from "@/lib/size-context";
import type { UseFluidHoverReturn } from "@/hooks/use-fluid-hover";
import type { MenuItemProps } from "@/components/ui/menu-item";
// ---------------------------------------------------------------------------
// Submenus inside a dropdown popup: the primitive-free half.
//
// The primitive (Base UI's SubmenuRoot, Radix's Sub) owns the safe area: the
// pointer can cut diagonally across other rows on its way to an open
// submenu without closing it. The fluid hover would still follow the
// pointer across those rows, so the highlight would point at "Share" while
// the "Move to" submenu stays open. useSubmenuHost stops that: while a
// submenu is open, the parent's highlight stays on its trigger and only
// moves when focus does. Both primitives move focus to another row only
// once the pointer has left the safe area, and close the submenu as they
// do, so the highlight agrees with the primitive's own verdict.
// ---------------------------------------------------------------------------
/** Distance from the trigger row to its submenu, in px. Both primitives
* measure from the row, which sits inside the popup's 4px padding, so this
* leaves a 2px gap between the two surfaces. */
export const SUBMENU_SIDE_OFFSET = 6;
/** Pulls the submenu up by the popup's padding (p-1), so its first row
* lines up with the trigger row. */
export const SUBMENU_ALIGN_OFFSET = -4;
export type DropdownSubTriggerProps = Omit<
MenuItemProps,
"checked" | "onSelect" | "closeOnClick"
>;
const ROW_SELECTOR = "[data-fluid-hover-index]";
const useIsoLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
/**
* The parent popup's side of its submenus. Use `onMouseEnter`,
* `onMouseMove` and `onMouseLeave` in place of the hook's own, skip
* clearing on blur while `holding()`, and hand `onSubmenuOpenChange` to the
* rows through the dropdown context. `open` is the parent popup's own state.
*/
export function useSubmenuHost(
containerRef: RefObject<HTMLElement | null>,
hover: Pick<UseFluidHoverReturn, "setActiveIndex" | "handlers">,
open: boolean
) {
// The hook's handlers object is new every render, but the functions in it
// are stable: depend on those, so the callbacks below stay stable too and
// a trigger's report effect only re-runs when its submenu really changes.
const { setActiveIndex } = hover;
const {
onMouseEnter: hoverEnter,
onMouseMove: hoverMove,
onMouseLeave: hoverLeave,
} = hover.handlers;
const openIndexRef = useRef<number | null>(null);
// Read when a submenu closes, in its trigger's effect cleanup. A layout
// effect refreshes it in the commit's layout phase, before any passive
// cleanup runs, so a close in the same commit already sees the new value.
const parentOpenRef = useRef(open);
useIsoLayoutEffect(() => {
parentOpenRef.current = open;
}, [open]);
// The pointer's last position. While a submenu is open it is read from the
// document: Base UI switches pointer events off on the parent while the
// pointer crosses to the submenu, so the parent hears no moves then.
const pointerRef = useRef<{ x: number; y: number } | null>(null);
const stopListeningRef = useRef<(() => void) | null>(null);
const listen = useCallback(() => {
if (stopListeningRef.current) return;
const onMove = (e: PointerEvent) => {
pointerRef.current = { x: e.clientX, y: e.clientY };
};
document.addEventListener("pointermove", onMove, { capture: true, passive: true });
stopListeningRef.current = () => {
document.removeEventListener("pointermove", onMove, { capture: true });
stopListeningRef.current = null;
};
}, []);
useEffect(() => () => stopListeningRef.current?.(), []);
const onSubmenuOpenChange = useCallback(
(index: number, open: boolean) => {
if (open) {
openIndexRef.current = index;
setActiveIndex(index);
listen();
return;
}
if (openIndexRef.current !== index) return;
openIndexRef.current = null;
stopListeningRef.current?.();
// The whole menu is closing (a pick, Escape on Radix, a press
// outside): leave its last highlight to fade out with it.
if (!parentOpenRef.current) return;
// Hand the highlight back: to the focused row (the row the primitive
// moved to, or the trigger after ←), else to the row under the
// pointer, else to nothing.
const container = containerRef.current;
if (!container) return;
const focused = (document.activeElement as HTMLElement | null)?.closest?.(ROW_SELECTOR);
if (focused && container.contains(focused)) {
setActiveIndex(Number(focused.getAttribute("data-fluid-hover-index")));
return;
}
const p = pointerRef.current;
const r = container.getBoundingClientRect();
if (p && p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom) {
hoverMove({ clientX: p.x, clientY: p.y } as React.MouseEvent);
} else {
setActiveIndex(null);
}
},
[containerRef, setActiveIndex, hoverMove, listen]
);
const holding = useCallback(() => openIndexRef.current !== null, []);
// Entering the popup starts a fresh hover session, which re-keys the
// highlight to fade in from the checked row. A held highlight stays put.
const onMouseEnter = useCallback(() => {
if (openIndexRef.current !== null) return;
hoverEnter();
}, [hoverEnter]);
const onMouseMove = useCallback(
(e: React.MouseEvent) => {
pointerRef.current = { x: e.clientX, y: e.clientY };
// The trigger stays lit while its submenu is open, whatever row the
// pointer crosses: focus moves the highlight instead.
if (openIndexRef.current !== null) return;
hoverMove(e);
},
[hoverMove]
);
const onMouseLeave = useCallback(() => {
if (openIndexRef.current !== null) return;
hoverLeave();
}, [hoverLeave]);
return { onSubmenuOpenChange, holding, onMouseEnter, onMouseMove, onMouseLeave };
}
/** Reports a trigger's submenu opening and closing to its parent popup. */
export function useReportSubmenu(
onSubmenuOpenChange: ((index: number, open: boolean) => void) | undefined,
index: number,
open: boolean
) {
useEffect(() => {
if (!open || !onSubmenuOpenChange) return;
onSubmenuOpenChange(index, true);
return () => onSubmenuOpenChange(index, false);
}, [onSubmenuOpenChange, index, open]);
}
/** The trailing chevron on a submenu trigger row. It lights with the row,
* like the leading icon. */
export function SubmenuChevron({ lit }: { lit: boolean }) {
const icons = useIcons();
const ChevronRight = icons["chevron-right"];
const sizeClasses = useSize();
return (
<span aria-hidden="true" className="inline-grid shrink-0 rtl:-scale-x-100">
<ChevronRight
size={sizeClasses.icon}
strokeWidth={lit ? 2 : 1.5}
className={cn(
"transition-[color,stroke-width] duration-80",
lit ? "text-foreground" : "text-muted-foreground"
)}
/>
</span>
);
}