{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "segmented-control",
  "title": "Segmented Control",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Alternador de poucas opções. Substitui um select quando as opções cabem na tela.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/segmented-control.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { EASE_MOLA_CSS } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface SegmentedOption<T extends string> {\n  value: T;\n  label?: string;\n  icon?: LucideIcon;\n  ariaLabel?: string;\n}\n\ninterface SegmentedControlProps<T extends string> {\n  value: T;\n  onChange: (value: T) => void;\n  options: SegmentedOption<T>[];\n  /** \"fill\" = itens crescem igualmente (com label); \"compact\" = botões de ícone. */\n  variant?: \"fill\" | \"compact\";\n  /**\n   * Densidade. `md` é a régua da casa.\n   *\n   * `sm` existe para painel lateral: medido num painel de 340px onde três\n   * grupos dividem a mesma linha, 12px de rótulo não cabem e o controle começa\n   * a truncar palavra (\"Por pesso…\"). Encolher a tipografia é mais honesto do\n   * que abreviar o rótulo, porque a abreviação some da leitura e o tamanho não.\n   */\n  size?: \"sm\" | \"md\";\n  /**\n   * Como o polegar vai de uma opção pra outra.\n   *\n   * - `slide` (padrão): desliza reto, sem passar do ponto. O da casa.\n   * - `spring`: desliza e passa um tico do alvo antes de assentar, como mola.\n   * - `stretch`: a borda da frente sai primeiro e a de trás vem depois; o\n   *   polegar estica na direção do clique e encolhe ao chegar, como gota.\n   * - `fade`: o polegar não viaja; some de um lado e acende no outro, com um\n   *   leve crescer. Pra quando a ordem das opções não diz nada.\n   */\n  animation?: \"slide\" | \"spring\" | \"stretch\" | \"fade\";\n  className?: string;\n  \"aria-label\"?: string;\n}\n\n/** No `stretch`, quanto a borda de trás espera a da frente sair, em ms. */\nconst ATRASO_ESTICA = 70;\n\n/**\n * Apple-like segmented control (trilho + pill ativo). Fonte única reusada pelo\n * cabeçalho da vitrine (seção e densidade), pelo Gallery e pelos kits.\n *\n * ── O que faltava (o \"meio simples\") ────────────────────────────────────────\n * Não faltava enfeite: faltava o pill. O \"pill ativo\" era só uma cor de fundo\n * que pulava de um botão para o outro — cada segmento tinha o seu, e o\n * `gap-1` entre eles fazia o trilho parecer três botões encostados em vez de um\n * controle só. Um segmented control de verdade tem UM polegar que ANDA: é o\n * movimento que conta de onde você veio, e é ele que faz o grupo ler como uma\n * peça única.\n *\n * Agora o polegar é um elemento só, absoluto, que translada por `transform`.\n * Nada de largura/altura animada — o trilho tem colunas iguais (`flex-1` no\n * fill, largura fixa no compact), então `translateX(índice · 100%)` cai exato\n * em qualquer quantidade de opções, sem medir DOM e sem ResizeObserver.\n *\n * O `gap` saiu e entraram divisores de fio de cabelo entre vizinhos inativos,\n * que somem ao lado do ativo — é o detalhe que separa \"montado\" de \"desenhado\",\n * e é o mesmo truque do iOS.\n *\n * ── Teclado ────────────────────────────────────────────────────────────────\n * Setas ←/→ (e ↑/↓) andam pelas opções e já trocam o valor, com Home/End nas\n * pontas. O grupo é UM ponto de tabulação (roving tabindex): com quatro desses\n * no cabeçalho da vitrine, um Tab por botão viraria uma travessia de doze\n * paradas antes de chegar no conteúdo.\n *\n * ── Ícone sem rótulo ───────────────────────────────────────────────────────\n * `aria-label` já entrega o nome pro leitor de tela; o `title` entrega pro\n * ponteiro. Quem troca \"Barras/Linha\" por dois ícones tira uma tradução do\n * caminho de quem vê — e não pode tirar o nome de quem não vê nem de quem não\n * reconhece o desenho.\n *\n * A API não mudou uma vírgula — o cabeçalho da vitrine inteira depende dela.\n */\nexport function SegmentedControl<T extends string>({\n  value,\n  onChange,\n  options,\n  variant = \"fill\",\n  size = \"md\",\n  animation = \"slide\",\n  className,\n  \"aria-label\": ariaLabel,\n}: SegmentedControlProps<T>) {\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const activeIndex = Math.max(\n    0,\n    options.findIndex((o) => o.value === value),\n  );\n  const n = options.length || 1;\n\n  /*\n   * Onde o polegar fica, medido no botão ativo.\n   *\n   * A versão anterior supunha colunas iguais (uma fração exata do trilho). Isso só\n   * vale quando todos os rótulos cabem na fração: com `flex-1` e rótulo sem quebra,\n   * um rótulo longo (\"Com os tempos\" ao lado de \"Varrer\") alarga o próprio botão, e\n   * o polegar invadia o vizinho ou deixava o texto encostado na borda. Medir o botão\n   * ativo acerta em qualquer combinação. O primeiro desenho (e o SSR) usa a fração,\n   * que é exata no caso comum; a medida entra no layout effect, antes de pintar.\n   */\n  const trilho = React.useRef<HTMLDivElement>(null);\n  const [medida, setMedida] = React.useState<{ left: number; width: number } | null>(null);\n  React.useLayoutEffect(() => {\n    const el = refs.current[activeIndex];\n    const pai = trilho.current;\n    if (!el || !pai) return;\n    const medir = () => setMedida({ left: el.offsetLeft, width: el.offsetWidth });\n    medir();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(medir);\n    ro.observe(pai);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [activeIndex, n]);\n\n  // Direção do último passo, pro `stretch` saber qual borda sai primeiro. A\n  // ref só anda depois do commit: re-render sem troca dá direção 0, e mudar o\n  // atraso com a transição já rodando não a reinicia.\n  const anterior = React.useRef(activeIndex);\n  const direcao = Math.sign(activeIndex - anterior.current);\n  React.useEffect(() => {\n    anterior.current = activeIndex;\n  }, [activeIndex]);\n\n  /*\n   * O foco vai num efeito, não dentro do handler.\n   *\n   * `onChange` aqui troca a seção da vitrine inteira: a árvore acima re-renderiza\n   * e o botão que eu tinha acabado de focar deixa de ser o mesmo nó. Focando\n   * dentro do handler (antes do render) o foco ia para um elemento que morria em\n   * seguida, caía no `body`, e a tecla SEGUINTE virava rolagem de página — foi\n   * exatamente assim que `End` mandou a vitrine para o rodapé em vez de ir para\n   * a última opção. Com a marca e o efeito, o foco pousa no nó que sobreviveu.\n   */\n  const pedirFoco = React.useRef(false);\n\n  React.useEffect(() => {\n    if (!pedirFoco.current) return;\n    pedirFoco.current = false;\n    refs.current[activeIndex]?.focus();\n  }, [activeIndex]);\n\n  const move = (to: number) => {\n    const i = (to + n) % n;\n    const opt = options[i];\n    if (!opt) return;\n    pedirFoco.current = true;\n    onChange(opt.value);\n    refs.current[i]?.focus();\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    switch (e.key) {\n      case \"ArrowLeft\":\n      case \"ArrowUp\":\n        e.preventDefault();\n        move(activeIndex - 1);\n        break;\n      case \"ArrowRight\":\n      case \"ArrowDown\":\n        e.preventDefault();\n        move(activeIndex + 1);\n        break;\n      case \"Home\":\n        e.preventDefault();\n        move(0);\n        break;\n      case \"End\":\n        e.preventDefault();\n        move(n - 1);\n        break;\n    }\n  };\n\n  return (\n    <div\n      ref={trilho}\n      role=\"group\"\n      aria-label={ariaLabel}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"relative isolate flex items-center rounded-xl bg-muted/50 p-1\",\n        className,\n      )}\n    >\n      {/* O polegar. `inset` de 4px = o `p-1` do trilho. Posição e largura vêm da\n          medida do botão ativo (ver `medida`); a fração é só o primeiro desenho. */}\n      {animation === \"stretch\" ? (\n        // As duas bordas andam separadas (`left` e `right`), e a da frente\n        // não espera: é isso que estica. 200 ms + 70 de atraso cabem no teto\n        // de 300 ms de resposta a gesto.\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-y-1 -z-10 rounded-lg bg-background shadow-sm\"\n          style={{\n            left: medida ? `${medida.left}px` : `calc(0.25rem + (100% - 0.5rem) * ${activeIndex} / ${n})`,\n            right: medida\n              ? `calc(100% - ${medida.left + medida.width}px)`\n              : `calc(0.25rem + (100% - 0.5rem) * ${n - 1 - activeIndex} / ${n})`,\n            transitionProperty: \"left, right\",\n            transitionDuration: \"var(--dur-base,200ms)\",\n            transitionTimingFunction: \"var(--ease-out)\",\n            transitionDelay:\n              direcao > 0\n                ? `${ATRASO_ESTICA}ms, 0ms`\n                : direcao < 0\n                  ? `0ms, ${ATRASO_ESTICA}ms`\n                  : \"0ms\",\n          }}\n        />\n      ) : animation !== \"fade\" ? (\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"pointer-events-none absolute inset-y-1 -z-10 rounded-lg bg-background shadow-sm\",\n            medida ? \"left-0\" : \"left-1\",\n            animation === \"spring\"\n              ? \"transition-[transform,width] [transition-duration:280ms]\"\n              : \"transition-[transform,width] [transition-duration:var(--dur-base,200ms)] [transition-timing-function:var(--ease-out)]\",\n          )}\n          style={{\n            width: medida ? `${medida.width}px` : `calc((100% - 0.5rem) / ${n})`,\n            transform: medida ? `translateX(${medida.left}px)` : `translateX(${activeIndex * 100}%)`,\n            transitionTimingFunction: animation === \"spring\" ? EASE_MOLA_CSS : undefined,\n          }}\n        />\n      ) : null}\n      {options.map((opt, i) => {\n        const Icon = opt.icon;\n        const active = value === opt.value;\n        const soIcone = !!Icon && !opt.label;\n        // O fio some ao lado do ativo (dos dois lados) para o polegar não\n        // encostar em risco nenhum.\n        const showDivider = i > 0 && i !== activeIndex && i - 1 !== activeIndex;\n        return (\n          <button\n            key={opt.value}\n            ref={(el) => {\n              refs.current[i] = el;\n            }}\n            type=\"button\"\n            onClick={() => onChange(opt.value)}\n            aria-pressed={active}\n            aria-label={opt.ariaLabel || opt.label}\n            title={soIcone ? opt.ariaLabel || opt.label : undefined}\n            // Compara índice e não valor: se `value` não bater com opção\n            // nenhuma, `activeIndex` cai em 0 e o grupo continua tabulável —\n            // com `active ? 0 : -1` ele ficaria inalcançável por teclado.\n            tabIndex={i === activeIndex ? 0 : -1}\n            className={cn(\n              \"relative flex items-center justify-center gap-1.5 rounded-lg font-medium pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2.5\",\n              \"transition-[color,opacity,transform] [transition-duration:var(--dur-fast,160ms)] [transition-timing-function:var(--ease-out)]\",\n              \"active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50\",\n              size === \"sm\" ? \"text-[11px]\" : \"text-xs\",\n              variant === \"fill\"\n                ? cn(\"flex-1\", size === \"sm\" ? \"px-2 py-1\" : \"px-3 py-1.5\")\n                : size === \"sm\"\n                  ? \"h-6 w-7 pointer-coarse:w-11\"\n                  : \"h-7 w-8 pointer-coarse:w-11\",\n              active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n              // Fio de cabelo entre vizinhos inativos.\n              showDivider &&\n                \"before:absolute before:inset-y-1.5 before:left-0 before:w-px before:bg-border before:content-['']\",\n            )}\n          >\n            {animation === \"fade\" && (\n              // No `fade` cada opção tem o próprio polegar: apaga num lado e\n              // acende no outro, crescendo de 96% a 100%.\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  \"pointer-events-none absolute inset-0 -z-10 rounded-lg bg-background shadow-sm\",\n                  \"transition-[opacity,transform] [transition-duration:var(--dur-base,200ms)] [transition-timing-function:var(--ease-out)]\",\n                  active ? \"scale-100 opacity-100\" : \"scale-[0.96] opacity-0\",\n                )}\n              />\n            )}\n            {Icon && <Icon className=\"h-3.5 w-3.5\" />}\n            {opt.label && <span className=\"whitespace-nowrap\">{opt.label}</span>}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Alternar entre 2 a 5 opções que cabem na tela, como lista ou grade, ou o período de um gráfico",
      "evite": "Mensal e anual com desconto: billing-toggle; muitas opções, morph-select; liga e desliga, liquid-toggle"
    }
  },
  "categories": [
    "controles",
    "controle",
    "apple"
  ],
  "type": "registry:ui"
}