{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "morph-select",
  "title": "Morph Select",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Select em que o próprio gatilho cresce e vira a lista, sem uma segunda caixa aparecendo embaixo.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/morph-select.tsx",
      "content": "\"use client\";\n\n/*\n * Adaptado de Morph Select — https://uiarc.dev/components/morph-select — Copyright (c) 2026 Elia Kuratli, licença MIT (texto integral em ATTRIBUTIONS.md do @visant/ui).\n */\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, type Variants } from \"motion/react\";\nimport { Check, ChevronDown } from \"lucide-react\";\n\nimport { DUR, INSTANT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Morph Select, o select cujo gatilho VIRA a lista.\n *\n * Um select comum abre um segundo objeto: o gatilho fica onde estava e uma\n * caixa nova aparece embaixo, com outra borda, outra sombra, outro raio. O olho\n * tem que ligar as duas coisas. Aqui é a mesma superfície que cresce: o gatilho\n * se alarga, desce, e a lista aparece DENTRO dele. Fechou, encolhe de volta ao\n * tamanho do rótulo. Não há o que ligar, porque nunca foram dois.\n *\n * Decisões que não se veem de primeira:\n *\n * 1. **O espaço do gatilho é reservado por um molde invisível.** A superfície\n *    é absoluta (precisa crescer por cima do conteúdo vizinho), então quem\n *    segura o lugar no fluxo é uma cópia invisível do gatilho fechado. O\n *    layout em volta nunca pula, aberto ou fechado.\n * 2. **Largura e altura animam de verdade, não por escala.** Escala achataria o\n *    texto e o raio no meio do caminho. São poucos px numa caixa pequena, o\n *    custo é nada; o raio passa de controle pra superfície por transição de CSS\n *    sobre o token, sem número cravado.\n * 3. **O foco nunca sai do gatilho.** A lista é apontada por\n *    `aria-activedescendant`: setas andam pelas opções (pulando as\n *    desabilitadas), Home/End vão às pontas, digitar uma letra pula pro rótulo\n *    que começa com ela, Esc fecha, Tab fecha sem escolher.\n * 4. **O realce desliza entre opções** (um elemento só, por `layoutId`), e o\n *    valor no gatilho rola pra cima ou pra baixo conforme a opção nova está\n *    acima ou abaixo da antiga na lista. A direção do giro é informação.\n *\n * Sem busca e sem grupos de propósito: pra lista longa ou texto livre, use um\n * combobox. Este é pra escolher um entre poucos, num formulário ou numa\n * barra de propriedades.\n */\n\nexport interface MorphSelectItem {\n  value: string;\n  label: string;\n  /** Ícone à esquerda do rótulo (um `<Icone />` do lucide). */\n  icon?: React.ReactNode;\n  /** Linha curta à direita, como um atalho ou um preço. */\n  meta?: string;\n  disabled?: boolean;\n}\n\nexport interface MorphSelectProps {\n  /** Rótulo visível; também dá nome ao gatilho e à lista. */\n  label: string;\n  /** Guarda o rótulo só pra leitor de tela. */\n  hideLabel?: boolean;\n  items: MorphSelectItem[];\n  /** Valor controlado. Sem ele, o select guarda o próprio estado. */\n  value?: string | null;\n  defaultValue?: string | null;\n  onValueChange?: (value: string, item: MorphSelectItem) => void;\n  /** Texto do gatilho quando nada foi escolhido. */\n  placeholder?: string;\n  /** Largura da superfície aberta, em px. Nunca menor que o gatilho. */\n  panelWidth?: number;\n  /** Altura da lista antes de rolar, em px. */\n  maxListHeight?: number;\n  /** A borda que fica parada enquanto a superfície cresce. Use `end` perto da margem direita. */\n  align?: \"start\" | \"end\";\n  disabled?: boolean;\n  className?: string;\n}\n\nconst MOLA = { type: \"spring\" as const, visualDuration: 0.28, bounce: 0.12 };\nconst ALTURA_GATILHO = 40;\n\nconst rolo: Variants = {\n  entra: (d: number) => ({ opacity: 0, y: d * 12 }),\n  fica: { opacity: 1, y: 0 },\n  sai: (d: number) => ({ opacity: 0, y: d * -12 }),\n};\nconst fade: Variants = { entra: { opacity: 0 }, fica: { opacity: 1 }, sai: { opacity: 0 } };\n\nexport function MorphSelect({\n  label,\n  hideLabel = false,\n  items,\n  value,\n  defaultValue = null,\n  onValueChange,\n  placeholder = \"Escolha\",\n  panelWidth = 260,\n  maxListHeight = 280,\n  align = \"start\",\n  disabled,\n  className,\n}: MorphSelectProps) {\n  const reduzido = !!useReducedMotion();\n  const id = React.useId();\n  const labelId = `${id}-rotulo`;\n  const listaId = `${id}-lista`;\n\n  const [interno, setInterno] = React.useState<string | null>(defaultValue);\n  const atual = value === undefined ? interno : value;\n  const escolhido = items.findIndex((i) => i.value === atual);\n  const item = escolhido >= 0 ? items[escolhido] : undefined;\n\n  const [aberto, setAberto] = React.useState(false);\n  const [ativo, setAtivo] = React.useState(-1);\n  const raizRef = React.useRef<HTMLDivElement>(null);\n  const gatilhoRef = React.useRef<HTMLButtonElement>(null);\n  const moldeRef = React.useRef<HTMLSpanElement>(null);\n  const listaRef = React.useRef<HTMLUListElement>(null);\n  const [fechado, setFechado] = React.useState({ w: 0 });\n  const [alturaLista, setAlturaLista] = React.useState(0);\n\n  // Direção do giro do valor: +1 quando a opção nova está abaixo da anterior.\n  const anterior = React.useRef(escolhido);\n  const direcao = escolhido >= anterior.current ? 1 : -1;\n  React.useEffect(() => {\n    anterior.current = escolhido;\n  }, [escolhido]);\n\n  React.useLayoutEffect(() => {\n    const molde = moldeRef.current;\n    const lista = listaRef.current;\n    if (!molde || !lista) return;\n    const medir = () => {\n      // `offsetWidth` arredonda pra baixo, e o texto tem largura fracionária: meio pixel a menos\n      // e o rótulo virava reticências (\"Em andam…\"). A largura USADA do estilo computado é\n      // fracionária e não sofre com `transform: scale` de um ancestral (o retângulo sofre).\n      const w = Math.ceil(parseFloat(getComputedStyle(molde).width) || molde.offsetWidth);\n      setFechado((f) => (f.w === w ? f : { w }));\n      setAlturaLista(Math.min(lista.scrollHeight, maxListHeight));\n    };\n    medir();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const obs = new ResizeObserver(medir);\n    obs.observe(molde);\n    obs.observe(lista);\n    return () => obs.disconnect();\n  }, [maxListHeight, items.length]);\n\n  // Clique fora fecha. Só escuta enquanto está aberto.\n  React.useEffect(() => {\n    if (!aberto) return;\n    const fora = (e: PointerEvent) => {\n      if (!raizRef.current?.contains(e.target as Node)) setAberto(false);\n    };\n    document.addEventListener(\"pointerdown\", fora);\n    return () => document.removeEventListener(\"pointerdown\", fora);\n  }, [aberto]);\n\n  // Opção ativa sempre visível dentro da lista que rola.\n  React.useEffect(() => {\n    if (!aberto || ativo < 0) return;\n    document.getElementById(`${id}-op-${ativo}`)?.scrollIntoView({ block: \"nearest\" });\n  }, [aberto, ativo, id]);\n\n  const habilitados = items.map((i, n) => (i.disabled ? -1 : n)).filter((n) => n >= 0);\n  const vizinho = (de: number, passo: number) => {\n    if (!habilitados.length) return -1;\n    const pos = habilitados.indexOf(de);\n    if (pos < 0) return passo > 0 ? habilitados[0]! : habilitados[habilitados.length - 1]!;\n    return habilitados[Math.min(habilitados.length - 1, Math.max(0, pos + passo))]!;\n  };\n\n  const abrir = () => {\n    if (disabled) return;\n    setAtivo(escolhido >= 0 && !items[escolhido]?.disabled ? escolhido : (habilitados[0] ?? -1));\n    setAberto(true);\n  };\n\n  const escolher = (n: number) => {\n    const alvo = items[n];\n    if (!alvo || alvo.disabled) return;\n    if (value === undefined) setInterno(alvo.value);\n    onValueChange?.(alvo.value, alvo);\n    setAberto(false);\n    gatilhoRef.current?.focus();\n  };\n\n  const aoTeclar = (e: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (disabled) return;\n    if (!aberto) {\n      if ([\"Enter\", \" \", \"ArrowDown\", \"ArrowUp\"].includes(e.key)) {\n        e.preventDefault();\n        abrir();\n      }\n      return;\n    }\n    const mapa: Record<string, () => void> = {\n      ArrowDown: () => setAtivo((a) => vizinho(a, 1)),\n      ArrowUp: () => setAtivo((a) => vizinho(a, -1)),\n      PageDown: () => setAtivo((a) => vizinho(a, 8)),\n      PageUp: () => setAtivo((a) => vizinho(a, -8)),\n      Home: () => setAtivo(habilitados[0] ?? -1),\n      End: () => setAtivo(habilitados[habilitados.length - 1] ?? -1),\n      Enter: () => escolher(ativo),\n      \" \": () => escolher(ativo),\n      Escape: () => setAberto(false),\n    };\n    if (e.key === \"Tab\") {\n      setAberto(false);\n      return;\n    }\n    const acao = mapa[e.key];\n    if (acao) {\n      e.preventDefault();\n      acao();\n      return;\n    }\n    // Uma letra pula pra próxima opção cujo rótulo começa com ela.\n    if (e.key.length === 1 && /\\S/.test(e.key)) {\n      const letra = e.key.toLocaleLowerCase(\"pt-BR\");\n      const ordem = [...habilitados.filter((n) => n > ativo), ...habilitados.filter((n) => n <= ativo)];\n      const achou = ordem.find((n) => items[n]!.label.toLocaleLowerCase(\"pt-BR\").startsWith(letra));\n      if (achou !== undefined) setAtivo(achou);\n    }\n  };\n\n  const larguraAberta = Math.max(panelWidth, fechado.w);\n  const transicao = reduzido ? INSTANT : MOLA;\n\n  const conteudoGatilho = (texto: string, icone?: React.ReactNode) => (\n    <>\n      {icone && <span className=\"flex shrink-0 text-muted-foreground [&>svg]:size-4\">{icone}</span>}\n      <span className=\"min-w-0 flex-1 truncate text-left\">{texto}</span>\n    </>\n  );\n\n  return (\n    <div ref={raizRef} className={cn(\"inline-flex flex-col gap-1.5 text-sm\", className)}>\n      <span id={labelId} className={cn(\"font-medium text-foreground\", hideLabel && \"sr-only\")}>\n        {label}\n      </span>\n\n      <div className=\"relative\" style={{ height: ALTURA_GATILHO }}>\n        {/* O molde: cópia invisível do gatilho fechado. Segura o lugar no fluxo e dá a largura fechada. */}\n        <span\n          ref={moldeRef}\n          aria-hidden=\"true\"\n          className=\"invisible inline-flex h-10 max-w-[16rem] items-center gap-2 border border-transparent pl-3 pr-9\"\n        >\n          {conteudoGatilho(item?.label ?? placeholder, item?.icon)}\n        </span>\n\n        <motion.div\n          initial={false}\n          // motion-lint-disable-next-line layout-property-animation  a superfície é o gatilho crescendo; escala deformaria texto e raio\n          animate={{\n            width: aberto ? larguraAberta : fechado.w || \"auto\",\n            height: aberto ? ALTURA_GATILHO + alturaLista : ALTURA_GATILHO,\n          }}\n          transition={transicao}\n          className={cn(\n            \"absolute top-0 z-20 overflow-hidden border border-border bg-card text-foreground\",\n            !aberto && \"pointer-coarse:overflow-visible\",\n            \"transition-[border-radius,box-shadow] duration-[var(--dur-base)] ease-[var(--ease-out)]\",\n            align === \"end\" ? \"right-0\" : \"left-0\",\n            // O anel de foco vai na superfície: dentro dela o `overflow-hidden` cortaria o anel do botão.\n            aberto\n              ? \"rounded-[var(--r-surface)] [box-shadow:var(--e-overlay)]\"\n              : \"rounded-[var(--r-control)] has-[button:focus-visible]:[box-shadow:var(--focus)]\",\n            disabled && \"opacity-50\",\n          )}\n        >\n          <button\n            ref={gatilhoRef}\n            type=\"button\"\n            role=\"combobox\"\n            aria-haspopup=\"listbox\"\n            aria-expanded={aberto}\n            aria-controls={listaId}\n            aria-labelledby={labelId}\n            aria-activedescendant={aberto && ativo >= 0 ? `${id}-op-${ativo}` : undefined}\n            disabled={disabled}\n            onClick={() => (aberto ? setAberto(false) : abrir())}\n            onKeyDown={aoTeclar}\n            className={cn(\n              // `[font:inherit]`: muito projeto (e o tema base do shadcn/Figma) estiliza `button`\n              // com fonte própria; o molde é um `span` e mede na fonte do pai. Fontes diferentes\n              // e o rótulo do gatilho virava reticências.\n              \"relative flex h-[38px] w-full items-center gap-2 pl-3 pr-9 outline-none [font:inherit] pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-1\",\n              disabled ? \"cursor-not-allowed\" : \"cursor-pointer\",\n              !item && \"text-muted-foreground\",\n            )}\n          >\n            <span className=\"relative grid min-w-0 flex-1 overflow-hidden\">\n              <AnimatePresence initial={false} custom={direcao} mode=\"popLayout\">\n                <motion.span\n                  key={item?.value ?? \"__vazio\"}\n                  custom={direcao}\n                  variants={reduzido ? fade : rolo}\n                  initial=\"entra\"\n                  animate=\"fica\"\n                  exit=\"sai\"\n                  transition={reduzido ? { duration: DUR.press } : { type: \"spring\", visualDuration: 0.24, bounce: 0 }}\n                  className=\"flex min-w-0 items-center gap-2 [grid-area:1/1]\"\n                >\n                  {conteudoGatilho(item?.label ?? placeholder, item?.icon)}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"absolute right-3 flex text-muted-foreground\"\n              initial={false}\n              animate={{ rotate: aberto ? 180 : 0 }}\n              transition={transicao}\n            >\n              <ChevronDown className=\"size-4\" />\n            </motion.span>\n          </button>\n\n          <ul\n            ref={listaRef}\n            id={listaId}\n            role=\"listbox\"\n            aria-labelledby={labelId}\n            tabIndex={-1}\n            aria-hidden={!aberto || undefined}\n            style={{ maxHeight: maxListHeight }}\n            className={cn(\n              \"overflow-y-auto overscroll-contain px-1 pb-1 transition-opacity duration-[var(--dur-fast)] ease-[var(--ease-out)]\",\n              aberto ? \"opacity-100\" : \"pointer-events-none opacity-0 pointer-coarse:invisible\",\n            )}\n          >\n            {items.map((op, n) => {\n              const marcado = n === escolhido;\n              const realcado = n === ativo;\n              return (\n                <li\n                  key={op.value}\n                  id={`${id}-op-${n}`}\n                  role=\"option\"\n                  aria-selected={marcado}\n                  aria-disabled={op.disabled || undefined}\n                  onPointerMove={() => !op.disabled && ativo !== n && setAtivo(n)}\n                  onClick={() => escolher(n)}\n                  className={cn(\n                    \"relative flex h-9 items-center gap-2 rounded-[var(--r-control)] px-2\",\n                    op.disabled ? \"cursor-not-allowed text-muted-foreground opacity-50\" : \"cursor-pointer\",\n                  )}\n                >\n                  {realcado && (\n                    <motion.span\n                      layoutId={`${id}-realce`}\n                      aria-hidden=\"true\"\n                      className=\"absolute inset-0 rounded-[inherit] bg-muted\"\n                      transition={reduzido ? INSTANT : { type: \"spring\", visualDuration: 0.2, bounce: 0 }}\n                    />\n                  )}\n                  {op.icon && <span className=\"relative flex shrink-0 text-muted-foreground [&>svg]:size-4\">{op.icon}</span>}\n                  <span className=\"relative min-w-0 flex-1 truncate\">{op.label}</span>\n                  {op.meta && <span className=\"relative shrink-0 text-xs text-muted-foreground tabular-nums\">{op.meta}</span>}\n                  <Check\n                    aria-hidden=\"true\"\n                    className={cn(\"relative size-4 shrink-0 text-foreground\", marcado ? \"opacity-100\" : \"opacity-0\")}\n                  />\n                </li>\n              );\n            })}\n          </ul>\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n\nexport default MorphSelect;\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Select de formulário em que o gatilho cresce e vira a lista, pra listas médias de opções",
      "evite": "Até 5 opções visíveis: segmented-control; escolha lúdica entre poucas, magnetic-select"
    }
  },
  "categories": [
    "controles",
    "select",
    "listbox",
    "formulario",
    "morph",
    "teclado"
  ],
  "type": "registry:ui"
}