{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "side-nav",
  "title": "Side Nav",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "A navegação lateral de aplicação: colapsa por clique ou por ⌘/Ctrl+B, agrupa, marca a rota ativa e lembra o estado. O rótulo vira sr-only quando fecha, nunca some do DOM.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/side-nav.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * SideNav — a navegação lateral de aplicação: colapsa, agrupa, lembra.\n *\n * ── Por que não se chama `sidebar`, e por que não é o `nav-drawer` esticado ─\n *\n * O nome não é `sidebar` de propósito. O shadcn publica um `sidebar` próprio, e\n * dois itens com o mesmo nome escrevem no mesmo `components/ui/sidebar.tsx`: o\n * segundo `add` apaga o primeiro, em silêncio, num projeto que já usava o outro.\n * `side-nav` também é mais honesto sobre o que a peça é — ela é NAVEGAÇÃO, com\n * landmark, `aria-current` e grupos; não é um painel lateral genérico.\n *\n * E ela não é o `@visant/nav-drawer` com outra pele. A gaveta é um DIÁLOGO: ela\n * cobre a página, prende o foco, fecha no `Escape` e devolve o foco ao gatilho —\n * é isso que um menu mobile precisa ser, e é isso que o Radix Dialog entrega\n * pronto. Esta aqui é o oposto: mora no layout, nunca cobre nada, não prende\n * foco nenhum e é a única coisa da tela que **continua igual entre as rotas**.\n * Esticar a gaveta produziria um diálogo permanentemente aberto, com trava de\n * rolagem na página inteira. São duas peças porque são dois papéis.\n *\n * O que as duas compartilham, e isso sim é de propósito: a mesma anatomia de\n * item (rótulo + `meta` à direita), o mesmo par `data-active`/`aria-current`, o\n * mesmo `asChild` por clonagem pro `<Link>` do projeto continuar sendo a âncora\n * de verdade, e o mesmo foco — `--focus`, nunca o anel do navegador.\n *\n * ── As quatro decisões que se paga caro pra descobrir ──────────────────────\n *\n * 1. **O estado é lido DEPOIS de montar.** `localStorage` não existe em SSR, e\n *    ler no `useState` inicial entregaria um HTML colapsado sobre uma hidratação\n *    expandida — o React reclama e a barra pisca de largura no primeiro quadro.\n *    Aqui o primeiro render é sempre o `defaultCollapsed`, e o valor guardado\n *    chega num efeito. Mesma regra do banco de mídia da vitrine.\n * 2. **O rótulo não sai do DOM quando colapsa.** Ele vira `sr-only`. Um ícone\n *    sozinho não tem nome acessível, e a barra colapsada viraria uma coluna de\n *    sete desenhos sem nome nenhum pra quem navega por leitor de tela. De quebra\n *    o `title` cobre o ponteiro, que também não adivinha ícone.\n * 3. **O atalho ignora campo de texto.** Sem essa guarda, `Ctrl/⌘ + B` dentro de\n *    um editor de texto do app colapsa a navegação em vez de negritar — e o\n *    defeito só aparece no dia em que o app ganha um editor.\n * 4. **Seta não navega aqui.** Uma lista de links dentro de um landmark de\n *    navegação é percorrida com Tab, e é o que o leitor de tela já anuncia. O\n *    tabindex rotativo do `@visant/rail` existe porque um trilho horizontal de\n *    vinte cards viraria vinte paradas de Tab antes do conteúdo; a barra lateral\n *    é a navegação, e as paradas dela são o ponto. Sequestrar ↑/↓ aqui só\n *    quebraria a rolagem de uma barra com muitos itens.\n *\n * A peça continua sem saber qual rota está ativa: `active` entra por prop. Ela\n * não importa o router de ninguém.\n */\n\ninterface SideNavCtx {\n  collapsed: boolean;\n  alternar: () => void;\n  /** Id do `<nav>`, pro `aria-controls` do gatilho apontar pra algum lugar. */\n  navId: string;\n}\n\nconst Ctx = React.createContext<SideNavCtx | null>(null);\n\nfunction useSideNav(quem: string) {\n  const ctx = React.useContext(Ctx);\n  if (!ctx) throw new Error(`<${quem}> precisa estar dentro de <SideNav>.`);\n  return ctx;\n}\n\nexport interface SideNavProps extends React.HTMLAttributes<HTMLElement> {\n  /** Estado inicial quando não há nada guardado. */\n  defaultCollapsed?: boolean;\n  /** Controle externo. Passando isto, a peça para de guardar sozinha. */\n  collapsed?: boolean;\n  onCollapsedChange?: (collapsed: boolean) => void;\n  /** Chave do `localStorage`. `false` desliga a persistência. */\n  storageKey?: string | false;\n  /**\n   * A tecla do atalho, sempre com `Ctrl`/`⌘`. `false` desliga.\n   *\n   * `\"b\"` é o padrão porque é o que VS Code, Linear e o próprio shadcn usam pra\n   * a mesma coisa — atalho de aplicação é convenção, não invenção.\n   */\n  shortcut?: string | false;\n  /** Nome do landmark. Duas navegações na mesma tela precisam de nomes diferentes. */\n  label?: string;\n  /** Marca no topo. Slot, porque cada projeto tem o componente de logo dele. */\n  brand?: React.ReactNode;\n  /**\n   * A marca reduzida a símbolo, pro modo compacto. Num trilho de 3,75rem o nome\n   * escrito não cabe e vira um corte no meio da palavra; o símbolo cabe. Sem\n   * ele, a barra compacta mostra a `brand` como antes.\n   */\n  brandIcon?: React.ReactNode;\n  /** Rodapé colado embaixo: conta, tema, versão. */\n  footer?: React.ReactNode;\n  /**\n   * O gatilho de colapso no topo da própria barra.\n   *\n   * Ele mora AQUI dentro porque o contexto que carrega o estado mora aqui: um\n   * `<SideNavToggle>` solto no header do app estaria fora do provider e\n   * estouraria no primeiro render. Quem quer o gatilho no header do app usa o\n   * par controlado (`collapsed` + `onCollapsedChange`) e desenha o botão dele —\n   * é o mesmo trato do `nav-drawer`, que também não decide onde fica o gatilho.\n   */\n  toggle?: boolean;\n  /** Largura aberta, em rem. */\n  width?: number;\n  /** Largura colapsada, em rem. Tem que caber um alvo de toque de 44px. */\n  collapsedWidth?: number;\n}\n\nexport function SideNav({\n  defaultCollapsed = false,\n  collapsed: collapsedProp,\n  onCollapsedChange,\n  storageKey = \"visant:side-nav\",\n  shortcut = \"b\",\n  label = \"Navegação principal\",\n  brand,\n  brandIcon,\n  footer,\n  toggle = true,\n  width = 15,\n  collapsedWidth = 3.75,\n  className,\n  style,\n  children,\n  ...props\n}: SideNavProps) {\n  const navId = React.useId();\n  const controlado = collapsedProp !== undefined;\n  const [interno, setInterno] = React.useState(defaultCollapsed);\n  const collapsed = controlado ? collapsedProp : interno;\n\n  // Ver a decisão 1: o valor guardado chega depois do primeiro render.\n  React.useEffect(() => {\n    if (controlado || !storageKey) return;\n    try {\n      const guardado = localStorage.getItem(storageKey);\n      if (guardado === \"1\" || guardado === \"0\") setInterno(guardado === \"1\");\n    } catch {\n      /* modo privado, storage bloqueado: segue com o padrão */\n    }\n  }, [controlado, storageKey]);\n\n  const definir = React.useCallback(\n    (valor: boolean) => {\n      onCollapsedChange?.(valor);\n      if (controlado) return;\n      setInterno(valor);\n      if (!storageKey) return;\n      try {\n        localStorage.setItem(storageKey, valor ? \"1\" : \"0\");\n      } catch {\n        /* idem */\n      }\n    },\n    [controlado, onCollapsedChange, storageKey],\n  );\n\n  // `ref` e não dependência: o listener é registrado uma vez, e não quero\n  // remontá-lo a cada troca de estado — atalho que se re-registra é atalho que\n  // perde tecla no meio de um `keydown` repetido.\n  const estado = React.useRef({ collapsed, definir });\n  estado.current = { collapsed, definir };\n\n  React.useEffect(() => {\n    if (!shortcut) return;\n    const alvo = shortcut.toLowerCase();\n\n    const aoTeclar = (e: KeyboardEvent) => {\n      if (e.key.toLowerCase() !== alvo || !(e.metaKey || e.ctrlKey)) return;\n\n      // Ver a decisão 3: dentro de campo de texto o atalho é de quem digita.\n      const foco = document.activeElement as HTMLElement | null;\n      if (\n        foco &&\n        (foco.isContentEditable ||\n          foco.tagName === \"INPUT\" ||\n          foco.tagName === \"TEXTAREA\" ||\n          foco.tagName === \"SELECT\")\n      ) {\n        return;\n      }\n\n      e.preventDefault();\n      estado.current.definir(!estado.current.collapsed);\n    };\n\n    window.addEventListener(\"keydown\", aoTeclar);\n    return () => window.removeEventListener(\"keydown\", aoTeclar);\n  }, [shortcut]);\n\n  const valor = React.useMemo<SideNavCtx>(\n    () => ({ collapsed, alternar: () => definir(!collapsed), navId }),\n    [collapsed, definir, navId],\n  );\n\n  return (\n    <Ctx.Provider value={valor}>\n      <nav\n        id={navId}\n        aria-label={label}\n        data-collapsed={collapsed ? \"\" : undefined}\n        className={cn(\n          \"group/sidenav flex h-full flex-col gap-4 border-r border-[var(--border)]\",\n          \"bg-[var(--card,var(--background))] py-4 text-[var(--foreground)]\",\n          // Só `width`, nunca `transition-all`: numa barra com trinta itens o\n          // `all` arrasta cor, sombra e transform de cada um deles junto.\n          // motion-lint-disable-next-line layout-property-animation  a barra precisa empurrar o conteúdo da página ao recolher\n          \"transition-[width] [transition-duration:var(--dur-base,220ms)]\",\n          \"[transition-timing-function:var(--ease-out)]\",\n          \"motion-reduce:transition-none\",\n          className,\n        )}\n        style={{ width: `${collapsed ? collapsedWidth : width}rem`, ...style }}\n        {...props}\n      >\n        {brand || toggle ? (\n          // Colapsada, a fileira vira coluna: 3,75rem não comportam marca e\n          // gatilho lado a lado, e esconder a marca faria o único momento em que\n          // a barra encolhe ser também o único em que a marca some da tela.\n          <div\n            className={cn(\n              \"flex min-h-9 items-center gap-2 px-3\",\n              \"group-data-[collapsed]/sidenav:flex-col group-data-[collapsed]/sidenav:gap-3\",\n              \"group-data-[collapsed]/sidenav:px-0\",\n            )}\n          >\n            {brand ? (\n              <div\n                className={cn(\n                  \"min-w-0 flex-1 truncate group-data-[collapsed]/sidenav:flex-none\",\n                  brandIcon && \"group-data-[collapsed]/sidenav:hidden\",\n                )}\n              >\n                {brand}\n              </div>\n            ) : null}\n            {brandIcon ? (\n              <div className=\"hidden shrink-0 group-data-[collapsed]/sidenav:flex\">{brandIcon}</div>\n            ) : null}\n            {toggle ? <SideNavToggle className=\"ml-auto group-data-[collapsed]/sidenav:ml-0\" /> : null}\n          </div>\n        ) : null}\n\n        {/*\n         * `overflow-y-auto` + `overscroll-contain`: barra com muitos grupos rola\n         * sozinha, e o `contain` impede que a rolagem vaze pro conteúdo do app\n         * quando chega no fim — o efeito de corrente que denuncia painel mal\n         * feito. `overflow-x-clip` e não `hidden` porque `hidden` criaria um\n         * segundo contêiner de rolagem, e no meio da transição de largura o\n         * rótulo ainda não cabe: sem o corte, aparece uma barra horizontal que\n         * pisca a cada colapso.\n         */}\n        <div className=\"flex-1 overflow-y-auto overflow-x-clip overscroll-contain px-2\">\n          {children}\n        </div>\n\n        {footer ? (\n          <div className=\"border-t border-[var(--border)] px-2 pt-3\">{footer}</div>\n        ) : null}\n      </nav>\n    </Ctx.Provider>\n  );\n}\n\n/**\n * O gatilho de colapso.\n *\n * Fica fora do `<nav>` na maioria dos layouts (topo do app), e é por isso que\n * ele lê o estado do contexto em vez de receber por prop: um segundo estado pra\n * manter em sincronia é como o ícone acaba mentindo depois de um atalho de\n * teclado, que é justamente o caminho que não passa por clique.\n */\nexport const SideNavToggle = React.forwardRef<\n  HTMLButtonElement,\n  React.ButtonHTMLAttributes<HTMLButtonElement>\n>(function SideNavToggle({ className, ...props }, ref) {\n  const { collapsed, alternar, navId } = useSideNav(\"SideNavToggle\");\n\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      onClick={alternar}\n      aria-expanded={!collapsed}\n      aria-controls={navId}\n      aria-label={collapsed ? \"Expandir a navegação\" : \"Recolher a navegação\"}\n      className={cn(\n        \"relative grid size-9 shrink-0 place-items-center rounded-[var(--r-control,0.5rem)] pointer-coarse:after:absolute pointer-coarse:after:-inset-1\",\n        \"text-[var(--muted-foreground)] transition-colors [transition-duration:var(--dur-fast,140ms)]\",\n        \"hover:bg-[var(--muted)] hover:text-[var(--foreground)]\",\n        \"outline-none focus-visible:[box-shadow:var(--focus)]\",\n        className,\n      )}\n      {...props}\n    >\n      {/* Desenhado, nunca um caractere: \"«\" muda de forma e de peso a cada fonte\n          de marca, e some quando a fonte não carrega. */}\n      <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" aria-hidden=\"true\">\n        <path\n          d=\"M2.5 3v10M13.5 8H6m0 0 2.75-2.75M6 8l2.75 2.75\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.4\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          // Gira em vez de trocar de ícone: dois `<path>` alternando piscam no\n          // meio da transição de largura, porque o SVG remonta.\n          className={cn(\n            \"origin-center transition-transform [transition-duration:var(--dur-base,220ms)]\",\n            \"[transition-timing-function:var(--ease-out)]\",\n            \"motion-reduce:transition-none\",\n            !collapsed && \"-scale-x-100\",\n          )}\n        />\n      </svg>\n    </button>\n  );\n});\n\nexport interface SideNavGroupProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** O nome do grupo. Colapsado, ele vira uma régua — ver a nota. */\n  label?: React.ReactNode;\n}\n\n/**\n * O grupo, e o que acontece com o rótulo dele quando a barra fecha.\n *\n * \"Projetos\" em 3,75rem de largura sai cortado no meio da segunda sílaba, e um\n * rótulo cortado é pior que rótulo nenhum — ele parece defeito. Colapsado, o\n * rótulo vira `sr-only` e no lugar dele fica uma régua: a informação \"aqui\n * começa outro conjunto\" sobrevive, que é a única parte do rótulo que o desenho\n * precisa carregar naquela largura.\n *\n * `role=\"group\"` + `aria-labelledby` e não uma `<section>`: seção dentro de\n * `<nav>` cria um segundo landmark aninhado, e o leitor de tela passa a anunciar\n * duas regiões onde existe uma.\n */\nexport function SideNavGroup({\n  label,\n  className,\n  children,\n  ...props\n}: SideNavGroupProps) {\n  const { collapsed } = useSideNav(\"SideNavGroup\");\n  const id = React.useId();\n\n  return (\n    <div\n      role=\"group\"\n      aria-labelledby={label ? id : undefined}\n      className={cn(\"py-2\", className)}\n      {...props}\n    >\n      {label ? (\n        <>\n          <p\n            id={id}\n            className={cn(\n              \"px-3 pb-1.5 text-[length:var(--text-rotulo,0.6875rem)] uppercase tracking-[0.14em]\",\n              \"text-[var(--muted-foreground)]\",\n              collapsed && \"sr-only\",\n            )}\n          >\n            {label}\n          </p>\n          {collapsed ? (\n            <hr aria-hidden=\"true\" className=\"mx-3 mb-2 border-t border-[var(--border)]\" />\n          ) : null}\n        </>\n      ) : null}\n      <ul className=\"flex flex-col gap-0.5\">{children}</ul>\n    </div>\n  );\n}\n\nexport interface SideNavItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {\n  /** O ícone. Obrigatório na prática: é tudo que sobra quando a barra fecha. */\n  icon?: React.ReactNode;\n  /** Marca a rota atual. A peça não descobre isso sozinha. */\n  active?: boolean;\n  /** Contagem, atalho, \"novo\". Some quando a barra fecha — não cabe. */\n  meta?: React.ReactNode;\n  /** Renderiza no filho — `<Link>` do Next, `<NavLink>` do router. */\n  asChild?: boolean;\n}\n\n/*\n * `forwardRef` não é zelo: `asChild` com o `<Link>` do Next é o uso normal, e\n * clonar sem encaminhar a ref avisa no console de todo projeto que usa a peça\n * do jeito certo.\n *\n * E `asChild` aqui é clonagem, não `Slot`: o Slot funde props no filho mas\n * mantém os filhos DELE, e a peça precisa reparentar — o rótulo tem que entrar\n * dentro do `<span>` que sabe virar `sr-only`, e o ícone tem que ficar fora\n * dele. Com Slot, o `<Link>` chegaria com três filhos e o texto seria o único\n * a sobreviver. Mesma solução do `@visant/nav-drawer`.\n */\nexport const SideNavItem = React.forwardRef<HTMLAnchorElement, SideNavItemProps>(\n  function SideNavItem(\n    { icon, active = false, meta, asChild = false, className, children, ...props },\n    ref,\n  ) {\n    const { collapsed } = useSideNav(\"SideNavItem\");\n\n    const rotulo =\n      asChild && React.isValidElement(children)\n        ? (children as React.ReactElement<{ children?: React.ReactNode }>).props.children\n        : children;\n\n    const conteudo = (\n      <>\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"grid size-5 shrink-0 place-items-center\",\n            \"[&_svg]:size-[1.125rem]\",\n            // Sem ícone e com a barra fechada não sobraria NADA visível: as duas\n            // primeiras letras do rótulo são o mínimo que ainda distingue um\n            // item do outro. Feio de propósito — é um lembrete de que a barra\n            // colapsável pede ícone.\n            !icon &&\n              \"font-mono text-[length:var(--text-micro,0.6875rem)] uppercase tracking-tight\",\n          )}\n        >\n          {icon ?? (typeof rotulo === \"string\" ? rotulo.slice(0, 2) : null)}\n        </span>\n\n        {/* Ver a decisão 2: vira `sr-only`, nunca sai do DOM. */}\n        <span\n          className={cn(\n            \"min-w-0 flex-1 truncate text-left\",\n            collapsed && \"sr-only\",\n          )}\n        >\n          {rotulo}\n        </span>\n\n        {meta && !collapsed ? (\n          <span className=\"shrink-0 text-[length:var(--text-micro,0.6875rem)] tabular-nums tracking-[0.08em] text-[var(--muted-foreground)]\">\n            {meta}\n          </span>\n        ) : null}\n      </>\n    );\n\n    const comum = {\n      ref,\n      \"data-active\": active || undefined,\n      // `data-active` pinta; `aria-current` INFORMA. Sem ele o leitor de tela lê\n      // sete links iguais e a pessoa não descobre em qual tela está — que é a\n      // pergunta que uma navegação existe pra responder.\n      \"aria-current\": active ? (\"page\" as const) : undefined,\n      // O ponteiro também não adivinha ícone. Só quando fechada: aberta, o\n      // tooltip nativo repetiria o rótulo que já está na tela.\n      title: collapsed && typeof rotulo === \"string\" ? rotulo : undefined,\n      className: cn(\n        \"relative flex w-full items-center gap-3 rounded-[var(--r-control,0.5rem)] px-3 py-2 pointer-coarse:min-h-11\",\n        \"text-[length:var(--text-corpo,0.875rem)] no-underline\",\n        \"text-[var(--muted-foreground)]\",\n        \"transition-colors [transition-duration:var(--dur-fast,140ms)]\",\n        \"hover:bg-[var(--muted)] hover:text-[var(--foreground)]\",\n        \"data-[active]:bg-[var(--muted)] data-[active]:font-medium data-[active]:text-[var(--foreground)]\",\n        \"outline-none focus-visible:[box-shadow:var(--focus)]\",\n        collapsed && \"justify-center px-0\",\n        className,\n      ),\n      ...props,\n    };\n\n    const marca = active ? (\n      // A régua da rota ativa: 2px na borda esquerda, na cor da marca. É o que\n      // sobrevive quando a barra fecha — o fundo `muted` sozinho fica fraco\n      // demais numa coluna de 3,75rem cheia de ícones.\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute inset-y-1.5 left-0 w-[2px] rounded-full bg-[var(--brand,currentColor)]\"\n      />\n    ) : null;\n\n    if (asChild && React.isValidElement(children)) {\n      const filho = children as React.ReactElement<Record<string, unknown>>;\n      return (\n        <li>\n          {React.cloneElement(\n            filho,\n            {\n              ...comum,\n              className: cn(comum.className, filho.props.className as string),\n            },\n            <>\n              {marca}\n              {conteudo}\n            </>,\n          )}\n        </li>\n      );\n    }\n\n    return (\n      <li>\n        <a {...comum}>\n          {marca}\n          {conteudo}\n        </a>\n      </li>\n    );\n  },\n);\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Navegação lateral de app ou dashboard, colapsável por atalho e com a rota ativa marcada",
      "evite": "Menu de site no celular: nav-drawer; poucas ações num botão flutuante, orbit-menu"
    }
  },
  "categories": [
    "controles",
    "sidebar",
    "navegacao",
    "app",
    "colapsar",
    "atalho",
    "teclado"
  ],
  "type": "registry:ui"
}