shadimage
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 · medium
ui-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>
  );
}

More from fluid

Browse all components in this library →