{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-select",
  "type": "registry:ui",
  "title": "Morph select",
  "description": "A select whose trigger grows into the list, with a gliding highlight and type-ahead.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/morph-select/morph-select.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport type {\n  CSSProperties,\n  FocusEvent as ReactFocusEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n  ReactNode,\n} from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { Check, ChevronDown, Search, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface MorphSelectOption {\n  value: string;\n  label: string;\n  /** A 20px mark shown before the label, in the list and in the trigger. An avatar or a plain icon. */\n  icon?: ReactNode;\n  /** Short trailing detail such as an offset or a count. Rendered with tabular numerals. */\n  meta?: string;\n  /** Extra words that match this option when searching. */\n  keywords?: string;\n  disabled?: boolean;\n}\n\n/** A labelled run of options. */\nexport interface MorphSelectGroup {\n  label: string;\n  options: MorphSelectOption[];\n}\n\nexport type MorphSelectItem = MorphSelectOption | MorphSelectGroup;\n\n/**\n * A select whose trigger is the list. Opening grows the trigger itself into the options surface, a highlight glides between options,\n * and the chosen option's label lifts out of the list and settles into the trigger while the trigger springs to its new width.\n * Supports groups, type-ahead, search for long lists, and the full listbox keyboard model.\n * Use it for compact property pickers and form fields with up to a few hundred options. Prefer a combobox when people mostly type free text.\n */\nexport interface MorphSelectProps {\n  /** Visible label, also the accessible name of the trigger and the list. */\n  label: string;\n  /** Keeps the label for assistive technology only. */\n  hideLabel?: boolean;\n  items: MorphSelectItem[];\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string, option: MorphSelectOption) => void;\n  placeholder?: string;\n  /** Shows a search field in place of the trigger when open. \"auto\" turns it on above eight options. */\n  searchable?: boolean | \"auto\";\n  searchPlaceholder?: string;\n  /** Width of the open surface in px. It never gets narrower than the trigger. Defaults to 272. */\n  panelWidth?: number;\n  /** Tallest the option list gets before it scrolls, in px. Defaults to 296. */\n  maxListHeight?: number;\n  /** Which edge of the trigger stays put while the surface grows. */\n  align?: \"start\" | \"end\";\n  /** Adds a hidden input for native form submission. */\n  name?: string;\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Section = { key: string; label?: string; options: MorphSelectOption[] };\ntype Change = { key: number; value: string | null; dir: number; fly: { x: number; y: number } | null };\n\nconst { blur } = motionTokens;\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\n/** Duration springs restated as stiffness and damping, so a retarget mid flight keeps the velocity it already has. */\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst GROW = physical(0.4, 0),\n  SHRINK = physical(0.34, 0),\n  GLIDE = physical(0.28, 0),\n  WIDTH = physical(0.42, 0);\n/** Width that narrows never overshoots: dipping even half a pixel under the closed width ellipsizes the label and nudges the chevron. */\nconst NARROW = physical(0.42, 0);\n/** The lifted label travels a touch faster than the surface folds, so the closing edge never catches it. */\nconst FLY = physical(0.3, 0);\nconst OPEN_RADIUS = 22;\nconst TYPEAHEAD_RESET = 600,\n  PAGE = 8;\n\n/* The root lays out the label and the anchor. The anchor holds the closed trigger's place; the shape floats over it and grows past it when open. */\nconst rootClass =\n  \"group/ms relative inline-grid min-w-0 justify-items-start gap-2 font-body text-sm leading-body tracking-body text-ink [--ms-h:var(--control-height-md)] [--ms-trigger-w:0px] data-open:z-30 data-[align=end]:justify-items-end\";\n/* One material for both states. The border is an overlay (::after) so it never changes a measurement. */\nconst shapeClass =\n  \"absolute top-0 left-0 overflow-clip bg-surface shadow-resting [transition:box-shadow_var(--duration-considered)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard)] after:pointer-events-none after:absolute after:inset-0 after:z-2 after:rounded-[inherit] after:border after:border-line after:transition-[border-color] after:duration-fast after:ease-standard after:content-[''] group-data-[align=end]/ms:right-0 group-data-[align=end]/ms:left-auto group-data-open/ms:bg-surface group-data-open/ms:shadow-floating group-data-disabled/ms:opacity-55 group-[:not([data-open],[data-disabled])]/ms:hover:bg-hover group-[:not([data-open],[data-disabled])]/ms:hover:after:border-line-strong contrast-more:after:border-line-strong motion-reduce:duration-0\";\nconst triggerBase =\n  \"box-border flex h-(--ms-h) items-center gap-2 py-0 pr-9 pl-3.5 text-(length:--text-sm) font-medium whitespace-nowrap\";\n/* An invisible copy that says how wide the closed trigger wants to be. It sits in flow until the first measurement lands (the\n   root's data-ready), so the server render already has the right size. */\nconst measureClass =\n  \"pointer-events-none invisible static w-max max-w-[min(20rem,calc(100vw-2rem))] group-data-ready/ms:absolute group-data-ready/ms:top-0 group-data-ready/ms:left-0 group-data-[align=end]/ms:group-data-ready/ms:right-0 group-data-[align=end]/ms:group-data-ready/ms:left-auto\";\nconst iconClass =\n  \"grid size-5 flex-none place-items-center text-ink-2 [&>img]:size-5 [&>img]:rounded-[50%] [&>img]:object-cover [&>svg]:size-4\";\nconst valueTextClass = \"min-w-0 truncate\";\nconst hoverWash = \"hover:bg-[color-mix(in_oklab,var(--ink)_8%,transparent)]\";\n/* The list hangs under the lid at its own width, so it measures the same whatever size the surface is mid spring. */\nconst listFaceClass =\n  \"absolute top-(--ms-h) left-0 box-border w-[max(var(--ms-trigger-w),min(var(--ms-panel-w),calc(100vw-2rem)))] px-1.5 pt-0 pb-1.5 group-data-[align=end]/ms:right-0 group-data-[align=end]/ms:left-auto before:absolute before:top-0 before:right-3.5 before:left-3.5 before:h-px before:bg-line-soft before:content-[''] inert:pointer-events-none\";\nconst srOnly = \"absolute size-px overflow-hidden whitespace-nowrap [clip-path:inset(50%)] [clip:rect(0_0_0_0)]\";\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nconst isGroup = (item: MorphSelectItem): item is MorphSelectGroup => \"options\" in item;\n\n/** Loose options between groups gather into unlabelled sections, so the list keeps the order it was given. */\nfunction normalize(items: MorphSelectItem[]): Section[] {\n  const out: Section[] = [];\n  items.forEach((item, index) => {\n    if (isGroup(item)) {\n      out.push({ key: `group-${index}`, label: item.label, options: item.options });\n      return;\n    }\n    const last = out[out.length - 1];\n    if (last && !last.label) last.options.push(item);\n    else out.push({ key: `loose-${index}`, options: [item] });\n  });\n  return out;\n}\n\nfunction matches(option: MorphSelectOption, needle: string) {\n  return `${option.label} ${option.meta ?? \"\"} ${option.keywords ?? \"\"}`.toLowerCase().includes(needle);\n}\n\n/** Lid values roll with the list: a later option rises from below, an earlier one drops from above. */\nconst valueVariants: Variants = {\n  enter: (change: Change) =>\n    change.fly\n      ? { opacity: 1, x: change.fly.x, y: change.fly.y, filter: \"blur(0px)\" }\n      : { opacity: 0, x: 0, y: `${change.dir * 0.5}em`, filter: `blur(${blur.soft}px)` },\n  rest: { opacity: 1, x: 0, y: 0, filter: \"blur(0px)\" },\n  exit: (change: Change) => ({\n    opacity: 0,\n    x: 0,\n    y: change.fly ? \"-0.3em\" : `${change.dir * -0.45}em`,\n    filter: `blur(${blur.subtle}px)`,\n    transition: { duration: 0.12, ease: standard },\n  }),\n};\nconst valueFade: Variants = {\n  enter: { opacity: 0 },\n  rest: { opacity: 1, x: 0, y: 0, filter: \"blur(0px)\" },\n  exit: { opacity: 0, transition: { duration: 0.1 } },\n};\n\n/** The closed trigger's width, rounded up. offsetWidth rounds to the nearest pixel, and half a pixel short is enough to ellipsize the label. */\nconst triggerWidth = (node: HTMLElement) => {\n  const exact = node.getBoundingClientRect().width,\n    rounded = node.offsetWidth;\n  // Under a scaled ancestor the rect is scaled too, so fall back to the layout width with a pixel of slack.\n  return Math.abs(exact - rounded) <= 0.5 ? Math.ceil(exact - 0.01) : rounded + 1;\n};\n\nexport function MorphSelect({\n  label,\n  hideLabel = false,\n  items,\n  value,\n  defaultValue = null,\n  onValueChange,\n  placeholder = \"Select\",\n  searchable = \"auto\",\n  searchPlaceholder,\n  panelWidth = 272,\n  maxListHeight = 296,\n  align = \"start\",\n  name,\n  disabled = false,\n  className,\n}: MorphSelectProps) {\n  const reduced = useReducedFlag();\n  const uid = useId();\n  const labelId = `${uid}-label`,\n    listId = `${uid}-list`,\n    optionId = (index: number) => `${uid}-option-${index}`;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<HTMLSpanElement>(null);\n  const listFaceRef = useRef<HTMLDivElement>(null);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const slotRef = useRef<HTMLSpanElement>(null);\n  const optionRefs = useRef(new Map<string, HTMLElement>());\n\n  const sections = useMemo(() => normalize(items), [items]);\n  const all = useMemo(() => sections.flatMap((section) => section.options), [sections]);\n  const indexOf = useMemo(() => new Map(all.map((option, index) => [option.value, index])), [all]);\n  const canSearch = searchable === \"auto\" ? all.length > 8 : searchable;\n\n  const [inner, setInner] = useState<string | null>(defaultValue);\n  const selected = value !== undefined ? value : inner;\n  const selectedOption = selected === null ? undefined : all.find((option) => option.value === selected);\n\n  const [open, setOpen] = useState(false);\n  const [query, setQuery] = useState(\"\");\n  const [active, setActive] = useState<string | null>(null);\n  const [change, setChange] = useState<Change>({ key: 0, value: null, dir: 1, fly: null });\n\n  const needle = query.trim().toLowerCase();\n  const visibleSections = useMemo(\n    () =>\n      needle\n        ? sections\n            .map((section) => ({ ...section, options: section.options.filter((option) => matches(option, needle)) }))\n            .filter((section) => section.options.length)\n        : sections,\n    [needle, sections],\n  );\n  const enabled = useMemo(\n    () => visibleSections.flatMap((section) => section.options).filter((option) => !option.disabled),\n    [visibleSections],\n  );\n  const current =\n    active !== null && enabled.some((option) => option.value === active) ? active : (enabled[0]?.value ?? null);\n  const resultCount = visibleSections.reduce((sum, section) => sum + section.options.length, 0);\n\n  /* The shape: one surface whose width, height, and corners spring between the trigger and the open list. */\n  const anchorW = useMotionValue<number | string>(\"auto\");\n  const shapeW = useMotionValue<number | string>(\"100%\"),\n    shapeH = useMotionValue<number | string>(\"100%\"),\n    radius = useMotionValue(22);\n  const sizes = useRef({ trigger: 0, lid: 0, listW: 0, listH: 0 });\n  const live = useRef({ open: false, reduced: false, measured: false });\n  useLayoutEffect(() => {\n    live.current.reduced = reduced;\n  }, [reduced]);\n\n  const place = useCallback(\n    (animated: boolean) => {\n      const { trigger, lid, listW, listH } = sizes.current;\n      if (!trigger || !lid) return;\n      const isOpen = live.current.open;\n      const next = isOpen\n        ? { w: Math.max(trigger, listW), h: lid + listH, r: OPEN_RADIUS }\n        : { w: trigger, h: lid, r: lid / 2 };\n      if (!animated || live.current.reduced || !live.current.measured) {\n        anchorW.jump(trigger);\n        shapeW.jump(next.w);\n        shapeH.jump(next.h);\n        radius.jump(next.r);\n        live.current.measured = true;\n        return;\n      }\n      const lastW = typeof shapeW.get() === \"number\" ? (shapeW.get() as number) : next.w;\n      const lastH = typeof shapeH.get() === \"number\" ? (shapeH.get() as number) : next.h;\n      const lastAnchor = typeof anchorW.get() === \"number\" ? (anchorW.get() as number) : trigger;\n      const transition = next.w * next.h >= lastW * lastH ? GROW : SHRINK;\n      animate(anchorW, trigger, trigger < lastAnchor ? NARROW : WIDTH);\n      animate(shapeW, next.w, isOpen ? transition : next.w < lastW ? NARROW : WIDTH);\n      animate(shapeH, next.h, transition);\n      animate(radius, next.r, transition);\n    },\n    [anchorW, radius, shapeH, shapeW],\n  );\n\n  useLayoutEffect(() => {\n    const measure = measureRef.current,\n      face = listFaceRef.current,\n      root = rootRef.current;\n    if (!measure || !face || !root) return;\n    const read = () => {\n      const previous = sizes.current;\n      // The open list is never narrower than the trigger; the width goes to CSS directly so a new label never waits on a render.\n      root.style.setProperty(\"--ms-trigger-w\", `${triggerWidth(measure)}px`);\n      const next = {\n        trigger: triggerWidth(measure),\n        lid: measure.offsetHeight,\n        listW: face.offsetWidth,\n        listH: face.offsetHeight,\n      };\n      const changed =\n        next.trigger !== previous.trigger ||\n        next.lid !== previous.lid ||\n        (live.current.open && (next.listW !== previous.listW || next.listH !== previous.listH));\n      sizes.current = next;\n      if (changed) place(live.current.measured);\n    };\n    read();\n    root.dataset.ready = \"\";\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(measure);\n    observer.observe(face);\n    return () => observer.disconnect();\n  }, [place]);\n\n  useLayoutEffect(() => {\n    if (live.current.open === open) return;\n    live.current.open = open;\n    // A pick changes the label and closes in the same commit, so both faces are read fresh before the shape moves.\n    const face = listFaceRef.current,\n      measure = measureRef.current;\n    if (face && measure) {\n      rootRef.current?.style.setProperty(\"--ms-trigger-w\", `${triggerWidth(measure)}px`);\n      sizes.current = {\n        trigger: triggerWidth(measure),\n        lid: measure.offsetHeight,\n        listW: face.offsetWidth,\n        listH: face.offsetHeight,\n      };\n    }\n    place(true);\n  }, [open, place]);\n\n  /* The highlight glides between options on its own spring and fades when nothing is active. */\n  const hy = useMotionValue(0),\n    hh = useMotionValue(0),\n    ho = useMotionValue(0);\n  const scrollIntent = useRef(false);\n  useLayoutEffect(() => {\n    const node = open && current !== null ? optionRefs.current.get(current) : undefined;\n    if (!node) {\n      animate(ho, 0, { duration: reduced || !open ? 0 : 0.12, ease: standard });\n      return;\n    }\n    const top = node.offsetTop,\n      height = node.offsetHeight;\n    if (ho.get() < 0.05 || reduced) {\n      hy.jump(top);\n      hh.jump(height);\n    } else {\n      animate(hy, top, GLIDE);\n      animate(hh, height, GLIDE);\n    }\n    animate(ho, 1, { duration: reduced ? 0 : 0.12, ease: enter });\n    const scroller = scrollRef.current;\n    if (scroller && scrollIntent.current) {\n      scrollIntent.current = false;\n      const pad = 6;\n      if (top < scroller.scrollTop + pad) scroller.scrollTop = top - pad;\n      else if (top + height > scroller.scrollTop + scroller.clientHeight - pad)\n        scroller.scrollTop = top + height - scroller.clientHeight + pad;\n    }\n  }, [current, hh, ho, hy, open, reduced, visibleSections]);\n\n  /* Focus lands in the lid: the search field when there is one, otherwise the trigger that keeps the active option. */\n  const pendingFocus = useRef<\"trigger\" | \"input\" | null>(null);\n  useLayoutEffect(() => {\n    const target = pendingFocus.current;\n    pendingFocus.current = null;\n    if (target === \"input\") inputRef.current?.focus({ preventScroll: true });\n    if (target === \"trigger\") triggerRef.current?.focus({ preventScroll: true });\n  }, [open]);\n\n  const typeahead = useRef({ buffer: \"\", at: 0 });\n  /** Space opens and picks on keydown; the click a button fires on the matching keyup must not undo it. */\n  const suppressClick = useRef(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const openList = (focus?: string | null, seed = \"\") => {\n    if (disabled || open) return;\n    setQuery(seed);\n    setActive(focus !== undefined ? focus : selected);\n    scrollIntent.current = true;\n    pendingFocus.current = canSearch ? \"input\" : \"trigger\";\n    setAnnouncement(\"\");\n    setOpen(true);\n  };\n\n  const close = useCallback((restoreFocus: boolean) => {\n    typeahead.current.buffer = \"\";\n    if (restoreFocus) pendingFocus.current = \"trigger\";\n    setOpen(false);\n  }, []);\n\n  const commit = (option: MorphSelectOption | undefined, source?: HTMLElement | null) => {\n    if (!option || option.disabled) return;\n    if (option.value !== selected) {\n      let fly: Change[\"fly\"] = null;\n      const slot = slotRef.current;\n      const part = source?.querySelector<HTMLElement>(\"[data-part='value']\");\n      if (!reduced && part && slot) {\n        const from = part.getBoundingClientRect(),\n          to = slot.getBoundingClientRect();\n        fly = { x: from.left - to.left, y: from.top + from.height / 2 - (to.top + to.height / 2) };\n      }\n      const from = selected === null ? -1 : (indexOf.get(selected) ?? -1),\n        to = indexOf.get(option.value) ?? 0;\n      setChange((last) => ({\n        key: last.key + 1,\n        value: option.value,\n        dir: from < 0 ? 1 : Math.sign(to - from) || 1,\n        fly,\n      }));\n      if (value === undefined) setInner(option.value);\n      onValueChange?.(option.value, option);\n    }\n    close(true);\n  };\n\n  const typeTo = (char: string) => {\n    const now = performance.now(),\n      state = typeahead.current;\n    state.buffer = now - state.at > TYPEAHEAD_RESET ? char : state.buffer + char;\n    state.at = now;\n    const buffer = state.buffer.toLowerCase();\n    const repeated = buffer.split(\"\").every((letter) => letter === buffer[0]);\n    const search = repeated ? buffer[0]! : buffer;\n    const pool = all.filter((option) => !option.disabled);\n    const from = open ? current : selected;\n    const at = pool.findIndex((option) => option.value === from);\n    const offset = repeated || buffer.length === 1 ? 1 : 0;\n    const ordered = [...pool.slice(at + offset), ...pool.slice(0, at + offset)];\n    return ordered.find((option) => option.label.toLowerCase().startsWith(search));\n  };\n\n  const move = (to: string | null | undefined) => {\n    if (to === undefined || to === null) return;\n    scrollIntent.current = true;\n    setActive(to);\n  };\n\n  const onKey = (event: ReactKeyboardEvent<HTMLElement>, fromInput: boolean) => {\n    const { key } = event;\n    if (key === \" \" && !fromInput) suppressClick.current = true;\n    const printable = key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey;\n    const typing = performance.now() - typeahead.current.at < TYPEAHEAD_RESET && typeahead.current.buffer.length > 0;\n\n    if (!open) {\n      if (key === \"ArrowDown\" || key === \"ArrowUp\" || key === \"Enter\" || (key === \" \" && !typing)) {\n        event.preventDefault();\n        openList();\n        return;\n      }\n      if (key === \"Home\" || key === \"End\") {\n        event.preventDefault();\n        const pool = all.filter((option) => !option.disabled);\n        openList((key === \"Home\" ? pool[0] : pool[pool.length - 1])?.value ?? null);\n        return;\n      }\n      if (printable) {\n        event.preventDefault();\n        if (canSearch) openList(undefined, key);\n        else {\n          const hit = typeTo(key);\n          openList(hit?.value ?? selected);\n        }\n      }\n      return;\n    }\n\n    const at = enabled.findIndex((option) => option.value === current);\n    const caretKeys = fromInput && query.length > 0;\n    switch (key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        move(enabled[Math.min(enabled.length - 1, at + 1)]?.value);\n        return;\n      case \"ArrowUp\":\n        event.preventDefault();\n        if (event.altKey) {\n          commit(enabled[at], optionRefs.current.get(current ?? \"\"));\n          return;\n        }\n        move(enabled[Math.max(0, at - 1)]?.value);\n        return;\n      case \"PageDown\":\n        event.preventDefault();\n        move(enabled[Math.min(enabled.length - 1, at + PAGE)]?.value);\n        return;\n      case \"PageUp\":\n        event.preventDefault();\n        move(enabled[Math.max(0, at - PAGE)]?.value);\n        return;\n      case \"Home\":\n        if (caretKeys) return;\n        event.preventDefault();\n        move(enabled[0]?.value);\n        return;\n      case \"End\":\n        if (caretKeys) return;\n        event.preventDefault();\n        move(enabled[enabled.length - 1]?.value);\n        return;\n      case \"Enter\":\n        event.preventDefault();\n        commit(enabled[at], optionRefs.current.get(current ?? \"\"));\n        return;\n      case \"Escape\":\n        event.preventDefault();\n        event.stopPropagation();\n        close(true);\n        return;\n      case \"Tab\":\n        close(false);\n        return;\n    }\n    if (fromInput) return;\n    if (key === \" \" && !typing) {\n      event.preventDefault();\n      commit(enabled[at], optionRefs.current.get(current ?? \"\"));\n      return;\n    }\n    if (printable) {\n      event.preventDefault();\n      move(typeTo(key)?.value);\n    }\n  };\n\n  // A press outside or focus leaving the control folds the list back into the trigger.\n  useEffect(() => {\n    if (!open) return;\n    const down = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) close(false);\n    };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [close, open]);\n  const onBlur = (event: ReactFocusEvent<HTMLDivElement>) => {\n    const next = event.relatedTarget as Node | null;\n    if (open && next && !event.currentTarget.contains(next)) close(false);\n  };\n\n  const onQuery = (next: string) => {\n    setQuery(next);\n    setActive(null);\n    scrollIntent.current = true;\n    if (scrollRef.current) scrollRef.current.scrollTop = 0;\n    const text = next.trim().toLowerCase();\n    const count = text ? all.filter((option) => matches(option, text)).length : all.length;\n    setAnnouncement(text ? (count ? `${count} ${count === 1 ? \"option\" : \"options\"}` : \"No matches\") : \"\");\n  };\n\n  const renderValue = (option: MorphSelectOption | undefined) =>\n    option ? (\n      <>\n        {option.icon && (\n          <span className={iconClass} aria-hidden=\"true\">\n            {option.icon}\n          </span>\n        )}\n        <span className={valueTextClass}>{option.label}</span>\n      </>\n    ) : (\n      <span className=\"font-normal text-ink-3\">{placeholder}</span>\n    );\n\n  const showSearch = open && canSearch;\n  const layerKey = selected ?? \"__empty\";\n  const changeForLayer: Change = change.value === selected ? change : { ...change, fly: null };\n  const rootStyle = { \"--ms-panel-w\": `${panelWidth}px`, \"--ms-list-max\": `${maxListHeight}px` } as CSSProperties;\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(rootClass, className)}\n      style={rootStyle}\n      data-open={open || undefined}\n      data-align={align}\n      data-disabled={disabled || undefined}\n      onBlur={onBlur}\n    >\n      <span id={labelId} className={hideLabel ? srOnly : \"text-(length:--text-sm) font-medium text-ink-2\"}>\n        {label}\n      </span>\n\n      <motion.div className=\"relative h-(--ms-h) max-w-full\" style={{ width: anchorW }}>\n        {/* Sizes the closed trigger. It holds the chosen label with the trigger's own padding, so the width spring has a target before anything moves. */}\n        <span ref={measureRef} className={cn(triggerBase, measureClass)} aria-hidden=\"true\">\n          {renderValue(selectedOption)}\n        </span>\n\n        <motion.div className={shapeClass} style={{ width: shapeW, height: shapeH, borderRadius: radius }}>\n          {/* The lid is the trigger row. It stays at the top of the surface in both states and sits above the list, so a lifted label passes over it. */}\n          <div className=\"absolute top-0 right-0 left-0 z-1 h-(--ms-h)\">\n            <button\n              ref={triggerRef}\n              type=\"button\"\n              className={cn(\n                triggerBase,\n                \"absolute inset-0 m-0 w-full border-0 bg-transparent text-left text-inherit [-webkit-tap-highlight-color:transparent] touch-manipulation disabled:cursor-not-allowed\",\n              )}\n              role=\"combobox\"\n              aria-labelledby={labelId}\n              aria-haspopup=\"listbox\"\n              aria-expanded={open}\n              aria-controls={listId}\n              aria-activedescendant={\n                open && !canSearch && current !== null ? optionId(indexOf.get(current) ?? 0) : undefined\n              }\n              disabled={disabled}\n              inert={showSearch || undefined}\n              tabIndex={showSearch ? -1 : 0}\n              onClick={() => {\n                if (suppressClick.current) {\n                  suppressClick.current = false;\n                  return;\n                }\n                if (open) close(true);\n                else openList();\n              }}\n              onKeyDown={(event) => onKey(event, false)}\n              onKeyUp={(event) => {\n                if (event.key === \" \")\n                  window.setTimeout(() => {\n                    suppressClick.current = false;\n                  });\n              }}\n            >\n              <span\n                ref={slotRef}\n                className=\"relative block h-full min-w-0 flex-1 data-hidden:opacity-0 data-hidden:transition-opacity data-hidden:duration-instant data-hidden:ease-standard motion-reduce:duration-0\"\n                data-hidden={showSearch || undefined}\n              >\n                <AnimatePresence initial={false} custom={changeForLayer}>\n                  <motion.span\n                    key={layerKey}\n                    className=\"absolute inset-0 flex min-w-0 items-center gap-2\"\n                    custom={changeForLayer}\n                    variants={reduced ? valueFade : valueVariants}\n                    initial=\"enter\"\n                    animate=\"rest\"\n                    exit=\"exit\"\n                    transition={\n                      reduced\n                        ? { duration: 0.12 }\n                        : changeForLayer.fly\n                          ? { x: FLY, y: FLY, opacity: { duration: 0 } }\n                          : {\n                              y: GLIDE,\n                              opacity: { duration: 0.2, ease: enter },\n                              filter: { duration: 0.22, ease: enter },\n                            }\n                    }\n                  >\n                    {renderValue(selectedOption)}\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n            </button>\n\n            {/* Search takes the lid's place when the list is long. */}\n            {canSearch && (\n              <motion.div\n                className=\"absolute inset-0 flex items-center gap-2 py-0 pr-0.5 pl-3.5 inert:pointer-events-none\"\n                inert={!showSearch || undefined}\n                initial={false}\n                animate={\n                  showSearch\n                    ? { opacity: 1, filter: \"blur(0px)\" }\n                    : { opacity: 0, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }\n                }\n                transition={\n                  showSearch\n                    ? { duration: 0.18, ease: enter, delay: reduced ? 0 : 0.04 }\n                    : { duration: 0.1, ease: standard }\n                }\n              >\n                <Search className=\"flex-none text-ink-3\" size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n                <input\n                  ref={inputRef}\n                  className=\"h-full min-w-0 flex-1 border-0 bg-transparent p-0 text-(length:--text-sm) tracking-body text-ink outline-none placeholder:text-ink-3\"\n                  type=\"text\"\n                  role=\"combobox\"\n                  aria-label={`Search ${label.toLowerCase()}`}\n                  aria-expanded={open}\n                  aria-controls={listId}\n                  aria-autocomplete=\"list\"\n                  aria-activedescendant={open && current !== null ? optionId(indexOf.get(current) ?? 0) : undefined}\n                  placeholder={searchPlaceholder ?? selectedOption?.label ?? \"Search\"}\n                  value={query}\n                  autoComplete=\"off\"\n                  spellCheck={false}\n                  onChange={(event) => onQuery(event.target.value)}\n                  onKeyDown={(event) => onKey(event, true)}\n                />\n                <AnimatePresence initial={false}>\n                  {query && (\n                    <motion.button\n                      key=\"clear\"\n                      type=\"button\"\n                      className={cn(\n                        \"grid size-6 flex-none place-items-center rounded-button border-0 bg-[color-mix(in_oklab,var(--ink)_8%,transparent)] p-0 text-ink-2 [-webkit-tap-highlight-color:transparent] hover:text-ink\",\n                        hoverWash,\n                      )}\n                      aria-label=\"Clear search\"\n                      onPointerDown={(event) => event.preventDefault()}\n                      onClick={() => {\n                        onQuery(\"\");\n                        inputRef.current?.focus();\n                      }}\n                      initial={{ opacity: 0, scale: 0.6 }}\n                      animate={{ opacity: 1, scale: 1 }}\n                      exit={{ opacity: 0, scale: 0.6, transition: { duration: 0.1 } }}\n                      transition={reduced ? { duration: 0 } : GLIDE}\n                    >\n                      <X size={14} strokeWidth={1.75} aria-hidden=\"true\" />\n                    </motion.button>\n                  )}\n                </AnimatePresence>\n                {/* Sits exactly over the shared chevron, so the chevron closes the list in both lid states. */}\n                <button\n                  type=\"button\"\n                  className={cn(\n                    \"size-9 flex-none rounded-button border-0 bg-transparent p-0 [-webkit-tap-highlight-color:transparent] transition-[background-color] duration-fast ease-standard\",\n                    hoverWash,\n                  )}\n                  aria-label={`Close ${label.toLowerCase()}`}\n                  onClick={() => close(true)}\n                />\n              </motion.div>\n            )}\n\n            <motion.span\n              className=\"pointer-events-none absolute top-1/2 right-3 -mt-2 grid size-4 place-items-center text-ink-3\"\n              aria-hidden=\"true\"\n              initial={false}\n              animate={{ rotate: open ? 180 : 0 }}\n              transition={reduced ? { duration: 0 } : GLIDE}\n            >\n              <ChevronDown size={16} strokeWidth={1.75} />\n            </motion.span>\n          </div>\n\n          <motion.div\n            ref={listFaceRef}\n            className={listFaceClass}\n            inert={!open || undefined}\n            aria-hidden={!open || undefined}\n            initial={false}\n            animate={\n              open\n                ? { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                : { opacity: 0, y: reduced ? 0 : -6, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }\n            }\n            transition={\n              open\n                ? {\n                    y: GROW,\n                    opacity: { duration: 0.2, ease: enter, delay: reduced ? 0 : 0.04 },\n                    filter: { duration: 0.22, ease: enter, delay: 0.04 },\n                  }\n                : { duration: 0.1, ease: standard }\n            }\n          >\n            <div\n              ref={scrollRef}\n              className=\"max-h-(--ms-list-max) overflow-y-auto overscroll-contain pt-1.5 [scrollbar-width:thin]\"\n            >\n              <div id={listId} className=\"relative grid gap-0.5\" role=\"listbox\" aria-labelledby={labelId}>\n                <motion.span\n                  className=\"pointer-events-none absolute top-0 right-0 left-0 rounded-[16px] bg-[color-mix(in_oklab,var(--ink)_6.5%,transparent)]\"\n                  style={{ y: hy, height: hh, opacity: ho }}\n                  aria-hidden=\"true\"\n                />\n                {visibleSections.map((section, sectionIndex) => {\n                  const rows = section.options.map((option) => {\n                    const index = indexOf.get(option.value) ?? 0;\n                    const isSelected = option.value === selected,\n                      isActive = option.value === current;\n                    return (\n                      // oxlint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events -- options are reached through aria-activedescendant; the trigger or search field handles the keys\n                      <div\n                        key={option.value}\n                        id={optionId(index)}\n                        role=\"option\"\n                        aria-selected={isSelected}\n                        aria-disabled={option.disabled || undefined}\n                        className={cn(\n                          \"relative box-border flex min-h-9 cursor-pointer items-center gap-2 rounded-[16px] py-0 pr-2.5 pl-2 text-ink-2 transition-[color] duration-fast ease-standard select-none\",\n                          option.disabled\n                            ? \"cursor-not-allowed text-ink-3 opacity-60\"\n                            : (isActive || isSelected) && \"text-ink\",\n                        )}\n                        data-active={isActive || undefined}\n                        ref={(node) => {\n                          if (node) optionRefs.current.set(option.value, node);\n                          else optionRefs.current.delete(option.value);\n                        }}\n                        onPointerMove={(event) => {\n                          if (event.pointerType === \"mouse\" && !option.disabled && option.value !== current)\n                            setActive(option.value);\n                        }}\n                        onPointerDown={(event) => event.preventDefault()}\n                        onClick={(event) => commit(option, event.currentTarget)}\n                      >\n                        <span className=\"flex min-w-0 flex-1 items-center gap-2 font-medium\" data-part=\"value\">\n                          {option.icon && (\n                            <span className={iconClass} aria-hidden=\"true\">\n                              {option.icon}\n                            </span>\n                          )}\n                          <span className={valueTextClass}>{option.label}</span>\n                        </span>\n                        {option.meta && (\n                          <span className=\"flex-none text-(length:--text-xs) text-ink-3 tabular-nums\">\n                            {option.meta}\n                          </span>\n                        )}\n                        <span\n                          className=\"grid size-4 flex-none place-items-center text-ink opacity-0 [transform:scale(.6)] [transition:opacity_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] data-on:opacity-100 data-on:[transform:none] motion-reduce:duration-0\"\n                          data-on={isSelected || undefined}\n                          aria-hidden=\"true\"\n                        >\n                          <Check size={16} strokeWidth={1.75} />\n                        </span>\n                      </div>\n                    );\n                  });\n                  return section.label ? (\n                    <div\n                      key={section.key}\n                      role=\"group\"\n                      aria-labelledby={`${uid}-${section.key}`}\n                      className={cn(\"grid gap-0.5\", sectionIndex > 0 && \"mt-1\")}\n                    >\n                      <div\n                        id={`${uid}-${section.key}`}\n                        className=\"px-2.5 pt-2 pb-1 text-(length:--text-xs) font-normal text-ink-3\"\n                        role=\"presentation\"\n                      >\n                        {section.label}\n                      </div>\n                      {rows}\n                    </div>\n                  ) : (\n                    <div\n                      key={section.key}\n                      role=\"presentation\"\n                      className={cn(\"grid gap-0.5\", sectionIndex > 0 && \"mt-1\")}\n                    >\n                      {rows}\n                    </div>\n                  );\n                })}\n                {resultCount === 0 && (\n                  <p className=\"m-0 px-2.5 pt-3 pb-3.5 text-(length:--text-sm) text-ink-3\">\n                    No matches for “{query.trim()}”\n                  </p>\n                )}\n              </div>\n            </div>\n          </motion.div>\n        </motion.div>\n      </motion.div>\n\n      {name && <input type=\"hidden\" name={name} value={selected ?? \"\"} />}\n      <span className={srOnly} role=\"status\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </div>\n  );\n}\n\nexport default MorphSelect;\n",
      "type": "registry:component",
      "target": "@components/iris/morph-select/morph-select.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/morph-select",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "MorphSelect",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/morph-select"
  }
}
