{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "listbox",
  "title": "Listbox",
  "description": "A list you choose from, always open: single or multiple, type-ahead, Home and End, groups and disabled options, written to the full ARIA pattern.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/listbox.tsx",
      "content": "import type * as React from \"react\"\nimport { useId, useRef } from \"react\"\nimport { LayoutGroup, motion, useReducedMotion } from \"motion/react\"\nimport { ListboxOptionRow, type ListboxVariant } from \"@/registry/miseui/ui/listbox-option\"\nimport { useListboxState, type ListboxGroup, type ListboxOption, type ListboxState } from \"@/registry/miseui/ui/listbox-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype ListboxEffect = \"spring\"\n\nconst ROOT_CLASS: Record<ListboxVariant, string> = {\n  plain: \"gap-1\",\n  check: \"gap-1\",\n  card: \"gap-2\",\n  compact: \"rounded-md border divide-y\",\n}\n\ntype RootAttributes = Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\" | \"onChange\" | \"onKeyDown\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\"\n>\n\ninterface ListboxBaseProps extends RootAttributes {\n  options: readonly ListboxOption[]\n  /** `plain` filled rows, `check` a trailing check or checkboxes, `card` bordered choices, `compact` dense ruled rows. */\n  variant?: ListboxVariant\n  /** `spring` slides one highlight between options when a single selection changes. */\n  effect?: ListboxEffect\n  disabled?: boolean\n  /** Posts the selection with the surrounding form. */\n  name?: string\n}\n\ntype ListboxSingleProps = ListboxBaseProps & {\n  multiple?: false\n  value?: string | null\n  defaultValue?: string | null\n  onValueChange?: (value: string) => void\n}\n\ntype ListboxMultipleProps = ListboxBaseProps & {\n  multiple: true\n  value?: readonly string[]\n  defaultValue?: readonly string[]\n  onValueChange?: (value: string[]) => void\n}\n\ntype ListboxProps = ListboxSingleProps | ListboxMultipleProps\n\nfunction toArray(value: string | readonly string[] | null | undefined) {\n  if (value === null || value === undefined) return []\n  return typeof value === \"string\" ? [value] : value\n}\n\ninterface ListboxGroupViewProps {\n  group: ListboxGroup\n  variant: ListboxVariant\n  multiple: boolean\n  spring: boolean\n  reduceMotion: boolean\n  state: ListboxState\n}\n\nfunction ListboxGroupView({ group, variant, multiple, spring, reduceMotion, state }: ListboxGroupViewProps) {\n  const labelId = useId()\n  const rows = group.options.map((option) => (\n    <ListboxOptionRow\n      key={option.value}\n      option={option}\n      id={state.optionDomId(option.value)}\n      variant={variant}\n      multiple={multiple}\n      selected={state.selected.includes(option.value)}\n      active={state.active?.value === option.value}\n      spring={spring}\n      reduceMotion={reduceMotion}\n      onPick={state.pick}\n    />\n  ))\n  if (group.name === null) return rows\n  return (\n    <div role=\"group\" aria-labelledby={labelId} className={cn(\"grid\", variant === \"compact\" ? \"divide-y\" : ROOT_CLASS[variant])}>\n      <div id={labelId} role=\"presentation\" className=\"px-3 pt-2 pb-1 text-[13px] text-muted-foreground\">{group.name}</div>\n      {rows}\n    </div>\n  )\n}\n\n/** Everything else on the props is a div attribute and goes on the list, which is what a Field needs. */\nconst OWN_PROPS: ReadonlySet<string> = new Set([\"options\", \"variant\", \"effect\", \"disabled\", \"name\", \"multiple\", \"value\", \"defaultValue\", \"onValueChange\", \"className\"])\n\nfunction rootAttributes(props: ListboxProps) {\n  return Object.fromEntries(Object.entries(props).filter(([key]) => !OWN_PROPS.has(key))) as RootAttributes\n}\n\nfunction Listbox(props: ListboxProps) {\n  const { options, variant = \"plain\", effect, disabled = false, name, className } = props\n  const multiple = props.multiple === true\n  const groupId = useId()\n  const listRef = useRef<HTMLDivElement>(null)\n  const reduceMotion = useReducedMotion() ?? false\n  const controlled = props.value === undefined ? undefined : toArray(props.value)\n  const emit = (next: readonly string[]) => {\n    if (props.multiple) props.onValueChange?.([...next])\n    else if (next[0] !== undefined) props.onValueChange?.(next[0])\n  }\n  const state = useListboxState({ options, multiple, disabled, controlled, initial: toArray(props.defaultValue), emit }, listRef)\n  return (\n    <LayoutGroup id={groupId}>\n      {name ? state.selected.map((entry) => <input key={entry} type=\"hidden\" name={name} value={entry} />) : null}\n      {/* layoutScroll, or the highlight would start its flight from where the row sat before the list was scrolled. */}\n      <motion.div\n        ref={listRef}\n        layoutScroll\n        role=\"listbox\"\n        tabIndex={disabled ? -1 : 0}\n        aria-multiselectable={multiple || undefined}\n        aria-disabled={disabled || undefined}\n        aria-activedescendant={!disabled && state.active ? state.optionDomId(state.active.value) : undefined}\n        data-slot=\"listbox\"\n        data-variant={variant}\n        data-effect={effect}\n        className={cn(\"group/listbox grid min-w-0 overflow-y-auto outline-none\", ROOT_CLASS[variant], disabled && \"pointer-events-none opacity-50\", className)}\n        onKeyDown={disabled ? undefined : state.onKeyDown}\n        {...rootAttributes(props)}\n      >\n        {state.groups.map((group) => (\n          <ListboxGroupView key={group.name ?? \"\"} group={group} variant={variant} multiple={multiple} spring={effect === \"spring\"} reduceMotion={reduceMotion} state={state} />\n        ))}\n      </motion.div>\n    </LayoutGroup>\n  )\n}\n\nexport { Listbox, type ListboxProps, type ListboxOption, type ListboxVariant, type ListboxEffect }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/listbox-option.tsx",
      "content": "import { Check } from \"lucide-react\"\nimport { motion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport type { ListboxOption as ListboxOptionData } from \"@/registry/miseui/ui/listbox-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype ListboxVariant = \"plain\" | \"check\" | \"card\" | \"compact\"\n\nconst ROW_CLASS: Record<ListboxVariant, string> = {\n  plain: \"min-h-9 gap-2 rounded-md px-3 py-2 text-sm pointer-coarse:min-h-10\",\n  check: \"min-h-9 gap-2 rounded-md px-3 py-2 text-sm pointer-coarse:min-h-10\",\n  card: \"gap-3 rounded-lg border p-3 text-sm\",\n  compact: \"min-h-8 gap-2 px-2 py-1 text-[13px] pointer-coarse:min-h-10\",\n}\n\n/** On the row with no effect, on the flying highlight with `spring`, so both looks stay identical at rest. */\nconst SELECTED_CLASS: Record<ListboxVariant, string> = {\n  plain: \"bg-accent\",\n  check: \"\",\n  card: \"ring-2 ring-foreground ring-inset\",\n  compact: \"bg-mise-soft\",\n}\n\nconst HOVER_CLASS: Record<ListboxVariant, string> = {\n  plain: \"hover:bg-accent/50\",\n  check: \"hover:bg-accent\",\n  card: \"hover:bg-accent/50\",\n  compact: \"hover:bg-accent/50\",\n}\n\ninterface CheckMarkProps {\n  multiple: boolean\n  selected: boolean\n}\n\nfunction CheckMark({ multiple, selected }: CheckMarkProps) {\n  if (multiple) {\n    return (\n      <span\n        aria-hidden=\"true\"\n        data-slot=\"listbox-checkbox\"\n        className={cn(\n          \"flex size-4 shrink-0 items-center justify-center rounded-sm border border-input\",\n          selected && \"border-mise bg-mise text-mise-foreground\",\n        )}\n      >\n        {selected ? <Check className=\"size-3\" /> : null}\n      </span>\n    )\n  }\n  return <Check aria-hidden=\"true\" data-slot=\"listbox-check\" className={cn(\"ml-auto size-4 shrink-0\", !selected && \"invisible\")} />\n}\n\ninterface ListboxOptionRowProps {\n  option: ListboxOptionData\n  id: string\n  variant: ListboxVariant\n  multiple: boolean\n  selected: boolean\n  active: boolean\n  spring: boolean\n  reduceMotion: boolean\n  onPick: (option: ListboxOptionData) => void\n}\n\nfunction ListboxOptionRow({ option, id, variant, multiple, selected, active, spring, reduceMotion, onPick }: ListboxOptionRowProps) {\n  const showHighlight = spring && selected && SELECTED_CLASS[variant] !== \"\"\n  return (\n    <div\n      id={id}\n      role=\"option\"\n      aria-selected={selected}\n      aria-disabled={option.disabled || undefined}\n      data-slot=\"listbox-option\"\n      data-active={active ? \"true\" : undefined}\n      className={cn(\n        \"relative isolate flex cursor-default items-center text-left select-none\",\n        \"transition-colors duration-(--duration-fast) ease-(--ease-mise)\",\n        // Only while the list holds keyboard focus: a mouse user has no use for a second marker.\n        \"group-focus-visible/listbox:data-active:ring-[3px] group-focus-visible/listbox:data-active:ring-ring/50 group-focus-visible/listbox:data-active:ring-inset\",\n        ROW_CLASS[variant],\n        option.disabled ? \"cursor-not-allowed opacity-50\" : HOVER_CLASS[variant],\n        selected && !spring && SELECTED_CLASS[variant],\n        selected && variant !== \"check\" && \"font-medium\",\n      )}\n      onClick={() => onPick(option)}\n    >\n      {showHighlight ? (\n        <motion.span\n          aria-hidden=\"true\"\n          data-slot=\"listbox-highlight\"\n          data-reduced={reduceMotion ? \"true\" : undefined}\n          // Selections in a multiple list stay put, so only a single selection has somewhere to fly from.\n          layoutId={multiple || reduceMotion ? undefined : \"listbox-highlight\"}\n          initial={multiple && !reduceMotion ? { opacity: 0 } : false}\n          animate={{ opacity: 1 }}\n          transition={reduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n          className={cn(\"absolute inset-0 -z-10 rounded-[inherit]\", SELECTED_CLASS[variant])}\n        />\n      ) : null}\n      {variant === \"check\" && multiple ? <CheckMark multiple selected={selected} /> : null}\n      {option.icon && variant !== \"compact\" ? <span aria-hidden=\"true\" className=\"flex shrink-0 text-muted-foreground [&_svg]:size-4\">{option.icon}</span> : null}\n      <span className=\"min-w-0 flex-1\">\n        <span className=\"block break-words\">{option.label}</span>\n        {option.description && variant !== \"compact\" ? <span className=\"block text-[13px] font-normal break-words text-muted-foreground\">{option.description}</span> : null}\n      </span>\n      {variant === \"check\" && !multiple ? <CheckMark multiple={false} selected={selected} /> : null}\n    </div>\n  )\n}\n\nexport { ListboxOptionRow, type ListboxVariant }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/listbox-state.ts",
      "content": "import type * as React from \"react\"\nimport { useId, useMemo, useRef, useState } from \"react\"\n\ninterface ListboxOption {\n  value: string\n  label: string\n  description?: string\n  /** Options sharing a group sit together under its name, in the order the groups first appear. */\n  group?: string\n  disabled?: boolean\n  icon?: React.ReactNode\n}\n\ninterface ListboxGroup {\n  name: string | null\n  options: readonly ListboxOption[]\n}\n\nconst TYPEAHEAD_RESET_MS = 500\n\n/** Display order, which is the order the keyboard has to follow even when the owner's array mixes its groups. */\nfunction groupOptions(options: readonly ListboxOption[]): readonly ListboxGroup[] {\n  const names = [...new Set(options.map((option) => option.group ?? null))]\n  return names.map((name) => ({ name, options: options.filter((option) => (option.group ?? null) === name) }))\n}\n\nfunction firstEnabled(options: readonly ListboxOption[]) {\n  return options.find((option) => !option.disabled) ?? null\n}\n\n/** Walks from `from` in steps of `delta` and stops at the ends; a listbox does not wrap. */\nfunction nextEnabled(options: readonly ListboxOption[], from: number, delta: 1 | -1) {\n  for (let index = from + delta; index >= 0 && index < options.length; index += delta) {\n    if (!options[index].disabled) return options[index]\n  }\n  return null\n}\n\n/** Starts after the current option, so pressing one letter again walks through everything that starts with it. */\nfunction typeaheadMatch(options: readonly ListboxOption[], query: string, from: number) {\n  const repeated = [...query].every((character) => character === query[0])\n  const needle = (repeated ? query[0] : query).toLowerCase()\n  const start = repeated ? from + 1 : from\n  const rotated = [...options.slice(Math.max(start, 0)), ...options.slice(0, Math.max(start, 0))]\n  return rotated.find((option) => !option.disabled && option.label.toLowerCase().startsWith(needle)) ?? null\n}\n\nfunction keepInView(list: HTMLElement, option: HTMLElement | null) {\n  if (!option) return\n  // Not scrollIntoView, which would also scroll the page around the list.\n  const listBox = list.getBoundingClientRect()\n  const box = option.getBoundingClientRect()\n  if (box.top < listBox.top) list.scrollTop -= listBox.top - box.top\n  else if (box.bottom > listBox.bottom) list.scrollTop += box.bottom - listBox.bottom\n}\n\ninterface ListboxStateOptions {\n  options: readonly ListboxOption[]\n  multiple: boolean\n  disabled: boolean\n  controlled?: readonly string[]\n  initial: readonly string[]\n  emit: (next: readonly string[]) => void\n}\n\nfunction useListboxSelection({ controlled, initial, multiple, emit }: Pick<ListboxStateOptions, \"controlled\" | \"initial\" | \"multiple\" | \"emit\">) {\n  const [uncontrolled, setUncontrolled] = useState(initial)\n  const selected = controlled ?? uncontrolled\n  const replace = (next: readonly string[]) => {\n    setUncontrolled(next)\n    emit(next)\n  }\n  const toggle = (value: string) => {\n    if (!multiple) {\n      replace([value])\n      return\n    }\n    replace(selected.includes(value) ? selected.filter((entry) => entry !== value) : [...selected, value])\n  }\n  return { selected, replace, toggle }\n}\n\nfunction useListboxTypeahead(jump: (query: string) => void) {\n  const buffer = useRef(\"\")\n  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n  return {\n    busy: () => buffer.current.length > 0,\n    press: (character: string) => {\n      clearTimeout(timer.current)\n      buffer.current += character\n      jump(buffer.current)\n      timer.current = setTimeout(() => {\n        buffer.current = \"\"\n      }, TYPEAHEAD_RESET_MS)\n    },\n  }\n}\n\ninterface ListboxKeyActions {\n  multiple: boolean\n  ordered: readonly ListboxOption[]\n  enabled: readonly ListboxOption[]\n  active: ListboxOption | null\n  stepped: (delta: 1 | -1, extend: boolean) => void\n  moveTo: (option: ListboxOption | null) => void\n  pick: (option: ListboxOption) => void\n  selectAll: () => void\n  typeahead: ReturnType<typeof useListboxTypeahead>\n}\n\nfunction listboxKeyDown(event: React.KeyboardEvent, keys: ListboxKeyActions) {\n  const { multiple, ordered, enabled, active, stepped, moveTo, pick, selectAll, typeahead } = keys\n  const ctrl = event.ctrlKey || event.metaKey\n  const actions: Record<string, () => void> = {\n    ArrowDown: () => stepped(1, event.shiftKey),\n    ArrowUp: () => stepped(-1, event.shiftKey),\n    Home: () => moveTo(firstEnabled(ordered)),\n    End: () => moveTo(enabled[enabled.length - 1] ?? null),\n    Enter: () => {\n      if (active) pick(active)\n    },\n  }\n  if (ctrl && event.key.toLowerCase() === \"a\" && multiple) {\n    event.preventDefault()\n    selectAll()\n  } else if (actions[event.key]) {\n    event.preventDefault()\n    actions[event.key]()\n  } else if (event.key === \" \" && !typeahead.busy()) {\n    event.preventDefault()\n    if (active) pick(active)\n  } else if (event.key.length === 1 && !ctrl && !event.altKey) {\n    event.preventDefault()\n    typeahead.press(event.key)\n  }\n}\n\nfunction useListboxState(props: ListboxStateOptions, listRef: React.RefObject<HTMLDivElement | null>) {\n  const { options, multiple, disabled } = props\n  const listId = useId()\n  const groups = useMemo(() => groupOptions(options), [options])\n  const ordered = useMemo(() => groups.flatMap((group) => group.options), [groups])\n  const { selected, replace, toggle } = useListboxSelection(props)\n  const [activeValue, setActiveValue] = useState<string | null>(null)\n  const enabled = ordered.filter((option) => !option.disabled)\n  const stillThere = ordered.find((option) => option.value === activeValue && !option.disabled)\n  const active = stillThere ?? ordered.find((option) => selected.includes(option.value) && !option.disabled) ?? firstEnabled(ordered)\n  const activeIndex = active ? ordered.indexOf(active) : -1\n  const optionDomId = (value: string) => `${listId}-${ordered.findIndex((option) => option.value === value)}`\n  const moveTo = (option: ListboxOption | null) => {\n    if (!option) return\n    setActiveValue(option.value)\n    if (listRef.current) keepInView(listRef.current, document.getElementById(optionDomId(option.value)))\n  }\n  const typeahead = useListboxTypeahead((query) => moveTo(typeaheadMatch(ordered, query, activeIndex)))\n  const pick = (option: ListboxOption) => {\n    if (option.disabled || disabled) return\n    setActiveValue(option.value)\n    toggle(option.value)\n  }\n  const stepped = (delta: 1 | -1, extend: boolean) => {\n    const next = nextEnabled(ordered, activeIndex, delta)\n    moveTo(next)\n    if (next && extend && multiple) toggle(next.value)\n  }\n  const selectAll = () => {\n    const values = enabled.map((option) => option.value)\n    replace(values.every((value) => selected.includes(value)) ? [] : values)\n  }\n  const onKeyDown = (event: React.KeyboardEvent) =>\n    listboxKeyDown(event, { multiple, ordered, enabled, active, stepped, moveTo, pick, selectAll, typeahead })\n  return { groups, ordered, selected, active, optionDomId, pick, onKeyDown }\n}\n\ntype ListboxState = ReturnType<typeof useListboxState>\n\nexport {\n  firstEnabled,\n  groupOptions,\n  nextEnabled,\n  typeaheadMatch,\n  useListboxState,\n  type ListboxGroup,\n  type ListboxOption,\n  type ListboxState,\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}