{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"select","type":"registry:ui","title":"Select","description":"Native or searchable listbox selection, including grouped and multi-select options.","package":"react-fancy","dependencies":["tailwind-merge"],"registryDependencies":["field","portal"],"files":[{"path":"components\/fancy\/select\/Select.tsx","content":"import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"..\/lib\/utils\/cn\";\nimport { Field } from \"..\/field\";\nimport { InputWrapper } from \"..\/lib\/components\/inputs\/InputWrapper\";\nimport { Portal } from \"..\/portal\";\nimport { useFloatingPosition } from \"..\/lib\/hooks\/use-floating-position\";\nimport { useOutsideClick } from \"..\/lib\/hooks\/use-outside-click\";\nimport { useEscapeKey } from \"..\/lib\/hooks\/use-escape-key\";\nimport {\n  dirtyClasses,\n  errorClasses,\n  inputBaseClasses,\n  inputSizeClasses,\n  resolveOption,\n} from \"..\/lib\/components\/inputs\/inputs.utils\";\nimport { useFieldMode } from \"..\/lib\/components\/inputs\/mode\/FieldMode.context\";\nimport { DisplayValue } from \"..\/lib\/components\/inputs\/mode\/DisplayValue\";\nimport { useInlineEdit } from \"..\/lib\/components\/inputs\/mode\/useInlineEdit\";\nimport type { InputOption, InputOptionGroup } from \"..\/lib\/components\/inputs\/inputs.types\";\nimport type { SelectProps } from \".\/Select.types\";\n\nfunction isOptionGroup(\n  item: InputOption | InputOptionGroup,\n): item is InputOptionGroup {\n  return typeof item === \"object\" && \"options\" in item;\n}\n\nfunction flattenOptions(list: (InputOption | InputOptionGroup)[]): InputOption[] {\n  const flat: InputOption[] = [];\n  for (const item of list) {\n    if (isOptionGroup(item)) {\n      flat.push(...item.options);\n    } else {\n      flat.push(item);\n    }\n  }\n  return flat;\n}\n\nfunction renderNativeOption(option: InputOption, index: number) {\n  const resolved = resolveOption(option);\n  return (\n    <option\n      key={`${resolved.value}-${index}`}\n      value={resolved.value as string}\n      disabled={resolved.disabled}\n    >\n      {resolved.label}\n    <\/option>\n  );\n}\n\n\/\/ \u2500\u2500 Native variant \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst NativeSelect = forwardRef<HTMLSelectElement, SelectProps>(\n  (\n    {\n      size = \"md\",\n      dirty,\n      error,\n      label,\n      labelHidden,\n      description,\n      required,\n      disabled,\n      className,\n      id,\n      list,\n      placeholder,\n      prefix,\n      suffix,\n      prefixPosition,\n      suffixPosition,\n      mode,\n      onValueChange,\n      onChange,\n      value,\n      defaultValue,\n      ...props\n    },\n    ref,\n  ) => {\n    const autoId = useId();\n    const selectId = id ?? autoId;\n    const resolvedMode = useFieldMode(mode);\n    const { showControl, interactive, enterEdit, exitEdit } = useInlineEdit(resolvedMode, disabled);\n\n    if (!showControl) {\n      const current = (value ?? defaultValue) as string | undefined;\n      const opt = flattenOptions(list)\n        .map((o) => resolveOption(o))\n        .find((o) => o.value === current);\n      const display = (\n        <DisplayValue\n          size={size}\n          leading={prefix}\n          trailing={suffix}\n          interactive={interactive}\n          onActivate={enterEdit}\n        >\n          {opt?.label ?? current}\n        <\/DisplayValue>\n      );\n      if (label || error || description) {\n        return (\n          <Field\n            label={label}\n            labelHidden={labelHidden}\n            description={description}\n            error={error}\n            required={required}\n            htmlFor={selectId}\n            size={size}\n          >\n            {display}\n          <\/Field>\n        );\n      }\n      return display;\n    }\n\n    \/\/ When a placeholder is provided but no value\/defaultValue, default to \"\"\n    \/\/ so the disabled placeholder <option> is the initial selection instead of\n    \/\/ the browser auto-picking the first real option.\n    const isControlled = value !== undefined;\n    const resolvedDefault =\n      !isControlled && defaultValue === undefined && placeholder ? \"\" : defaultValue;\n\n    const select = (\n      <InputWrapper\n        prefix={prefix}\n        suffix={suffix}\n        prefixPosition={prefixPosition}\n        suffixPosition={suffixPosition}\n        size={size}\n      >\n        <select\n          data-react-fancy-select=\"\"\n          ref={ref}\n          id={selectId}\n          disabled={disabled}\n          required={required}\n          className={cn(\n            inputBaseClasses,\n            inputSizeClasses[size],\n            dirtyClasses(dirty),\n            errorClasses(error),\n            \"w-full appearance-none bg-no-repeat bg-[length:16px] bg-[right_8px_center]\",\n            \"bg-[url('data:image\/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22%236b7280%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M5.23%207.21a.75.75%200%20011.06.02L10%2011.168l3.71-3.938a.75.75%200%20111.08%201.04l-4.25%204.5a.75.75%200%2001-1.08%200l-4.25-4.5a.75.75%200%2001.02-1.06z%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E')]\",\n            \"pr-8\",\n            className,\n          )}\n          onChange={(e) => {\n            onChange?.(e);\n            onValueChange?.(e.target.value);\n          }}\n          {...props}\n          {...(isControlled ? { value } : { defaultValue: resolvedDefault })}\n          autoFocus={interactive || props.autoFocus}\n          onBlur={(e) => {\n            props.onBlur?.(e);\n            if (interactive) exitEdit();\n          }}\n        >\n          {placeholder && (\n            <option value=\"\" disabled>\n              {placeholder}\n            <\/option>\n          )}\n          {list.map((item, index) =>\n            isOptionGroup(item) ? (\n              <optgroup key={`group-${index}`} label={item.label}>\n                {item.options.map((opt, optIndex) =>\n                  renderNativeOption(opt, optIndex),\n                )}\n              <\/optgroup>\n            ) : (\n              renderNativeOption(item, index)\n            ),\n          )}\n        <\/select>\n      <\/InputWrapper>\n    );\n\n    if (label || error || description) {\n      return (\n        <Field\n          label={label}\n          labelHidden={labelHidden}\n          description={description}\n          error={error}\n          required={required}\n          htmlFor={selectId}\n          size={size}\n        >\n          {select}\n        <\/Field>\n      );\n    }\n\n    return select;\n  },\n);\n\nNativeSelect.displayName = \"NativeSelect\";\n\n\/\/ \u2500\u2500 Listbox variant \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst ListboxSelect = forwardRef<HTMLSelectElement, SelectProps>(\n  (\n    {\n      size = \"md\",\n      dirty,\n      error,\n      label,\n      labelHidden,\n      description,\n      required,\n      disabled,\n      className,\n      id,\n      list,\n      placeholder = \"Select...\",\n      multiple = false,\n      values: controlledValues,\n      defaultValues,\n      onValueChange,\n      onValuesChange,\n      searchable = false,\n      creatable = false,\n      onCreate,\n      createLabel = \"Create\",\n      selectedSuffix = \"selected\",\n      indicator = \"check\",\n      mode,\n      value: controlledSingleValue,\n      defaultValue: defaultSingleValue,\n    },\n    _ref,\n  ) => {\n    const autoId = useId();\n    const selectId = id ?? autoId;\n    const resolvedMode = useFieldMode(mode);\n    const { showControl, interactive, enterEdit, exitEdit } = useInlineEdit(resolvedMode, disabled);\n\n    \/\/ Creatable implies a text input, which is also the search input.\n    const textInputEnabled = searchable || creatable;\n\n    \/\/ \u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    const [open, setOpen] = useState(false);\n    const [search, setSearch] = useState(\"\");\n    const [activeIndex, setActiveIndex] = useState(-1);\n    const [createdOptions, setCreatedOptions] = useState<InputOption[]>([]);\n\n    \/\/ Single-select state\n    const [singleValue, setSingleValue] = useState<string>(\n      (controlledSingleValue as string) ?? (defaultSingleValue as string) ?? \"\",\n    );\n    const currentSingle = (controlledSingleValue as string) ?? singleValue;\n\n    \/\/ Multi-select state\n    const [multiValues, setMultiValues] = useState<string[]>(\n      controlledValues ?? defaultValues ?? [],\n    );\n    const currentMulti = controlledValues ?? multiValues;\n\n    \/\/ Sync controlled values\n    useEffect(() => {\n      if (controlledValues) setMultiValues(controlledValues);\n    }, [controlledValues]);\n    useEffect(() => {\n      if (controlledSingleValue !== undefined) setSingleValue(controlledSingleValue as string);\n    }, [controlledSingleValue]);\n\n    \/\/ \u2500\u2500 Refs \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    const anchorRef = useRef<HTMLButtonElement>(null);\n    const listRef = useRef<HTMLDivElement>(null);\n    const searchRef = useRef<HTMLInputElement>(null);\n\n    const position = useFloatingPosition(anchorRef, listRef, {\n      placement: \"bottom-start\",\n      offset: 4,\n      enabled: open,\n    });\n\n    const close = useCallback(() => {\n      setOpen(false);\n      setSearch(\"\");\n      setActiveIndex(-1);\n    }, []);\n\n    \/\/ Outside-click guards the dropdown (in a Portal) against its own clicks,\n    \/\/ with the trigger button ignored since it toggles open\/closed itself.\n    useOutsideClick(listRef, close, open, anchorRef);\n    useEscapeKey(close, open);\n\n    \/\/ Focus text input on open\n    useEffect(() => {\n      if (open && textInputEnabled) {\n        requestAnimationFrame(() => searchRef.current?.focus());\n      }\n    }, [open, textInputEnabled]);\n\n    \/\/ \u2500\u2500 Options \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    const resolvedOptions = useMemo(() => {\n      const base = flattenOptions(list).map(resolveOption);\n      const created = createdOptions.map(resolveOption);\n      return [...base, ...created];\n    }, [list, createdOptions]);\n\n    const filtered = search\n      ? resolvedOptions.filter((o) =>\n          o.label.toLowerCase().includes(search.toLowerCase()),\n        )\n      : resolvedOptions;\n\n    \/\/ Exact-match check to decide whether to show the \"Create X\" row\n    const canCreate =\n      creatable &&\n      search.trim().length > 0 &&\n      !resolvedOptions.some(\n        (o) =>\n          o.label.toLowerCase() === search.trim().toLowerCase() ||\n          o.value === search.trim(),\n      );\n\n    \/\/ \u2500\u2500 Selection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    const isSelected = (value: string): boolean => {\n      if (multiple) return currentMulti.includes(value);\n      return currentSingle === value;\n    };\n\n    const toggleOption = useCallback(\n      (value: string) => {\n        if (multiple) {\n          const next = currentMulti.includes(value)\n            ? currentMulti.filter((v) => v !== value)\n            : [...currentMulti, value];\n          setMultiValues(next);\n          onValuesChange?.(next);\n        } else {\n          setSingleValue(value);\n          onValueChange?.(value);\n          close();\n        }\n      },\n      [multiple, currentMulti, onValuesChange, onValueChange, close],\n    );\n\n    const handleCreate = useCallback(() => {\n      const label = search.trim();\n      if (!label) return;\n      const value = label;\n      const newOption: InputOption = { value, label };\n      setCreatedOptions((prev) => [...prev, newOption]);\n      onCreate?.(label);\n      setSearch(\"\");\n      if (multiple) {\n        const next = [...currentMulti, value];\n        setMultiValues(next);\n        onValuesChange?.(next);\n        \/\/ Keep dropdown open for multi-select; refocus input\n        requestAnimationFrame(() => searchRef.current?.focus());\n      } else {\n        setSingleValue(value);\n        onValueChange?.(value);\n        close();\n      }\n    }, [search, multiple, currentMulti, onCreate, onValuesChange, onValueChange, close]);\n\n    \/\/ \u2500\u2500 Display text \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    const getDisplayText = (): string => {\n      if (multiple) {\n        if (currentMulti.length === 0) return placeholder;\n        if (currentMulti.length === 1) {\n          const opt = resolvedOptions.find((o) => o.value === currentMulti[0]);\n          return opt?.label ?? currentMulti[0];\n        }\n        return `${currentMulti.length} ${selectedSuffix}`;\n      }\n      if (!currentSingle) return placeholder;\n      const opt = resolvedOptions.find((o) => o.value === currentSingle);\n      return opt?.label ?? currentSingle;\n    };\n\n    const hasValue = multiple ? currentMulti.length > 0 : !!currentSingle;\n\n    \/\/ \u2500\u2500 Keyboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    const handleKeyDown = (e: React.KeyboardEvent) => {\n      if (!open) {\n        if (e.key === \"ArrowDown\" || e.key === \"Enter\" || e.key === \" \") {\n          e.preventDefault();\n          setOpen(true);\n        }\n        return;\n      }\n\n      if (e.key === \"ArrowDown\") {\n        e.preventDefault();\n        setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));\n      } else if (e.key === \"ArrowUp\") {\n        e.preventDefault();\n        setActiveIndex((i) => Math.max(i - 1, 0));\n      } else if (e.key === \"Enter\") {\n        if (activeIndex >= 0) {\n          e.preventDefault();\n          const item = filtered[activeIndex];\n          if (item && !item.disabled) toggleOption(item.value as string);\n        } else if (canCreate) {\n          e.preventDefault();\n          handleCreate();\n        }\n      }\n    };\n\n    \/\/ \u2500\u2500 View mode \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    if (!showControl) {\n      const labels = multiple\n        ? currentMulti.map(\n            (v) => resolvedOptions.find((o) => o.value === v)?.label ?? v,\n          )\n        : currentSingle\n          ? [resolvedOptions.find((o) => o.value === currentSingle)?.label ?? currentSingle]\n          : [];\n      const display = (\n        <DisplayValue size={size} interactive={interactive} onActivate={enterEdit}>\n          {labels.join(\", \")}\n        <\/DisplayValue>\n      );\n      if (label || error || description) {\n        return (\n          <Field\n            label={label}\n            labelHidden={labelHidden}\n            description={description}\n            error={error}\n            required={required}\n            htmlFor={selectId}\n            size={size}\n          >\n            {display}\n          <\/Field>\n        );\n      }\n      return display;\n    }\n\n    \/\/ \u2500\u2500 Render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    const trigger = (\n      <button\n        ref={anchorRef}\n        type=\"button\"\n        id={selectId}\n        disabled={disabled}\n        autoFocus={interactive}\n        onClick={() => setOpen((o) => !o)}\n        onKeyDown={handleKeyDown}\n        role=\"combobox\"\n        aria-expanded={open}\n        aria-haspopup=\"listbox\"\n        data-react-fancy-select=\"\"\n        data-variant=\"listbox\"\n        className={cn(\n          inputBaseClasses,\n          inputSizeClasses[size],\n          dirtyClasses(dirty),\n          errorClasses(error),\n          \"flex w-full cursor-pointer items-center justify-between gap-2 text-left\",\n          !hasValue && \"text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\",\n          className,\n        )}\n      >\n        <span className=\"truncate\">{getDisplayText()}<\/span>\n        <svg\n          className={cn(\"h-4 w-4 shrink-0 text-zinc-400 transition-transform\", open && \"rotate-180\")}\n          viewBox=\"0 0 20 20\"\n          fill=\"currentColor\"\n        >\n          <path\n            fillRule=\"evenodd\"\n            d=\"M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z\"\n            clipRule=\"evenodd\"\n          \/>\n        <\/svg>\n      <\/button>\n    );\n\n    const dropdown = open && (\n      <Portal>\n        <div\n          ref={listRef}\n          role=\"listbox\"\n          aria-multiselectable={multiple || undefined}\n          className=\"fixed z-50 max-h-60 min-w-[8rem] overflow-y-auto rounded-xl border border-[color:var(--fancy-border,var(--color-zinc-200))] bg-[color:var(--fancy-surface,var(--color-white))] p-1 shadow-lg dark:border-[color:var(--fancy-border-dark,var(--color-zinc-700))] dark:bg-[color:var(--fancy-surface-dark,var(--color-zinc-900))] dark:shadow-zinc-950\/50 fancy-scale-in\"\n          style={{\n            left: position.x,\n            top: position.y,\n            width: anchorRef.current?.offsetWidth,\n          }}\n        >\n          {textInputEnabled && (\n            <div className=\"px-2 pb-1 pt-1\">\n              <input\n                ref={searchRef}\n                type=\"text\"\n                value={search}\n                onChange={(e) => {\n                  setSearch(e.target.value);\n                  setActiveIndex(-1);\n                }}\n                onKeyDown={handleKeyDown}\n                placeholder={creatable && !searchable ? \"Type to add\u2026\" : \"Search\u2026\"}\n                className=\"w-full rounded-md border-0 bg-[color:var(--fancy-surface-muted,var(--color-zinc-100))] px-2.5 py-1.5 text-sm text-[color:var(--fancy-text,var(--color-zinc-900))] placeholder:text-[color:var(--fancy-text-faint,var(--color-zinc-400))] outline-none dark:bg-[color:var(--fancy-surface-muted-dark,var(--color-zinc-800))] dark:text-[color:var(--fancy-text-dark,var(--color-zinc-100))] dark:placeholder:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\"\n              \/>\n            <\/div>\n          )}\n\n          {canCreate && (\n            <button\n              type=\"button\"\n              onClick={handleCreate}\n              className=\"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-500\/10\"\n            >\n              <svg className=\"h-4 w-4 shrink-0\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                <path fillRule=\"evenodd\" d=\"M10 3a.75.75 0 01.75.75v5.5h5.5a.75.75 0 010 1.5h-5.5v5.5a.75.75 0 01-1.5 0v-5.5h-5.5a.75.75 0 010-1.5h5.5v-5.5A.75.75 0 0110 3z\" clipRule=\"evenodd\" \/>\n              <\/svg>\n              <span className=\"truncate\">{createLabel} &quot;{search.trim()}&quot;<\/span>\n            <\/button>\n          )}\n\n          {filtered.length === 0 && !canCreate ? (\n            <div className=\"px-3 py-2 text-sm text-zinc-400\">No results found<\/div>\n          ) : filtered.length === 0 ? null : (\n            filtered.map((option, i) => {\n              const selected = isSelected(option.value as string);\n              return (\n                <button\n                  key={option.value as string}\n                  type=\"button\"\n                  role=\"option\"\n                  aria-selected={selected}\n                  disabled={option.disabled}\n                  onClick={() => toggleOption(option.value as string)}\n                  className={cn(\n                    \"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors\",\n                    i === activeIndex\n                      ? \"bg-[color:var(--fancy-surface-muted,var(--color-zinc-100))] dark:bg-[color:var(--fancy-surface-muted-dark,var(--color-zinc-800))]\"\n                      : \"hover:bg-[color:var(--fancy-surface-hover,var(--color-zinc-50))] dark:hover:bg-[color:var(--fancy-surface-hover-dark,color-mix(in_oklab,var(--color-zinc-800)_50%,transparent))]\",\n                    selected\n                      ? \"text-[color:var(--fancy-text,var(--color-zinc-900))] dark:text-[color:var(--fancy-text-dark,var(--color-zinc-100))]\"\n                      : \"text-[color:var(--fancy-text-soft,var(--color-zinc-700))] dark:text-[color:var(--fancy-text-soft-dark,var(--color-zinc-300))]\",\n                    option.disabled && \"cursor-not-allowed opacity-50\",\n                  )}\n                >\n                  {\/* Indicator *\/}\n                  {indicator === \"checkbox\" ? (\n                    <span\n                      className={cn(\n                        \"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors\",\n                        selected\n                          ? \"border-blue-500 bg-blue-500 text-white dark:border-blue-400 dark:bg-blue-400\"\n                          : \"border-zinc-300 dark:border-zinc-600\",\n                      )}\n                    >\n                      {selected && (\n                        <svg className=\"h-3 w-3\" viewBox=\"0 0 12 12\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                          <polyline points=\"2.5 6 5 8.5 9.5 3.5\" \/>\n                        <\/svg>\n                      )}\n                    <\/span>\n                  ) : (\n                    <span className=\"flex h-4 w-4 shrink-0 items-center justify-center\">\n                      {selected && (\n                        <svg className=\"h-4 w-4 text-blue-500 dark:text-blue-400\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                          <path fillRule=\"evenodd\" d=\"M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z\" clipRule=\"evenodd\" \/>\n                        <\/svg>\n                      )}\n                    <\/span>\n                  )}\n\n                  {\/* Label + description *\/}\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"block truncate\">{option.label}<\/span>\n                    {option.description && (\n                      <span className=\"block truncate text-xs text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\">{option.description}<\/span>\n                    )}\n                  <\/span>\n                <\/button>\n              );\n            })\n          )}\n        <\/div>\n      <\/Portal>\n    );\n\n    const content = (\n      <div\n        className=\"relative\"\n        onBlur={(e) => {\n          \/\/ The popover is portaled (focus moves out of this wrapper), so only\n          \/\/ exit edit when the dropdown is closed and focus has truly left.\n          if (interactive && !open && !e.currentTarget.contains(e.relatedTarget as Node)) {\n            exitEdit();\n          }\n        }}\n      >\n        {trigger}\n        {dropdown}\n      <\/div>\n    );\n\n    if (label || error || description) {\n      return (\n        <Field\n          label={label}\n          labelHidden={labelHidden}\n          description={description}\n          error={error}\n          required={required}\n          htmlFor={selectId}\n          size={size}\n        >\n          {content}\n        <\/Field>\n      );\n    }\n\n    return content;\n  },\n);\n\nListboxSelect.displayName = \"ListboxSelect\";\n\n\/\/ \u2500\u2500 Public component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport const Select = forwardRef<HTMLSelectElement, SelectProps>(\n  (props, ref) => {\n    const variant = props.variant ?? (props.multiple ? \"listbox\" : \"native\");\n\n    if (variant === \"listbox\") {\n      return <ListboxSelect {...props} ref={ref} \/>;\n    }\n    return <NativeSelect {...props} ref={ref} \/>;\n  },\n);\n\nSelect.displayName = \"Select\";\n","type":"registry:ui","target":"components\/fancy\/select\/Select.tsx"},{"path":"components\/fancy\/select\/Select.types.ts","content":"import type { SelectHTMLAttributes } from \"react\";\nimport type { InputBaseProps, InputOption, InputOptionGroup, InputAffixProps } from \"..\/lib\/components\/inputs\/inputs.types\";\n\nexport interface SelectProps\n  extends InputBaseProps,\n    InputAffixProps,\n    Omit<SelectHTMLAttributes<HTMLSelectElement>, \"size\" | \"prefix\" | \"multiple\"> {\n  \/** Options list \u2014 strings or objects with value\/label *\/\n  list: InputOption[] | InputOptionGroup[];\n  placeholder?: string;\n  \/** Callback for single-select value changes *\/\n  onValueChange?: (value: string) => void;\n\n  \/** Layout variant. \"native\" uses <select>, \"listbox\" uses a custom dropdown. Default: \"native\" *\/\n  variant?: \"native\" | \"listbox\";\n  \/** Enable multi-select (listbox variant only) *\/\n  multiple?: boolean;\n  \/** Controlled multi-select value *\/\n  values?: string[];\n  \/** Default multi-select value (uncontrolled) *\/\n  defaultValues?: string[];\n  \/** Callback for multi-select value changes *\/\n  onValuesChange?: (values: string[]) => void;\n  \/** Enable search\/filter within the dropdown (listbox variant only) *\/\n  searchable?: boolean;\n  \/**\n   * Allow creating new options by typing in the dropdown's text input\n   * (listbox variant only). Automatically enables the text input.\n   *\/\n  creatable?: boolean;\n  \/** Callback invoked when a new option is created from user input *\/\n  onCreate?: (label: string) => void;\n  \/** Label prefix for the \"Create\" affordance. Default: \"Create\" *\/\n  createLabel?: string;\n  \/** Suffix for the selected count display. Default: \"selected\" *\/\n  selectedSuffix?: string;\n  \/** Selection indicator style. Default: \"check\" *\/\n  indicator?: \"check\" | \"checkbox\";\n}\n","type":"registry:ui","target":"components\/fancy\/select\/Select.types.ts"},{"path":"components\/fancy\/select\/index.ts","content":"export { Select } from \".\/Select\";\nexport type { SelectProps } from \".\/Select.types\";\n","type":"registry:ui","target":"components\/fancy\/select\/index.ts"},{"path":"components\/fancy\/lib\/utils\/cn.ts","content":"import { twMerge } from \"tailwind-merge\";\n\n\/**\n * A class value: a string, a number, a conditional map, an array of any of\n * those, or nothing. Mirrors what `clsx` accepted, because every component in\n * this package already passes these shapes.\n *\/\nexport type ClassValue =\n  | string\n  | number\n  | bigint\n  | null\n  | undefined\n  | false\n  | ClassDictionary\n  | ClassValue[];\n\ntype ClassDictionary = Record<string, unknown>;\n\n\/**\n * Flatten class values into a space-separated string.\n *\n * This was `clsx` until 2026-08-19. It is roughly twenty lines of logic, it\n * shipped as a RUNTIME dependency to every consumer of this package, and its\n * last commit was 801 days old \u2014 so it fell to the rule that third-party code\n * must be both approved and actively maintained. Owning it costs less than\n * carrying it.\n *\n * `tailwind-merge` deliberately stays: deciding which of two conflicting\n * Tailwind utilities wins requires modelling the entire utility space, which is\n * emphatically not twenty lines, and it is actively maintained.\n *\n * Falsy values are dropped \u2014 including `0` and `\"\"`, which is what `clsx` did\n * and what `tests\/cn.test.ts` pins. A truthy number is kept, because digits are\n * legal class names. `bigint` is admitted because clsx's signature did and\n * the compiler proved it reachable from two call sites here.\n *\/\nfunction flatten(value: ClassValue): string {\n  if (!value) return \"\";\n\n  if (typeof value === \"string\" || typeof value === \"number\" || typeof value === \"bigint\") {\n    return String(value);\n  }\n\n  if (Array.isArray(value)) {\n    let out = \"\";\n    for (const item of value) {\n      const part = flatten(item);\n      if (part) out = out ? `${out} ${part}` : part;\n    }\n    return out;\n  }\n\n  let out = \"\";\n  for (const key in value) {\n    if (value[key]) out = out ? `${out} ${key}` : key;\n  }\n  return out;\n}\n\nexport function cn(...inputs: ClassValue[]): string {\n  return twMerge(flatten(inputs));\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/cn.ts"},{"path":"components\/fancy\/lib\/components\/inputs\/InputWrapper.tsx","content":"import type { ReactNode } from \"react\";\nimport { cn } from \"..\/..\/utils\/cn\";\nimport type { AffixPosition } from \".\/inputs.types\";\nimport type { Size } from \"..\/..\/utils\/types\";\n\ninterface InputWrapperProps {\n  children: ReactNode;\n  prefix?: ReactNode;\n  suffix?: ReactNode;\n  prefixPosition?: AffixPosition;\n  suffixPosition?: AffixPosition;\n  size?: Size;\n  \/** Extra padding className to apply to the input when affixes are \"inside\" *\/\n  insidePrefixPadding?: string;\n  insideSuffixPadding?: string;\n}\n\n\/\/ NOTE: these must be COMPLETE, static class strings. Tailwind only detects\n\/\/ utilities it can see verbatim in the source \u2014 building the arbitrary variant\n\/\/ at runtime (e.g. `[&_input]:${\"pl-9\"}`) means `[&_input]:pl-9` is never\n\/\/ emitted to CSS, so the inside affix gets no padding and overlaps the text\n\/\/ (issue #6). Keep input\/select\/textarea spelled out here.\nconst insidePaddingLeft: Record<Size, string> = {\n  xs: \"[&_input]:pl-7 [&_select]:pl-7 [&_textarea]:pl-7\",\n  sm: \"[&_input]:pl-8 [&_select]:pl-8 [&_textarea]:pl-8\",\n  md: \"[&_input]:pl-9 [&_select]:pl-9 [&_textarea]:pl-9\",\n  lg: \"[&_input]:pl-10 [&_select]:pl-10 [&_textarea]:pl-10\",\n  xl: \"[&_input]:pl-11 [&_select]:pl-11 [&_textarea]:pl-11\",\n};\n\nconst insidePaddingRight: Record<Size, string> = {\n  xs: \"[&_input]:pr-7 [&_select]:pr-7 [&_textarea]:pr-7\",\n  sm: \"[&_input]:pr-8 [&_select]:pr-8 [&_textarea]:pr-8\",\n  md: \"[&_input]:pr-9 [&_select]:pr-9 [&_textarea]:pr-9\",\n  lg: \"[&_input]:pr-10 [&_select]:pr-10 [&_textarea]:pr-10\",\n  xl: \"[&_input]:pr-11 [&_select]:pr-11 [&_textarea]:pr-11\",\n};\n\nconst affixOutsideClasses =\n  \"inline-flex items-center border border-[color:var(--fancy-border-strong,var(--color-zinc-300))] bg-zinc-50 px-3 text-sm text-[color:var(--fancy-text-muted,var(--color-zinc-500))] dark:border-[color:var(--fancy-border-strong-dark,var(--color-zinc-700))] dark:bg-zinc-800 dark:text-[color:var(--fancy-text-muted-dark,var(--color-zinc-400))]\";\n\nexport function InputWrapper({\n  children,\n  prefix,\n  suffix,\n  prefixPosition = \"inside\",\n  suffixPosition = \"inside\",\n  size = \"md\",\n}: InputWrapperProps) {\n  const hasInsidePrefix = prefix && prefixPosition === \"inside\";\n  const hasInsideSuffix = suffix && suffixPosition === \"inside\";\n  const hasOutsidePrefix = prefix && prefixPosition === \"outside\";\n  const hasOutsideSuffix = suffix && suffixPosition === \"outside\";\n  const hasOutside = hasOutsidePrefix || hasOutsideSuffix;\n\n  if (!prefix && !suffix) {\n    return <>{children}<\/>;\n  }\n\n  if (hasOutside) {\n    return (\n      <div data-react-fancy-input-wrapper=\"\" className=\"flex\">\n        {hasOutsidePrefix && (\n          <span className={cn(affixOutsideClasses, \"rounded-l-lg border-r-0\")}>\n            {prefix}\n          <\/span>\n        )}\n        <div\n          className={cn(\n            \"relative flex flex-1 items-center\",\n            hasInsidePrefix && \"relative\",\n          )}\n        >\n          {hasInsidePrefix && (\n            <span className=\"pointer-events-none absolute left-3 z-10 text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\">\n              {prefix}\n            <\/span>\n          )}\n          <div\n            className={cn(\n              \"w-full [&>*]:w-full\",\n              hasOutsidePrefix && !hasOutsideSuffix && \"[&_input]:rounded-l-none [&_select]:rounded-l-none [&_textarea]:rounded-l-none\",\n              !hasOutsidePrefix && hasOutsideSuffix && \"[&_input]:rounded-r-none [&_select]:rounded-r-none [&_textarea]:rounded-r-none\",\n              hasOutsidePrefix && hasOutsideSuffix && \"[&_input]:rounded-none [&_select]:rounded-none [&_textarea]:rounded-none\",\n              hasInsidePrefix && insidePaddingLeft[size],\n            )}\n          >\n            {children}\n          <\/div>\n          {hasInsideSuffix && (\n            <span className=\"pointer-events-none absolute right-3 z-10 text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\">\n              {suffix}\n            <\/span>\n          )}\n        <\/div>\n        {hasOutsideSuffix && (\n          <span className={cn(affixOutsideClasses, \"rounded-r-lg border-l-0\")}>\n            {suffix}\n          <\/span>\n        )}\n      <\/div>\n    );\n  }\n\n  \/\/ Both inside\n  return (\n    <div data-react-fancy-input-wrapper=\"\" className=\"relative flex items-center\">\n      {hasInsidePrefix && (\n        <span className=\"pointer-events-none absolute left-3 z-10 text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\">\n          {prefix}\n        <\/span>\n      )}\n      <div\n        className={cn(\n          \"w-full\",\n          hasInsidePrefix && insidePaddingLeft[size],\n          hasInsideSuffix && insidePaddingRight[size],\n        )}\n      >\n        {children}\n      <\/div>\n      {hasInsideSuffix && (\n        <span className=\"pointer-events-none absolute right-3 z-10 text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\">\n          {suffix}\n        <\/span>\n      )}\n    <\/div>\n  );\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/components\/inputs\/InputWrapper.tsx"},{"path":"components\/fancy\/lib\/hooks\/use-floating-position.ts","content":"import { useCallback, useEffect, useState, type RefObject } from \"react\";\nimport type { Placement } from \"..\/utils\/types\";\n\ninterface FloatingPosition {\n  x: number;\n  y: number;\n  placement: Placement;\n}\n\ninterface UseFloatingPositionOptions {\n  placement?: Placement;\n  offset?: number;\n  enabled?: boolean;\n}\n\nfunction getPosition(\n  anchor: DOMRect,\n  floating: DOMRect,\n  placement: Placement,\n  offset: number,\n): FloatingPosition {\n  let x = 0;\n  let y = 0;\n  const base = placement.split(\"-\")[0] as \"top\" | \"bottom\" | \"left\" | \"right\";\n  const align = placement.split(\"-\")[1] as \"start\" | \"end\" | undefined;\n\n  switch (base) {\n    case \"top\":\n      x = anchor.left + anchor.width \/ 2 - floating.width \/ 2;\n      y = anchor.top - floating.height - offset;\n      break;\n    case \"bottom\":\n      x = anchor.left + anchor.width \/ 2 - floating.width \/ 2;\n      y = anchor.bottom + offset;\n      break;\n    case \"left\":\n      x = anchor.left - floating.width - offset;\n      y = anchor.top + anchor.height \/ 2 - floating.height \/ 2;\n      break;\n    case \"right\":\n      x = anchor.right + offset;\n      y = anchor.top + anchor.height \/ 2 - floating.height \/ 2;\n      break;\n  }\n\n  if (base === \"top\" || base === \"bottom\") {\n    if (align === \"start\") x = anchor.left;\n    else if (align === \"end\") x = anchor.right - floating.width;\n  }\n\n  if (base === \"left\" || base === \"right\") {\n    if (align === \"start\") y = anchor.top;\n    else if (align === \"end\") y = anchor.bottom - floating.height;\n  }\n\n  \/\/ Viewport clamping\n  let finalPlacement = placement;\n  const vw = window.innerWidth;\n  const vh = window.innerHeight;\n\n  if (base === \"bottom\" && y + floating.height > vh) {\n    y = anchor.top - floating.height - offset;\n    finalPlacement = placement.replace(\"bottom\", \"top\") as Placement;\n  } else if (base === \"top\" && y < 0) {\n    y = anchor.bottom + offset;\n    finalPlacement = placement.replace(\"top\", \"bottom\") as Placement;\n  }\n\n  x = Math.max(4, Math.min(x, vw - floating.width - 4));\n  y = Math.max(4, Math.min(y, vh - floating.height - 4));\n\n  return { x, y, placement: finalPlacement };\n}\n\nexport function useFloatingPosition(\n  anchorRef: RefObject<HTMLElement | null>,\n  floatingRef: RefObject<HTMLElement | null>,\n  options: UseFloatingPositionOptions = {},\n): FloatingPosition {\n  const { placement = \"bottom\", offset = 8, enabled = true } = options;\n  const [position, setPosition] = useState<FloatingPosition>({\n    x: -9999,\n    y: -9999,\n    placement,\n  });\n\n  const update = useCallback(() => {\n    const anchor = anchorRef.current;\n    const floating = floatingRef.current;\n    if (!anchor || !floating) return;\n\n    const anchorRect = anchor.getBoundingClientRect();\n    const floatingRect = floating.getBoundingClientRect();\n    const next = getPosition(anchorRect, floatingRect, placement, offset);\n    \/\/ Bail out when nothing changed so this can run after every commit\n    \/\/ (below) without causing an infinite render loop.\n    setPosition((prev) =>\n      prev.x === next.x && prev.y === next.y && prev.placement === next.placement\n        ? prev\n        : next,\n    );\n  }, [anchorRef, floatingRef, placement, offset]);\n\n  \/\/ Re-measure after every commit while open.\n  \/\/\n  \/\/ The floating element mounts a frame or two AFTER `enabled` flips \u2014 its\n  \/\/ render is gated behind a state flip (e.g. useAnimation's `mounted`), so at\n  \/\/ the moment `enabled` turns true the floating ref is still null and a single\n  \/\/ measurement no-ops, leaving the popover parked at its off-screen sentinel.\n  \/\/\n  \/\/ A passive effect with no deps runs after *every* commit \u2014 including the one\n  \/\/ that finally mounts the floating element \u2014 and, crucially, keeps running in\n  \/\/ hidden\/background tabs where `requestAnimationFrame` is paused. The bail-out\n  \/\/ in `update` makes the steady-state calls free, so this both fixes the\n  \/\/ late-mount race and survives tab-visibility throttling.\n  useEffect(() => {\n    if (enabled) update();\n  });\n\n  \/\/ \u2026but the per-commit effect ISN'T enough when the floating element mounts\n  \/\/ through a <Portal> whose own mount gate (SSR-safe; renders nothing until its\n  \/\/ useEffect runs) attaches the node WITHOUT re-rendering this hook's component.\n  \/\/ Then no commit happens here, the measurement never runs, and the popover\n  \/\/ stays parked at its off-screen sentinel until an unrelated scroll\/resize\n  \/\/ fires `update()` \u2014 the \"menu only appears after I scroll\" bug. Poll with rAF\n  \/\/ until the node is actually in the DOM, measure it, then keep it observed for\n  \/\/ size changes (async content \/ lazily-loaded icons).\n  useEffect(() => {\n    if (!enabled) return;\n    let raf = 0;\n    let ro: ResizeObserver | null = null;\n    const tick = () => {\n      const el = floatingRef.current;\n      if (el) {\n        update();\n        if (typeof ResizeObserver !== \"undefined\") {\n          ro = new ResizeObserver(() => update());\n          ro.observe(el);\n        }\n      } else {\n        raf = requestAnimationFrame(tick);\n      }\n    };\n    raf = requestAnimationFrame(tick);\n    return () => {\n      cancelAnimationFrame(raf);\n      ro?.disconnect();\n    };\n  }, [enabled, update, floatingRef]);\n\n  \/\/ Park off-screen when closed so the next open re-measures from scratch.\n  useEffect(() => {\n    if (!enabled) {\n      setPosition((prev) =>\n        prev.x === -9999 ? prev : { x: -9999, y: -9999, placement },\n      );\n    }\n  }, [enabled, placement]);\n\n  \/\/ Reposition on scroll\/resize while open.\n  useEffect(() => {\n    if (!enabled) return;\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n\n    return () => {\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [update, enabled]);\n\n  return position;\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-floating-position.ts"},{"path":"components\/fancy\/lib\/hooks\/use-outside-click.ts","content":"import { useEffect, type RefObject } from \"react\";\n\nexport function useOutsideClick(\n  ref: RefObject<HTMLElement | null>,\n  handler: (event: MouseEvent | TouchEvent) => void,\n  enabled: boolean = true,\n  ignoreRef?: RefObject<HTMLElement | null>,\n): void {\n  useEffect(() => {\n    if (!enabled) return;\n\n    const listener = (event: MouseEvent | TouchEvent) => {\n      const el = ref.current;\n      if (!el || el.contains(event.target as Node)) return;\n      \/\/ Ignore clicks on the trigger\/anchor element \u2014 the trigger's\n      \/\/ own onClick handler manages toggling.\n      if (ignoreRef?.current?.contains(event.target as Node)) return;\n      handler(event);\n    };\n\n    document.addEventListener(\"mousedown\", listener);\n    document.addEventListener(\"touchstart\", listener);\n\n    return () => {\n      document.removeEventListener(\"mousedown\", listener);\n      document.removeEventListener(\"touchstart\", listener);\n    };\n  }, [ref, handler, enabled, ignoreRef]);\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-outside-click.ts"},{"path":"components\/fancy\/lib\/hooks\/use-escape-key.ts","content":"import { createContext, useContext, useEffect, useRef } from \"react\";\n\n\/**\n * Escape belongs to the innermost open layer.\n *\n * Every `useEscapeKey(handler, enabled)` call is a LAYER while `enabled`: a\n * Dropdown's menu, a Select's listbox, a Popover, a Modal, a Drawer, a Tooltip,\n * a full-screen surface. They all join one stack, one document listener reads\n * each Escape, and only one layer gets it.\n *\n * Why not one document listener per layer, as this hook used to do: every\n * open layer then received the same Escape. A Dropdown inside a Modal closed\n * the menu AND the dialog. Inside a full-screen surface it was worse: the\n * surface's own key handler runs during the event's trip through the surface,\n * before any document listener, so it could not tell that a menu wanted the\n * key. DeckEditor exited full screen with its Shape menu still open. Fixing\n * that by ordering listeners would only last until someone registers one in a\n * different place. So the decision is made from the stack instead.\n *\n * \"Innermost\" is decided in two steps:\n *\n * 1. The most recently opened layer is the candidate. Opening a menu from a\n *    dialog is the normal order of things.\n * 2. A full-screen surface knows its own descendants: layers rendered inside\n *    it register under it through `EscapeLayerContext`, and that context flows\n *    through portals. If the candidate is a surface with open layers inside,\n *    the most recent of those wins instead. Without this step, a menu opened\n *    BEFORE an agent put its surface full screen would lose the Escape to the\n *    surface.\n *\n * An Escape that something already handled (`event.defaultPrevented`, for\n * example a text field clearing itself) is left alone.\n *\/\n\ninterface LayerEntry {\n  handler: { current: () => void };\n  \/** The surface this layer was rendered inside, if any. *\/\n  parent: EscapeScope | null;\n  \/** Set when the layer IS a surface: layers inside it point here. *\/\n  scope: EscapeScope | null;\n}\n\n\/** Identity of a full-screen surface for the layers rendered inside it. *\/\nexport type EscapeScope = object;\n\nconst stack: LayerEntry[] = [];\n\n\/** The surface (if any) that layers rendered here belong to. *\/\nexport const EscapeLayerContext = createContext<EscapeScope | null>(null);\n\nfunction innermost(): LayerEntry | undefined {\n  let candidate = stack[stack.length - 1];\n  while (candidate?.scope) {\n    const scope = candidate.scope;\n    let inner: LayerEntry | undefined;\n    for (let i = stack.length - 1; i >= 0; i--) {\n      if (stack[i].parent === scope) {\n        inner = stack[i];\n        break;\n      }\n    }\n    if (!inner) break;\n    candidate = inner;\n  }\n  return candidate;\n}\n\nfunction onDocumentKeyDown(event: KeyboardEvent) {\n  if (event.key !== \"Escape\" || event.defaultPrevented) return;\n  const target = innermost();\n  if (!target) return;\n  event.preventDefault();\n  target.handler.current();\n}\n\nfunction push(entry: LayerEntry): () => void {\n  if (stack.length === 0) document.addEventListener(\"keydown\", onDocumentKeyDown);\n  stack.push(entry);\n  return () => {\n    const i = stack.indexOf(entry);\n    if (i >= 0) stack.splice(i, 1);\n    if (stack.length === 0) document.removeEventListener(\"keydown\", onDocumentKeyDown);\n  };\n}\n\n\/**\n * Register a layer that closes on Escape while `enabled`. Pass `scope` only for\n * a surface that other layers can open inside (a full-screen surface).\n *\n * Returns `isInnermost()`, for a layer that also handles Escape in its own key\n * handler and must not act when a layer above it owns the key.\n *\n * @internal Exported for full-screen surfaces in sibling packages; ordinary\n * layers call `useEscapeKey`.\n *\/\nexport function useEscapeLayer(\n  handler: () => void,\n  enabled: boolean,\n  scope: EscapeScope | null = null,\n): { isInnermost: () => boolean } {\n  const parent = useContext(EscapeLayerContext);\n  const handlerRef = useRef(handler);\n  handlerRef.current = handler;\n  const entryRef = useRef<LayerEntry | null>(null);\n\n  \/\/ Depends on `enabled` (and identity), NOT on `handler`: callers pass inline\n  \/\/ callbacks, and re-registering on every render would move a dialog above\n  \/\/ the menu it had just opened.\n  useEffect(() => {\n    if (!enabled) return;\n    const entry: LayerEntry = { handler: handlerRef, parent, scope };\n    entryRef.current = entry;\n    const pop = push(entry);\n    return () => {\n      pop();\n      if (entryRef.current === entry) entryRef.current = null;\n    };\n  }, [enabled, parent, scope]);\n\n  const isInnermostRef = useRef(() => entryRef.current !== null && innermost() === entryRef.current);\n  return { isInnermost: isInnermostRef.current };\n}\n\nexport function useEscapeKey(handler: () => void, enabled: boolean = true): void {\n  useEscapeLayer(handler, enabled);\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/hooks\/use-escape-key.ts"},{"path":"components\/fancy\/lib\/components\/inputs\/inputs.utils.ts","content":"import type { Size } from \"..\/..\/utils\/types\";\nimport type { InputOption } from \".\/inputs.types\";\n\nexport const inputSizeClasses: Record<Size, string> = {\n  xs: \"px-2 py-1 text-xs rounded\",\n  sm: \"px-2.5 py-1.5 text-sm rounded-md\",\n  md: \"px-3 py-2 text-sm rounded-lg\",\n  lg: \"px-4 py-2.5 text-base rounded-lg\",\n  xl: \"px-5 py-3 text-lg rounded-xl\",\n};\n\nexport const labelSizeClasses: Record<Size, string> = {\n  xs: \"text-xs\",\n  sm: \"text-xs\",\n  md: \"text-sm\",\n  lg: \"text-sm\",\n  xl: \"text-base\",\n};\n\nexport function dirtyClasses(dirty?: boolean): string {\n  return dirty ? \"border-l-[3px] border-l-amber-400\" : \"\";\n}\n\nexport function dirtyRingClasses(dirty?: boolean): string {\n  return dirty ? \"ring-2 ring-amber-400\/50\" : \"\";\n}\n\nexport function errorClasses(error?: string): string {\n  return error ? \"border-red-500 focus:ring-red-500\" : \"\";\n}\n\nexport const inputBaseClasses =\n  \"border border-[color:var(--fancy-border-strong,var(--color-zinc-300))] bg-[color:var(--fancy-input,var(--color-white))] text-[color:var(--fancy-text,var(--color-zinc-900))] placeholder:text-[color:var(--fancy-text-faint,var(--color-zinc-400))] transition-[border-color,box-shadow] duration-150 focus:outline-none focus:ring-2 focus:ring-[color:var(--fancy-focus,var(--color-blue-500))]\/40 focus:border-[color:var(--fancy-focus,var(--color-blue-500))] disabled:opacity-50 disabled:cursor-not-allowed dark:border-[color:var(--fancy-border-strong-dark,var(--color-zinc-700))] dark:bg-[color:var(--fancy-input-dark,#1e1e24)] dark:text-[color:var(--fancy-text-dark,var(--color-zinc-100))] dark:placeholder:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))] dark:focus:border-[color:var(--fancy-focus-dark,var(--color-blue-400))] dark:focus:ring-[color:var(--fancy-focus-dark,var(--color-blue-400))]\/20\";\n\nexport function resolveOption<V = string>(\n  option: InputOption<V>,\n): { value: V; label: string; disabled?: boolean; description?: string } {\n  if (typeof option === \"string\") {\n    return { value: option as unknown as V, label: option };\n  }\n  return option;\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/components\/inputs\/inputs.utils.ts"},{"path":"components\/fancy\/lib\/components\/inputs\/mode\/FieldMode.context.ts","content":"import { createContext, useContext } from \"react\";\nimport type { FieldMode } from \"..\/inputs.types\";\n\nexport interface FieldModeContextValue {\n  mode: FieldMode;\n}\n\nexport const FieldModeContext = createContext<FieldModeContextValue | null>(null);\n\n\/**\n * Resolve the effective mode for an input. Precedence: an explicit prop wins,\n * then the nearest `<Form>` \/ `<FormProvider>` context, then `\"edit\"`.\n *\n * Deliberately tolerant (returns `\"edit\"` outside any provider) \u2014 unlike a slot\n * guard \u2014 so every input works standalone with zero context and existing call\n * sites behave identically.\n *\/\nexport function useFieldMode(explicit?: FieldMode): FieldMode {\n  const ctx = useContext(FieldModeContext);\n  return explicit ?? ctx?.mode ?? \"edit\";\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/components\/inputs\/mode\/FieldMode.context.ts"},{"path":"components\/fancy\/lib\/components\/inputs\/mode\/DisplayValue.tsx","content":"import type { KeyboardEvent, ReactNode } from \"react\";\nimport { cn } from \"..\/..\/..\/utils\/cn\";\nimport type { Size } from \"..\/..\/..\/utils\/types\";\nimport { inputSizeClasses } from \"..\/inputs.utils\";\n\nexport interface DisplayValueProps {\n  \/** The formatted value to show. Empty \/ null \/ undefined renders `empty`. *\/\n  children?: ReactNode;\n  size?: Size;\n  \/** Static adornment rendered before the value (mirrors an input's `leading`\/`prefix`). *\/\n  leading?: ReactNode;\n  \/** Static adornment rendered after the value (mirrors an input's `trailing`\/`suffix`). *\/\n  trailing?: ReactNode;\n  \/** Shown when the value is empty. Default `\u2014`. *\/\n  empty?: ReactNode;\n  \/** When true the value is clickable to enter edit mode (inline click-to-edit). *\/\n  interactive?: boolean;\n  \/** Called when an interactive value is activated (click \/ Enter \/ Space). *\/\n  onActivate?: () => void;\n  className?: string;\n}\n\nfunction isEmpty(value: ReactNode): boolean {\n  return value === null || value === undefined || value === \"\";\n}\n\n\/**\n * The read-only counterpart to an `<input>`. Renders a non-interactive value\n * with the same size rhythm as the editable control (reusing\n * {@link inputSizeClasses}) but without the border \/ background \/ focus ring.\n *\n * When `interactive` (inline click-to-edit), the value becomes a focusable,\n * hoverable target that calls `onActivate` on click \/ Enter \/ Space to swap in\n * the real control. Dropped into the SAME `Field` wrapper the editable control\n * uses, so label \/ description \/ error chrome stays byte-identical between the\n * display and the editor.\n *\n * Carries `data-react-fancy-display` \/ `data-mode=\"view\"` stable handles for\n * agents and tests (Human+ contract).\n *\/\nexport function DisplayValue({\n  children,\n  size = \"md\",\n  leading,\n  trailing,\n  empty = \"\u2014\",\n  interactive,\n  onActivate,\n  className,\n}: DisplayValueProps) {\n  const empties = isEmpty(children);\n\n  const onKeyDown = interactive\n    ? (e: KeyboardEvent<HTMLDivElement>) => {\n        if (e.key === \"Enter\" || e.key === \" \") {\n          e.preventDefault();\n          onActivate?.();\n        }\n      }\n    : undefined;\n\n  return (\n    <div\n      data-react-fancy-display=\"\"\n      data-mode=\"view\"\n      role={interactive ? \"button\" : undefined}\n      tabIndex={interactive ? 0 : undefined}\n      title={interactive ? \"Click to edit\" : undefined}\n      onClick={interactive ? onActivate : undefined}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"flex w-full items-center gap-2 text-[color:var(--fancy-text,var(--color-zinc-900))] dark:text-[color:var(--fancy-text-dark,var(--color-zinc-100))]\",\n        inputSizeClasses[size],\n        \/\/ Strip the editable box look \u2014 keep only the size\/padding rhythm.\n        \"border-0 bg-transparent px-0\",\n        interactive &&\n          \"-mx-2 cursor-text rounded-md px-2 outline-none transition-colors hover:bg-[color:var(--fancy-surface-muted,var(--color-zinc-100))] focus-visible:ring-2 focus-visible:ring-blue-500\/40 dark:hover:bg-[color:var(--fancy-surface-muted-dark,var(--color-zinc-800))]\",\n        empties && \"text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\",\n        className,\n      )}\n    >\n      {leading && <span className=\"text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\">{leading}<\/span>}\n      <span className=\"min-w-0 truncate\">{empties ? empty : children}<\/span>\n      {trailing && <span className=\"text-[color:var(--fancy-text-faint,var(--color-zinc-400))] dark:text-[color:var(--fancy-text-faint-dark,var(--color-zinc-500))]\">{trailing}<\/span>}\n    <\/div>\n  );\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/components\/inputs\/mode\/DisplayValue.tsx"},{"path":"components\/fancy\/lib\/components\/inputs\/mode\/useInlineEdit.ts","content":"import { useCallback, useState } from \"react\";\nimport type { FieldMode } from \"..\/inputs.types\";\n\nexport interface InlineEdit {\n  \/** Render the editable control (`true`) vs. the read-only value display (`false`). *\/\n  showControl: boolean;\n  \/** Whether view mode is interactive (click-to-edit) \u2014 i.e. `view` and not disabled. *\/\n  interactive: boolean;\n  \/** Enter edit mode (no-op unless interactive). *\/\n  enterEdit: () => void;\n  \/** Leave edit mode, back to the value display. *\/\n  exitEdit: () => void;\n}\n\n\/**\n * Inline click-to-edit for `mode=\"view\"`. The bound value shows as clean static\n * text; clicking it swaps in the focused control; blur returns to the display\n * (the control's `onChange` \/ `onBlur` fire as usual, so a host can persist on\n * commit). `mode=\"edit\"` is always the control; a **disabled** view stays static\n * text (no click-to-edit).\n *\n * This is the value of \"view mode\": tidy read-only surfaces where each field\n * becomes editable only when the user reaches for it.\n *\/\nexport function useInlineEdit(mode: FieldMode, disabled?: boolean): InlineEdit {\n  const [editing, setEditing] = useState(false);\n  const interactive = mode === \"view\" && !disabled;\n  const showControl = mode !== \"view\" || (interactive && editing);\n  const enterEdit = useCallback(() => {\n    if (interactive) setEditing(true);\n  }, [interactive]);\n  const exitEdit = useCallback(() => setEditing(false), []);\n  return { showControl, interactive, enterEdit, exitEdit };\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/components\/inputs\/mode\/useInlineEdit.ts"},{"path":"components\/fancy\/lib\/components\/inputs\/inputs.types.ts","content":"import type { ReactNode } from \"react\";\nimport type { Size } from \"..\/..\/utils\/types\";\n\nexport type InputOption<V = string> =\n  | string\n  | { value: V; label: string; disabled?: boolean; description?: string };\n\nexport type InputOptionGroup<V = string> = {\n  label: string;\n  options: InputOption<V>[];\n};\n\n\/**\n * Edit vs. read-only display. In `\"view\"` mode an input renders the bound value\n * as static text instead of an interactive control \u2014 the React\/Inertia analog of\n * Livewire's public\/bindable properties. Declared here (rather than in `mode\/`)\n * to keep `InputBaseProps` free of an import cycle. Resolved per input via\n * {@link useFieldMode}: explicit prop \u2192 `<Form>`\/`<FormProvider>` context \u2192 `\"edit\"`.\n *\/\nexport type FieldMode = \"edit\" | \"view\";\n\nexport interface InputBaseProps {\n  size?: Size;\n  dirty?: boolean;\n  error?: string;\n  label?: string;\n  \/**\n   * Keep the label for assistive tech, but do not draw it.\n   *\n   * For a control whose options already say what it does \u2014 a filter toolbar's\n   * segmented switches, say \u2014 where a visible label is noise but an unnamed\n   * control is a bug. Without this the only choices were an unnamed control or\n   * a label the design does not have, and the silent one is the one that ships.\n   *\/\n  labelHidden?: boolean;\n  description?: string;\n  required?: boolean;\n  disabled?: boolean;\n  className?: string;\n  id?: string;\n  name?: string;\n  \/** `\"edit\"` (default) renders the control; `\"view\"` renders the value as text. Overrides the `<Form mode>` context. *\/\n  mode?: FieldMode;\n}\n\nexport type AffixPosition = \"inside\" | \"outside\";\n\nexport interface InputAffixProps {\n  prefix?: ReactNode;\n  suffix?: ReactNode;\n  prefixPosition?: AffixPosition;\n  suffixPosition?: AffixPosition;\n}\n","type":"registry:lib","target":"components\/fancy\/lib\/components\/inputs\/inputs.types.ts"},{"path":"components\/fancy\/lib\/utils\/types.ts","content":"import type { ElementType } from \"react\";\n\nexport type Size = \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\";\n\n\/**\n * What a navigation primitive owes a router.\n *\n * Every nav surface here \u2014 `Navbar.Item`, `Sidebar.Item`, `Menu.Item`,\n * `MobileMenu.Item`, `Breadcrumbs.Item` \u2014 used to hardcode a plain `<a href>`.\n * That is a FULL PAGE LOAD in every client-routed app, and the only way around\n * it was to nest a router `<Link>` inside the item: an anchor inside an anchor,\n * which is invalid HTML and the same nested-anchor shape that already cost this\n * suite an SSR hydration bug.\n *\n * `as` is the seam {@link ButtonProps.as} already had. It is router-agnostic on\n * purpose \u2014 one prop serves Inertia, TanStack Router, React Router and Next\n * rather than a package per router:\n *\n * ```tsx\n * \/\/ href-based routers (Inertia, Next)\n * <Navbar.Item as={Link} href=\"\/dashboard\">Dashboard<\/Navbar.Item>\n *\n * \/\/ to-based routers (TanStack Router, React Router) \u2014 `to` reaches the Link\n * \/\/ through the forwarded rest props\n * <Navbar.Item as={Link} to=\"\/dashboard\">Dashboard<\/Navbar.Item>\n * ```\n *\n * Link mode engages on `href` OR `as`, because a `to`-based router never passes\n * an `href` and would otherwise silently render the non-interactive fallback.\n *\/\nexport interface NavigableProps {\n  \/** Target URL. Renders link mode with a plain `<a>` unless `as` is given. *\/\n  href?: string;\n  \/**\n   * Link-mode element override \u2014 the component to render INSTEAD of the plain\n   * `<a>`, e.g. a router's `<Link>` so navigation happens client-side. Receives\n   * `className`, the item's `data-*` handles, and every unrecognized prop, so\n   * router-specific props (`to`, `params`, `preload`, `search`) pass straight\n   * through. Ignored in button mode.\n   *\/\n  as?: ElementType;\n}\n\n\/**\n * The full Tailwind v4 named color palette \u2014 every default hue, including all\n * five gray families \u2014 plus `brand`, the host's own colour. Components that\n * accept a `color` should type it as this so the whole palette is available.\n *\n * `brand` is not a hue. It resolves through three CSS variables the host sets\n * once, rather than through a Tailwind scale:\n *\n *   `--color-brand`, `--color-brand-contrast`, `--color-brand-hover`\n *\n * Three rather than one because a generated hover assumes \"same hue, darker\",\n * and a brand palette need not work that way \u2014 navy whose hover is gold cannot\n * be derived from navy by any amount of arithmetic. The host states it.\n *\/\nexport type Color =\n  \/\/ the host's own colour, resolved from CSS variables (see styles.css)\n  | \"brand\"\n  \/\/ grays\n  | \"slate\"\n  | \"gray\"\n  | \"zinc\"\n  | \"neutral\"\n  | \"stone\"\n  \/\/ hues\n  | \"red\"\n  | \"orange\"\n  | \"amber\"\n  | \"yellow\"\n  | \"lime\"\n  | \"green\"\n  | \"emerald\"\n  | \"teal\"\n  | \"cyan\"\n  | \"sky\"\n  | \"blue\"\n  | \"indigo\"\n  | \"violet\"\n  | \"purple\"\n  | \"fuchsia\"\n  | \"pink\"\n  | \"rose\";\n\n\/** Every value of {@link Color}, for building exhaustive class maps. *\/\nexport const COLORS = [\n  \"brand\",\n  \"slate\", \"gray\", \"zinc\", \"neutral\", \"stone\",\n  \"red\", \"orange\", \"amber\", \"yellow\", \"lime\", \"green\", \"emerald\", \"teal\",\n  \"cyan\", \"sky\", \"blue\", \"indigo\", \"violet\", \"purple\", \"fuchsia\", \"pink\", \"rose\",\n] as const satisfies readonly Color[];\n\nexport type Variant = \"solid\" | \"outline\" | \"ghost\" | \"soft\";\n\n\/** Button accepts the full Tailwind v4 palette (alias of {@link Color}). *\/\nexport type ButtonColor = Color;\n\n\/**\n * @deprecated Renamed to {@link ButtonColor}. `ActionColor` remains as an alias\n * for backward compatibility and will be removed in a future major version.\n *\/\nexport type ActionColor = ButtonColor;\n\nexport type Placement =\n  | \"top\"\n  | \"bottom\"\n  | \"left\"\n  | \"right\"\n  | \"top-start\"\n  | \"top-end\"\n  | \"bottom-start\"\n  | \"bottom-end\";\n","type":"registry:lib","target":"components\/fancy\/lib\/utils\/types.ts"}]}