@softize/opus 8.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (286) hide show
  1. package/CHANGELOG.md +1616 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -0
  4. package/bin/cli.mjs +528 -0
  5. package/bin/lib/check.mjs +307 -0
  6. package/bin/lib/components.mjs +151 -0
  7. package/bin/lib/create.mjs +208 -0
  8. package/bin/lib/db-check-runner.mjs +86 -0
  9. package/bin/lib/db-migrate-runner.mjs +89 -0
  10. package/bin/lib/db-scaffold-runner.mjs +84 -0
  11. package/bin/lib/db.mjs +261 -0
  12. package/bin/lib/docs-include.mjs +48 -0
  13. package/bin/lib/gen-dicts.mjs +134 -0
  14. package/bin/lib/gen-docs.mjs +288 -0
  15. package/bin/lib/gen-manifest.mjs +102 -0
  16. package/bin/lib/gen-openapi.mjs +195 -0
  17. package/bin/lib/gen-runner.mjs +472 -0
  18. package/bin/lib/gen-stubs.mjs +463 -0
  19. package/bin/lib/gen.mjs +311 -0
  20. package/bin/lib/init.mjs +514 -0
  21. package/bin/lib/introspect.mjs +107 -0
  22. package/bin/lib/mcp.mjs +85 -0
  23. package/bin/lib/postinstall.mjs +56 -0
  24. package/docs/chat-event-protocol.md +85 -0
  25. package/docs/code-style.md +16 -0
  26. package/docs/data-layer.md +246 -0
  27. package/docs/ownership-vs-shadcn-lock.md +102 -0
  28. package/docs/protocol.md +2053 -0
  29. package/docs/releasing.md +110 -0
  30. package/docs/shellnav.md +131 -0
  31. package/package.json +338 -0
  32. package/registry/hooks/hooks.json +26 -0
  33. package/registry/hooks/link-memory-on-start.mjs +46 -0
  34. package/registry/hooks/opus-check-on-stop.mjs +114 -0
  35. package/registry/skills/create-action/SKILL.md +49 -0
  36. package/registry/skills/create-action/scaffold.mjs +122 -0
  37. package/registry/templates/app/_gitignore +3 -0
  38. package/registry/templates/app/_npmrc +1 -0
  39. package/registry/templates/app/_opus/_gitignore +5 -0
  40. package/registry/templates/app/_prettierrc.json +6 -0
  41. package/registry/templates/app/index.html +13 -0
  42. package/registry/templates/app/opus.config.ts +16 -0
  43. package/registry/templates/app/package.json +43 -0
  44. package/registry/templates/app/pnpm-workspace.yaml +11 -0
  45. package/registry/templates/app/public/favicon.svg +4 -0
  46. package/registry/templates/app/src/App.tsx +37 -0
  47. package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
  48. package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
  49. package/registry/templates/app/src/domains/tasks/index.ts +13 -0
  50. package/registry/templates/app/src/index.css +18 -0
  51. package/registry/templates/app/src/main.tsx +25 -0
  52. package/registry/templates/app/tsconfig.json +20 -0
  53. package/registry/templates/app/vite.config.ts +46 -0
  54. package/registry/templates/monorepo/_gitignore +3 -0
  55. package/registry/templates/monorepo/_npmrc +1 -0
  56. package/registry/templates/monorepo/package.json +9 -0
  57. package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
  58. package/src/ai/ask.ts +64 -0
  59. package/src/ai/drivers/anthropic.ts +309 -0
  60. package/src/ai/index.ts +17 -0
  61. package/src/audit/drivers/console.ts +117 -0
  62. package/src/audit/drivers/pg.ts +172 -0
  63. package/src/audit/index.ts +51 -0
  64. package/src/auth/drivers/better-auth.ts +103 -0
  65. package/src/auth/drivers/jwt.ts +188 -0
  66. package/src/auth/index.ts +9 -0
  67. package/src/client/drivers/fetch.ts +202 -0
  68. package/src/client/index.ts +22 -0
  69. package/src/core/actions.ts +110 -0
  70. package/src/core/audit.ts +239 -0
  71. package/src/core/contracts.ts +137 -0
  72. package/src/core/domain.ts +310 -0
  73. package/src/core/errors.ts +181 -0
  74. package/src/core/index.ts +174 -0
  75. package/src/core/logical-type.ts +31 -0
  76. package/src/core/reactions.ts +81 -0
  77. package/src/core/runtime.ts +1167 -0
  78. package/src/core/schedules.ts +41 -0
  79. package/src/core/types.ts +1356 -0
  80. package/src/data/drivers/kysely.ts +389 -0
  81. package/src/data/index.ts +10 -0
  82. package/src/data/readonly-pool.ts +160 -0
  83. package/src/dsl/eval.ts +136 -0
  84. package/src/dsl/index.ts +29 -0
  85. package/src/dsl/kysely.ts +230 -0
  86. package/src/dsl/loads.ts +123 -0
  87. package/src/dsl/parser.ts +423 -0
  88. package/src/dsl/types.ts +113 -0
  89. package/src/events/drivers/mitt.ts +70 -0
  90. package/src/events/index.ts +9 -0
  91. package/src/log/drivers/pino.ts +57 -0
  92. package/src/log/index.ts +9 -0
  93. package/src/mcp/index.ts +62 -0
  94. package/src/queue/drivers/bullmq.ts +190 -0
  95. package/src/queue/index.ts +9 -0
  96. package/src/scheduler/drivers/node-cron.ts +93 -0
  97. package/src/scheduler/every.ts +45 -0
  98. package/src/scheduler/index.ts +9 -0
  99. package/src/schema/drivers/zod.ts +765 -0
  100. package/src/schema/entity.ts +439 -0
  101. package/src/schema/format/locale.ts +144 -0
  102. package/src/schema/index.ts +65 -0
  103. package/src/schema/openapi.ts +302 -0
  104. package/src/schema/scaffold.ts +160 -0
  105. package/src/server/drivers/fastify.ts +224 -0
  106. package/src/server/drivers/node.ts +386 -0
  107. package/src/server/index.ts +142 -0
  108. package/src/storage/drivers/fs.ts +90 -0
  109. package/src/storage/drivers/s3.ts +117 -0
  110. package/src/storage/index.ts +27 -0
  111. package/src/testing/fake.ts +298 -0
  112. package/src/testing/index.ts +324 -0
  113. package/src/ui/components/patterns/action-form-card.tsx +48 -0
  114. package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
  115. package/src/ui/components/patterns/app-shell.tsx +227 -0
  116. package/src/ui/components/patterns/confirm.tsx +226 -0
  117. package/src/ui/components/patterns/data-state.tsx +75 -0
  118. package/src/ui/components/patterns/form-dialog.tsx +64 -0
  119. package/src/ui/components/patterns/form.tsx +584 -0
  120. package/src/ui/components/patterns/list.tsx +1488 -0
  121. package/src/ui/components/patterns/page.tsx +46 -0
  122. package/src/ui/components/patterns/section-shell.tsx +246 -0
  123. package/src/ui/components/patterns/shell-nav.tsx +150 -0
  124. package/src/ui/components/patterns/sidebar.tsx +89 -0
  125. package/src/ui/components/patterns/split.tsx +93 -0
  126. package/src/ui/components/patterns/trigger.tsx +196 -0
  127. package/src/ui/components/patterns/view.tsx +84 -0
  128. package/src/ui/components/primitives/accordion.tsx +64 -0
  129. package/src/ui/components/primitives/alert-dialog.tsx +190 -0
  130. package/src/ui/components/primitives/alert.tsx +116 -0
  131. package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
  132. package/src/ui/components/primitives/avatar.tsx +107 -0
  133. package/src/ui/components/primitives/badge.tsx +37 -0
  134. package/src/ui/components/primitives/breadcrumb.tsx +109 -0
  135. package/src/ui/components/primitives/button-group.tsx +83 -0
  136. package/src/ui/components/primitives/button.tsx +102 -0
  137. package/src/ui/components/primitives/calendar.tsx +218 -0
  138. package/src/ui/components/primitives/card.tsx +56 -0
  139. package/src/ui/components/primitives/carousel.tsx +239 -0
  140. package/src/ui/components/primitives/chat.tsx +407 -0
  141. package/src/ui/components/primitives/checkbox.tsx +30 -0
  142. package/src/ui/components/primitives/collapsible.tsx +31 -0
  143. package/src/ui/components/primitives/command.tsx +182 -0
  144. package/src/ui/components/primitives/composer.tsx +121 -0
  145. package/src/ui/components/primitives/copyable.tsx +50 -0
  146. package/src/ui/components/primitives/dialog.tsx +147 -0
  147. package/src/ui/components/primitives/drawer.tsx +141 -0
  148. package/src/ui/components/primitives/empty.tsx +104 -0
  149. package/src/ui/components/primitives/field.tsx +246 -0
  150. package/src/ui/components/primitives/icon-picker.tsx +180 -0
  151. package/src/ui/components/primitives/input-group.tsx +168 -0
  152. package/src/ui/components/primitives/input-otp.tsx +75 -0
  153. package/src/ui/components/primitives/input.tsx +72 -0
  154. package/src/ui/components/primitives/item.tsx +193 -0
  155. package/src/ui/components/primitives/kbd.tsx +28 -0
  156. package/src/ui/components/primitives/label.tsx +22 -0
  157. package/src/ui/components/primitives/markdown.tsx +35 -0
  158. package/src/ui/components/primitives/menu.tsx +255 -0
  159. package/src/ui/components/primitives/pagination.tsx +127 -0
  160. package/src/ui/components/primitives/popover.tsx +87 -0
  161. package/src/ui/components/primitives/progress.tsx +29 -0
  162. package/src/ui/components/primitives/radio-group.tsx +43 -0
  163. package/src/ui/components/primitives/resizable.tsx +51 -0
  164. package/src/ui/components/primitives/scroll-area.tsx +56 -0
  165. package/src/ui/components/primitives/select.tsx +479 -0
  166. package/src/ui/components/primitives/separator.tsx +26 -0
  167. package/src/ui/components/primitives/skeleton.tsx +13 -0
  168. package/src/ui/components/primitives/slider.tsx +61 -0
  169. package/src/ui/components/primitives/sonner.tsx +46 -0
  170. package/src/ui/components/primitives/spinner.tsx +29 -0
  171. package/src/ui/components/primitives/switch.tsx +33 -0
  172. package/src/ui/components/primitives/table.tsx +114 -0
  173. package/src/ui/components/primitives/tabs.tsx +104 -0
  174. package/src/ui/components/primitives/textarea.tsx +18 -0
  175. package/src/ui/components/primitives/toggle-group.tsx +81 -0
  176. package/src/ui/components/primitives/toggle.tsx +45 -0
  177. package/src/ui/components/primitives/tooltip.tsx +55 -0
  178. package/src/ui/components/primitives/truncate.tsx +49 -0
  179. package/src/ui/docs/DocBrowser.tsx +90 -0
  180. package/src/ui/docs/changelog.tsx +80 -0
  181. package/src/ui/docs/content/accordion.md +86 -0
  182. package/src/ui/docs/content/action-form-card.md +24 -0
  183. package/src/ui/docs/content/action-form-dialog.md +30 -0
  184. package/src/ui/docs/content/action-form.md +125 -0
  185. package/src/ui/docs/content/action-list-dialog.md +68 -0
  186. package/src/ui/docs/content/action-list.md +194 -0
  187. package/src/ui/docs/content/action-trigger.md +72 -0
  188. package/src/ui/docs/content/action-view.md +47 -0
  189. package/src/ui/docs/content/actions.md +138 -0
  190. package/src/ui/docs/content/ai.md +112 -0
  191. package/src/ui/docs/content/alert-dialog.md +73 -0
  192. package/src/ui/docs/content/alert.md +69 -0
  193. package/src/ui/docs/content/app-shell.md +155 -0
  194. package/src/ui/docs/content/aspect-ratio.md +66 -0
  195. package/src/ui/docs/content/audit.md +84 -0
  196. package/src/ui/docs/content/auth.md +70 -0
  197. package/src/ui/docs/content/avatar.md +94 -0
  198. package/src/ui/docs/content/badge.md +48 -0
  199. package/src/ui/docs/content/breadcrumb.md +87 -0
  200. package/src/ui/docs/content/button-group.md +71 -0
  201. package/src/ui/docs/content/button.md +60 -0
  202. package/src/ui/docs/content/calendar.md +62 -0
  203. package/src/ui/docs/content/card.md +49 -0
  204. package/src/ui/docs/content/carousel.md +85 -0
  205. package/src/ui/docs/content/chat.md +69 -0
  206. package/src/ui/docs/content/checkbox.md +75 -0
  207. package/src/ui/docs/content/cli.md +58 -0
  208. package/src/ui/docs/content/collapsible.md +64 -0
  209. package/src/ui/docs/content/command.md +56 -0
  210. package/src/ui/docs/content/composer.md +50 -0
  211. package/src/ui/docs/content/confirm.md +120 -0
  212. package/src/ui/docs/content/copyable.md +30 -0
  213. package/src/ui/docs/content/customization.md +110 -0
  214. package/src/ui/docs/content/cycle.md +34 -0
  215. package/src/ui/docs/content/data-state.md +47 -0
  216. package/src/ui/docs/content/data.md +99 -0
  217. package/src/ui/docs/content/dialog.md +60 -0
  218. package/src/ui/docs/content/drawer.md +55 -0
  219. package/src/ui/docs/content/empty.md +66 -0
  220. package/src/ui/docs/content/events.md +61 -0
  221. package/src/ui/docs/content/field.md +58 -0
  222. package/src/ui/docs/content/getting-started.md +109 -0
  223. package/src/ui/docs/content/icon-picker.md +51 -0
  224. package/src/ui/docs/content/input-group.md +78 -0
  225. package/src/ui/docs/content/input-otp.md +72 -0
  226. package/src/ui/docs/content/input.md +78 -0
  227. package/src/ui/docs/content/item.md +84 -0
  228. package/src/ui/docs/content/kbd.md +62 -0
  229. package/src/ui/docs/content/label.md +32 -0
  230. package/src/ui/docs/content/log.md +55 -0
  231. package/src/ui/docs/content/markdown.md +41 -0
  232. package/src/ui/docs/content/mcp.md +44 -0
  233. package/src/ui/docs/content/menu.md +114 -0
  234. package/src/ui/docs/content/microcopy.md +83 -0
  235. package/src/ui/docs/content/page.md +34 -0
  236. package/src/ui/docs/content/pagination.md +99 -0
  237. package/src/ui/docs/content/popover.md +49 -0
  238. package/src/ui/docs/content/progress.md +69 -0
  239. package/src/ui/docs/content/queue.md +62 -0
  240. package/src/ui/docs/content/radio-group.md +77 -0
  241. package/src/ui/docs/content/resizable.md +86 -0
  242. package/src/ui/docs/content/router.md +56 -0
  243. package/src/ui/docs/content/runtime.md +77 -0
  244. package/src/ui/docs/content/scheduler.md +66 -0
  245. package/src/ui/docs/content/scroll-area.md +89 -0
  246. package/src/ui/docs/content/section-shell.md +121 -0
  247. package/src/ui/docs/content/select.md +342 -0
  248. package/src/ui/docs/content/separator.md +33 -0
  249. package/src/ui/docs/content/sidebar.md +38 -0
  250. package/src/ui/docs/content/skeleton.md +34 -0
  251. package/src/ui/docs/content/slider.md +64 -0
  252. package/src/ui/docs/content/spinner.md +37 -0
  253. package/src/ui/docs/content/split.md +33 -0
  254. package/src/ui/docs/content/storage.md +69 -0
  255. package/src/ui/docs/content/switch.md +69 -0
  256. package/src/ui/docs/content/table.md +102 -0
  257. package/src/ui/docs/content/tabs.md +94 -0
  258. package/src/ui/docs/content/testing.md +89 -0
  259. package/src/ui/docs/content/textarea.md +30 -0
  260. package/src/ui/docs/content/toast.md +67 -0
  261. package/src/ui/docs/content/toggle-group.md +81 -0
  262. package/src/ui/docs/content/toggle.md +72 -0
  263. package/src/ui/docs/content/tokens.md +171 -0
  264. package/src/ui/docs/content/tooltip.md +50 -0
  265. package/src/ui/docs/content/truncate.md +37 -0
  266. package/src/ui/docs/content/ui.md +40 -0
  267. package/src/ui/docs/content/upgrading.md +48 -0
  268. package/src/ui/docs/doc-client.tsx +214 -0
  269. package/src/ui/docs/doc.tsx +301 -0
  270. package/src/ui/docs/folder.tsx +149 -0
  271. package/src/ui/docs/index.ts +21 -0
  272. package/src/ui/docs/markdown.tsx +130 -0
  273. package/src/ui/docs/md-raw.d.ts +4 -0
  274. package/src/ui/docs/plugin.ts +104 -0
  275. package/src/ui/docs/registry.tsx +424 -0
  276. package/src/ui/docs/standalone.tsx +107 -0
  277. package/src/ui/drivers/react.tsx +627 -0
  278. package/src/ui/index.ts +92 -0
  279. package/src/ui/lib/cn.ts +10 -0
  280. package/src/ui/lib/zod-pt-br.ts +38 -0
  281. package/src/ui/meta.ts +412 -0
  282. package/src/ui/react.tsx +235 -0
  283. package/src/ui/router.ts +96 -0
  284. package/src/ui/theme.css +234 -0
  285. package/src/vite/design.ts +652 -0
  286. package/src/vite/index.ts +8 -0
@@ -0,0 +1,46 @@
1
+ /**
2
+ * <Page /> — o esqueleto de página do back-office.
3
+ *
4
+ * Padroniza o que toda página repete: <main> + container de largura CHEIA (o caso
5
+ * comum do back-office; quem quiser estreito passa `className="max-w-5xl"`) + header
6
+ * (título, descrição e a ação à direita — em geral o botão de criar). O conteúdo é
7
+ * children, sem nada imposto. Presentacional (sem contrato) de propósito: uma página
8
+ * agrega N fontes; o que é action-driven mora dentro (ActionList, listas query-backed).
9
+ */
10
+
11
+ import type { ReactNode } from 'react'
12
+ import { cn } from '../../lib/cn.ts'
13
+
14
+ export interface PageProps {
15
+ title: string
16
+ /** Total de itens ao lado do título (o estilo do Maestro: mono, esmaecido). */
17
+ count?: number
18
+ /** Linha de contexto sob o título. */
19
+ description?: ReactNode
20
+ /** Ação à direita do header — em geral o botão de criar. */
21
+ actions?: ReactNode
22
+ /** Classes do container. Default: largura cheia; `max-w-5xl` estreita e centra. */
23
+ className?: string
24
+ children: ReactNode
25
+ }
26
+
27
+ export function Page({ title, count, description, actions, className, children }: PageProps): React.ReactElement {
28
+ return (
29
+ <main data-slot="page" className="min-w-0 flex-1">
30
+ <div className={cn('mx-auto px-8 py-8', className)}>
31
+ {/* As ações alinham com o TÍTULO; a descrição vem abaixo, em linha própria. */}
32
+ <div data-slot="page-header" className="mb-6">
33
+ <div className={cn(actions !== undefined && 'flex items-center justify-between gap-4')}>
34
+ <h1 className="flex items-baseline gap-2 text-2xl font-semibold tracking-tight">
35
+ {title}
36
+ {count !== undefined && <span className="font-mono text-base text-muted-foreground/60">{count}</span>}
37
+ </h1>
38
+ {actions}
39
+ </div>
40
+ {description !== undefined && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
41
+ </div>
42
+ {children}
43
+ </div>
44
+ </main>
45
+ )
46
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * <SectionShell /> — o terceiro nível do chrome: uma SEÇÃO com navegação própria.
3
+ *
4
+ * O vocabulário de layout tinha dois níveis (AppShell → Page) e faltava o do meio:
5
+ * seção que agrega N telas irmãs (Configurações, Relatórios, a doc) e quer a lista
6
+ * DENTRO do conteúdo, não pendurada na sidebar do app. Sem isso, cada seção
7
+ * redesenhava o mesmo casco `nav + painel` à mão — o DocBrowser, o site do Opus e o
8
+ * back-office da Empresa X chegaram nas mesmas classes por três caminhos.
9
+ *
10
+ * A nav é `w-56` de propósito: mais estreita que o `w-64` do AppShell, pra hierarquia
11
+ * ficar legível quando as duas aparecem lado a lado.
12
+ *
13
+ * Presentacional e CONTROLADO — mesma regra do AppShell: o shell é o quadro. Quem
14
+ * escolhe o item ativo, o que fazer no clique e o que renderizar no painel é o
15
+ * consumidor; roteamento (path, querystring, estado) fica de fora por decisão.
16
+ */
17
+
18
+ import type { ReactNode } from 'react'
19
+ import { cn } from '../../lib/cn.ts'
20
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
21
+
22
+ export interface SectionNavItem {
23
+ /** Coordenada estável do item — é o que volta em `onSelect` e casa com `activeId`.
24
+ * Único em TODA a nav (não só no grupo): `activeId` casa por id, então id repetido
25
+ * marca dois itens como atuais e o painel não remonta ao alternar entre eles. */
26
+ id: string
27
+ label: string
28
+ /** Ícone à esquerda do label. */
29
+ icon?: ReactNode
30
+ /** Selo curto à direita (contagem, `beta`, subpath do SDK…). */
31
+ badge?: ReactNode
32
+ /** Visível mas inerte — a tela existe no mapa e ainda não abre. */
33
+ disabled?: boolean
34
+ }
35
+
36
+ /** Sub-rótulo DENTRO de um grupo (o 3º nível: sub-pasta na doc, família num catálogo).
37
+ * Sai mais fraco e mais indentado que o rótulo do grupo. */
38
+ export interface SectionNavSubgroup {
39
+ label?: string
40
+ items: SectionNavItem[]
41
+ }
42
+
43
+ export interface SectionNavGroup {
44
+ /** Cabeçalho do grupo. Ausente (ou vazio) = itens soltos, sem rótulo acima. */
45
+ label?: string
46
+ /** Itens direto no grupo. */
47
+ items?: SectionNavItem[]
48
+ /** Subgrupos rotulados, DEPOIS dos `items` soltos. A maioria das seções não precisa
49
+ * — é o nível que a doc usa quando a pasta tem sub-pasta. */
50
+ subgroups?: SectionNavSubgroup[]
51
+ }
52
+
53
+ export interface SectionShellProps {
54
+ /** A navegação da seção: grupos (rotulados ou não) → itens. */
55
+ groups: SectionNavGroup[]
56
+ /** `id` do item ativo. Ausente = nenhum destacado (estado vazio, a cargo do consumidor). */
57
+ activeId?: string
58
+ onSelect: (id: string) => void
59
+ /** Topo da nav — título da seção, busca, botão de criar. */
60
+ navHeader?: ReactNode
61
+ /** Rodapé da nav, ancorado embaixo. */
62
+ navFooter?: ReactNode
63
+ /** Classes da raiz. Default: ocupa a altura disponível (`h-full`). */
64
+ className?: string
65
+ /** Classes da COLUNA da nav (o wrapper que empilha header + lista + rodapé), não da
66
+ * `<nav>` de dentro. Default: `w-56 shrink-0 border-r`. */
67
+ navClassName?: string
68
+ /** Rótulo da landmark `<nav>` — o que o leitor de tela anuncia. Só importa de verdade
69
+ * quando o app já tem outra nav na tela (a da sidebar): sem rótulo, as duas viram
70
+ * "navigation" e não se distinguem. */
71
+ navLabel?: string
72
+ /** O que remonta o painel — e remontar é o que zera o scroll. Default: `activeId`
73
+ * (trocar de tela volta ao topo, o esperado). Dois usos legítimos de sobrescrever:
74
+ * uma chave CONSTANTE pra NUNCA remontar (a seção preserva o scroll ao trocar de
75
+ * item), ou uma chave MAIS FINA que o `activeId` pra remontar também dentro da mesma
76
+ * tela (ex.: `${activeId}/${recordId}` numa sub-rota que troca de registro). */
77
+ scrollResetKey?: string
78
+ /** Nav REDIMENSIONÁVEL pelo usuário (arrastar a divisa), como o `rail` do AppShell.
79
+ * Opt-in: sem isto a nav é a coluna fixa `w-56` de sempre. Ligado, nav e painel viram
80
+ * painéis (Resizable) e a largura passa a ser % — o `navClassName` de largura deixa
81
+ * de valer (quem manda é `navDefaultSize`). */
82
+ resizable?: boolean
83
+ /** Largura inicial (%) da nav quando `resizable`. Default: 18 (≈ o w-56 num 1280). */
84
+ navDefaultSize?: number
85
+ /** Largura mínima (%) da nav quando `resizable`. Default: 12. */
86
+ navMinSize?: number
87
+ /** O painel: a tela do item ativo (em geral uma `<Page>`), ou o estado vazio. */
88
+ children: ReactNode
89
+ }
90
+
91
+ /** Rótulo só existe se tiver texto: vazio (ou só espaço) não vira cabeçalho fantasma —
92
+ * a normalização mora AQUI pra não se repetir em cada consumidor. */
93
+ const titled = (label?: string): boolean => (label ?? '').trim() !== ''
94
+
95
+ /** Idem pro selo: vazio viraria uma pílula com borda e sem texto. `false` é o caso que
96
+ * mais aparece — `badge={isBeta && 'beta'}` é idioma corrente. `0` CONTA (contagem
97
+ * legítima), então truthy não serve. */
98
+ const shown = (badge: ReactNode): boolean => {
99
+ if (badge === undefined || badge === null || badge === false || badge === '') return false
100
+ if (typeof badge === 'number' && Number.isNaN(badge)) return false
101
+ return !(Array.isArray(badge) && badge.length === 0)
102
+ }
103
+
104
+ /** Subgrupo só conta se tiver item: `items: []` (uma busca sem match, p.ex.) sairia
105
+ * como sub-cabeçalho órfão. */
106
+ const subFilled = (sub: SectionNavSubgroup): boolean => sub.items.length > 0
107
+
108
+ /** Grupo sem NADA dentro (nem itens, nem subgrupo COM item) não vira `<div>` vazio: ele
109
+ * consumiria um degrau do `space-y-5` do pai e abriria um buraco na nav — ou pior,
110
+ * deixaria o rótulo do grupo pairando sobre nada. */
111
+ const filled = (group: SectionNavGroup): boolean =>
112
+ (group.items?.length ?? 0) > 0 || (group.subgroups?.some(subFilled) ?? false)
113
+
114
+ export function SectionShell({
115
+ groups,
116
+ activeId,
117
+ onSelect,
118
+ navHeader,
119
+ navFooter,
120
+ className,
121
+ navClassName,
122
+ navLabel = 'Navegação da seção',
123
+ scrollResetKey,
124
+ resizable = false,
125
+ navDefaultSize = 18,
126
+ navMinSize = 12,
127
+ children,
128
+ }: SectionShellProps): React.ReactElement {
129
+ // `nested`: item de subgrupo ROTULADO — recua sob o rótulo da categoria, senão os três
130
+ // níveis (seção → categoria → página) sairiam todos na mesma margem.
131
+ const renderItem = (item: SectionNavItem, nested = false): React.ReactElement => {
132
+ const active = item.id === activeId
133
+ return (
134
+ <button
135
+ key={item.id}
136
+ type="button"
137
+ disabled={item.disabled}
138
+ // O destaque do ativo é visual; `aria-current` é o que o leitor de tela ouve —
139
+ // sem ele a nav é uma lista de botões indistintos.
140
+ aria-current={active ? 'page' : undefined}
141
+ onClick={() => onSelect(item.id)}
142
+ className={cn(
143
+ 'flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left text-sm transition-colors',
144
+ nested ? 'pl-6' : 'pl-4',
145
+ 'disabled:pointer-events-none disabled:opacity-40',
146
+ active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60',
147
+ )}
148
+ >
149
+ {item.icon}
150
+ <span className="min-w-0 flex-1 truncate">{item.label}</span>
151
+ {shown(item.badge) && (
152
+ <span className="shrink-0 rounded border border-border/60 px-1 font-mono text-[10px] leading-tight text-muted-foreground/60">
153
+ {item.badge}
154
+ </span>
155
+ )}
156
+ </button>
157
+ )
158
+ }
159
+
160
+ // `div`, não `aside`: isto só empilha header/nav/rodapé. `aside` abriria uma landmark
161
+ // `complementary` anônima ao lado da `navigation` — ruído pro leitor de tela, já que a
162
+ // nav é quem carrega o sentido. Com `resizable`, a largura vem do painel (%), então a
163
+ // coluna perde o `w-56 shrink-0` e ocupa o painel inteiro.
164
+ const nav = (
165
+ <div
166
+ data-slot="section-shell-nav"
167
+ className={cn(
168
+ 'flex flex-col border-r border-border',
169
+ resizable ? 'h-full min-w-0' : 'w-56 shrink-0',
170
+ navClassName,
171
+ )}
172
+ >
173
+ {navHeader}
174
+ <nav aria-label={navLabel} className="min-h-0 flex-1 space-y-5 overflow-y-auto px-3 py-5">
175
+ {/* Chave = POSIÇÃO. Rótulo não serve: ele é opcional e repetível (o catálogo
176
+ da doc tem DUAS seções com label vazio), e chave duplicada é reconciliação
177
+ indefinida. Aqui posição é estável e o conteúdo não guarda estado — os
178
+ botões têm chave própria (o id do item). */}
179
+ {groups.map((group, gi) =>
180
+ !filled(group) ? null : (
181
+ <div key={gi} className="space-y-0.5">
182
+ {titled(group.label) && (
183
+ <div className="px-2.5 pb-1 text-xs font-semibold tracking-tight text-foreground/80">
184
+ {group.label}
185
+ </div>
186
+ )}
187
+ {group.items?.map((item) => renderItem(item))}
188
+ {group.subgroups?.map((sub, si) =>
189
+ !subFilled(sub) ? null : (
190
+ <div key={si} className="space-y-0.5">
191
+ {titled(sub.label) && (
192
+ <div className="pb-0.5 pl-4 pr-2.5 pt-1.5 text-[11px] font-medium text-muted-foreground/50">
193
+ {sub.label}
194
+ </div>
195
+ )}
196
+ {sub.items.map((item) => renderItem(item, titled(sub.label)))}
197
+ </div>
198
+ ),
199
+ )}
200
+ </div>
201
+ ),
202
+ )}
203
+ </nav>
204
+ {navFooter}
205
+ </div>
206
+ )
207
+
208
+ // key: trocar de tela remonta o painel — é o que zera o scroll (senão a tela nova abre
209
+ // na altura em que a anterior estava).
210
+ const main = (
211
+ <div
212
+ key={scrollResetKey ?? activeId}
213
+ data-slot="section-shell-main"
214
+ className={cn('min-h-0 overflow-y-auto', resizable ? 'h-full' : 'flex-1')}
215
+ >
216
+ {children}
217
+ </div>
218
+ )
219
+
220
+ // Redimensionável: nav e painel viram painéis com divisa arrastável — o mesmo
221
+ // mecanismo do `rail` do AppShell, agora no terceiro nível do chrome.
222
+ if (resizable) {
223
+ return (
224
+ <ResizablePanelGroup
225
+ orientation="horizontal"
226
+ data-slot="section-shell"
227
+ className={cn('h-full min-h-0', className)}
228
+ >
229
+ <ResizablePanel defaultSize={navDefaultSize} minSize={navMinSize}>
230
+ {nav}
231
+ </ResizablePanel>
232
+ <ResizableHandle />
233
+ <ResizablePanel defaultSize={100 - navDefaultSize} minSize={30}>
234
+ {main}
235
+ </ResizablePanel>
236
+ </ResizablePanelGroup>
237
+ )
238
+ }
239
+
240
+ return (
241
+ <div data-slot="section-shell" className={cn('flex h-full min-h-0', className)}>
242
+ {nav}
243
+ {main}
244
+ </div>
245
+ )
246
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * <ShellNav /> — o menu como primitivo PIVOTÁVEL. Um nav (grupos → itens, com heading e
3
+ * âncora inferior) que serve os DOIS lares do chrome sem flag:
4
+ *
5
+ * - na sidebar do <AppShell> → recolhe pra ícone-só (com tooltip) quando a sidebar
6
+ * recolhe, sozinho. Sabe que está NA sidebar (e se ela recolheu) pelo contexto de
7
+ * slot do AppShell (`useSidebarSlot`) — o app não passa isso em duas mãos.
8
+ * - dentro do conteúdo (o que o <SectionShell> faz) → sempre expandido (fora da sidebar,
9
+ * `useSidebarSlot` é null).
10
+ *
11
+ * "Tanto faz onde": o componente se adapta ao lugar, sem "modo" configurado.
12
+ *
13
+ * Presentacional e CONTROLADO — o app é dono do `activeId`, do clique e do roteamento.
14
+ * v1 PLANA (grupos → itens); aninhamento (accordion/pasta) e DnD COMPOSTO entram quando o
15
+ * primeiro consumidor de árvore migrar — ver `docs/shellnav.md`.
16
+ */
17
+
18
+ import type { ReactNode } from 'react'
19
+ import { cn } from '../../lib/cn.ts'
20
+ import { useSidebarSlot } from './app-shell.tsx'
21
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
22
+
23
+ export interface ShellNavItem {
24
+ /** Coordenada estável — volta em `onSelect` e casa com `activeId`. Único em toda a nav. */
25
+ id: string
26
+ label: string
27
+ /** Ícone à esquerda (é o que sobra no modo recolhido — vira o alvo, com o label no tooltip). */
28
+ icon?: ReactNode
29
+ /** Selo à direita (contagem, `beta`). Some no recolhido. */
30
+ badge?: ReactNode
31
+ /** Visível mas inerte. */
32
+ disabled?: boolean
33
+ }
34
+
35
+ export interface ShellNavGroup {
36
+ /** Cabeçalho do grupo. Ausente/vazio = itens soltos, sem rótulo. Some no recolhido. */
37
+ label?: string
38
+ items: ShellNavItem[]
39
+ }
40
+
41
+ export interface ShellNavProps {
42
+ groups: ShellNavGroup[]
43
+ /** `id` do item ativo. Ausente = nenhum destacado. */
44
+ activeId?: string
45
+ onSelect: (id: string) => void
46
+ /** Topo do nav — título, busca, botão (ex.: o "+" do Relatórios). Some no recolhido. Ver <ShellNavHeading>. */
47
+ heading?: ReactNode
48
+ /** Grupos ANCORADOS embaixo (ex.: Configurações no app), mesmo renderer, separados por filete. */
49
+ footer?: ShellNavGroup[]
50
+ /** Rótulo da landmark `<nav>`. */
51
+ navLabel?: string
52
+ /** Classes da raiz (a coluna). A LARGURA vem do lar (sidebar do AppShell / SectionShell). */
53
+ className?: string
54
+ }
55
+
56
+ /** Rótulo só existe se tiver texto — vazio não vira cabeçalho fantasma. */
57
+ const titled = (label?: string): boolean => (label ?? '').trim() !== ''
58
+
59
+ /** Selo vazio não vira pílula sem texto. `0` conta (contagem legítima). */
60
+ const shown = (badge: ReactNode): boolean => {
61
+ if (badge === undefined || badge === null || badge === false || badge === '') return false
62
+ if (typeof badge === 'number' && Number.isNaN(badge)) return false
63
+ return !(Array.isArray(badge) && badge.length === 0)
64
+ }
65
+
66
+ /** Grupo sem item não abre buraco no `space-y` nem deixa rótulo pairando sobre nada. */
67
+ const filled = (group: ShellNavGroup): boolean => group.items.length > 0
68
+
69
+ function renderItem(item: ShellNavItem, activeId: string | undefined, onSelect: (id: string) => void, railed: boolean): React.ReactElement {
70
+ const active = item.id === activeId
71
+ const button = (
72
+ <button
73
+ type="button"
74
+ disabled={item.disabled}
75
+ aria-current={active ? 'page' : undefined}
76
+ onClick={() => onSelect(item.id)}
77
+ className={cn(
78
+ 'flex w-full items-center gap-2.5 rounded-md py-1.5 text-left text-sm transition-colors',
79
+ 'disabled:pointer-events-none disabled:opacity-40',
80
+ railed ? 'justify-center px-0' : 'px-2.5',
81
+ active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60',
82
+ )}
83
+ >
84
+ {item.icon !== undefined && <span className="flex shrink-0">{item.icon}</span>}
85
+ {!railed && <span className="min-w-0 flex-1 truncate">{item.label}</span>}
86
+ {!railed && shown(item.badge) && <span className="shrink-0">{item.badge}</span>}
87
+ </button>
88
+ )
89
+ if (!railed) return <div key={item.id}>{button}</div>
90
+ // Recolhido: o label migra pro tooltip (à direita) — sem ele, a coluna vira ícones adivinháveis.
91
+ return (
92
+ <Tooltip key={item.id}>
93
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
94
+ <TooltipContent side="right">{item.label}</TooltipContent>
95
+ </Tooltip>
96
+ )
97
+ }
98
+
99
+ function renderGroup(group: ShellNavGroup, gi: number, activeId: string | undefined, onSelect: (id: string) => void, railed: boolean): React.ReactElement | null {
100
+ if (!filled(group)) return null
101
+ return (
102
+ <div key={gi} className="space-y-0.5">
103
+ {!railed && titled(group.label) && (
104
+ <div className="px-2.5 pb-1 text-xs font-medium text-muted-foreground/70">{group.label}</div>
105
+ )}
106
+ {group.items.map((item) => renderItem(item, activeId, onSelect, railed))}
107
+ </div>
108
+ )
109
+ }
110
+
111
+ export function ShellNav({ groups, activeId, onSelect, heading, footer, navLabel = 'Navegação', className }: ShellNavProps): React.ReactElement {
112
+ // Recolhe SÓ na sidebar recolhida (o contexto de slot é null no conteúdo → sempre expandido).
113
+ const railed = useSidebarSlot()?.collapsed ?? false
114
+
115
+ const body = (
116
+ <div data-slot="shell-nav" className={cn('flex h-full min-h-0 flex-col', className)}>
117
+ {!railed && heading}
118
+ <nav aria-label={navLabel} className={cn('min-h-0 flex-1 space-y-4 overflow-y-auto p-2', railed && 'px-1.5')}>
119
+ {groups.map((group, gi) => renderGroup(group, gi, activeId, onSelect, railed))}
120
+ </nav>
121
+ {footer !== undefined && footer.some(filled) && (
122
+ <div className={cn('space-y-4 border-t border-border p-2', railed && 'px-1.5')}>
123
+ {footer.map((group, gi) => renderGroup(group, gi, activeId, onSelect, railed))}
124
+ </div>
125
+ )}
126
+ </div>
127
+ )
128
+
129
+ // Recolhido usa tooltips — garante um provider (nested é ok se o app já tem um).
130
+ return railed ? <TooltipProvider delayDuration={200}>{body}</TooltipProvider> : body
131
+ }
132
+
133
+ export interface ShellNavHeadingProps {
134
+ /** Título da seção. */
135
+ title: ReactNode
136
+ /** Ação à direita — em geral um botão (o "+" de criar). */
137
+ action?: ReactNode
138
+ className?: string
139
+ }
140
+
141
+ /** Cabeçalho padrão do ShellNav: título + espaço pra um botão. É o gesto que hoje cada
142
+ * nav monta à mão (o "+" do Relatórios, o título de Configurações). */
143
+ export function ShellNavHeading({ title, action, className }: ShellNavHeadingProps): React.ReactElement {
144
+ return (
145
+ <div className={cn('flex h-12 shrink-0 items-center gap-1.5 border-b border-border px-4', className)}>
146
+ <span className="min-w-0 flex-1 truncate text-sm font-semibold">{title}</span>
147
+ {action !== undefined && <span className="shrink-0">{action}</span>}
148
+ </div>
149
+ )
150
+ }
@@ -0,0 +1,89 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import { cn } from '../../lib/cn.ts'
3
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
4
+
5
+ interface SidebarContextValue { collapsed: boolean }
6
+ const SidebarContext = createContext<SidebarContextValue | null>(null)
7
+
8
+ export interface SidebarProps {
9
+ collapsed?: boolean
10
+ /** Desliga a borda quando o Split já desenha a divisória. */
11
+ divider?: boolean
12
+ className?: string
13
+ children: ReactNode
14
+ }
15
+
16
+ /** Moldura composicional de uma barra lateral; o Split decide a posição, Sidebar o chrome. */
17
+ export function Sidebar({ collapsed = false, divider = true, className, children }: SidebarProps): React.ReactElement {
18
+ return (
19
+ <SidebarContext.Provider value={{ collapsed }}>
20
+ <aside data-slot="sidebar" data-collapsed={String(collapsed)} className={cn('group/sidebar flex h-full min-h-0 shrink-0 flex-col transition-[width] duration-200 ease-out', divider && 'border-r border-border', collapsed ? 'w-14' : 'w-64', className)}>
21
+ {children}
22
+ </aside>
23
+ </SidebarContext.Provider>
24
+ )
25
+ }
26
+
27
+ /** Faixa fixa no topo de um pane — marca, contexto ou ações locais. */
28
+ export function PaneHeader({ className, children }: { className?: string; children: ReactNode }): React.ReactElement {
29
+ return <div data-slot="pane-header" className={cn('shrink-0 border-b border-border', className)}>{children}</div>
30
+ }
31
+
32
+ /** Região rolável que ocupa o espaço restante de um pane. */
33
+ export function PaneContent({ className, children }: { className?: string; children: ReactNode }): React.ReactElement {
34
+ return <div data-slot="pane-content" className={cn('min-h-0 flex-1 overflow-y-auto', className)}>{children}</div>
35
+ }
36
+
37
+ /** Faixa fixa no rodapé de um pane para ações persistentes. */
38
+ export function PaneFooter({ className, children }: { className?: string; children: ReactNode }): React.ReactElement {
39
+ return <div data-slot="pane-footer" className={cn('shrink-0 border-t border-border p-2', className)}>{children}</div>
40
+ }
41
+
42
+ /** @deprecated Use PaneHeader. */
43
+ export const SidebarHeader = PaneHeader
44
+ /** @deprecated Use PaneContent. */
45
+ export const SidebarContent = PaneContent
46
+ /** @deprecated Use PaneFooter. */
47
+ export const SidebarFooter = PaneFooter
48
+
49
+ export interface SidebarItemProps {
50
+ label: string
51
+ icon?: ReactNode
52
+ badge?: ReactNode
53
+ active?: boolean
54
+ disabled?: boolean
55
+ onClick: () => void
56
+ }
57
+
58
+ /** Item de navegação que se adapta ao colapso da Sidebar em que está. */
59
+ export function SidebarItem({ label, icon, badge, active = false, disabled = false, onClick }: SidebarItemProps): React.ReactElement {
60
+ const collapsed = useContext(SidebarContext)?.collapsed ?? false
61
+ const button = (
62
+ <button type="button" disabled={disabled} aria-current={active ? 'page' : undefined} onClick={onClick} className={cn('flex items-center gap-2.5 rounded-md transition-colors', 'disabled:pointer-events-none disabled:opacity-40', collapsed ? 'mx-auto size-9 justify-center p-0' : 'w-full px-2.5 py-1.5 text-left text-sm', active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60')}>
63
+ {icon !== undefined && <span className="flex shrink-0">{icon}</span>}
64
+ {!collapsed && <span className="min-w-0 flex-1 truncate">{label}</span>}
65
+ {!collapsed && badge !== undefined && <span className="shrink-0">{badge}</span>}
66
+ </button>
67
+ )
68
+ if (!collapsed) return button
69
+ return <Tooltip><TooltipTrigger asChild>{button}</TooltipTrigger><TooltipContent side="right">{label}</TooltipContent></Tooltip>
70
+ }
71
+
72
+ export interface SidebarNavGroup {
73
+ label?: string
74
+ items: Array<Omit<SidebarItemProps, 'active' | 'onClick'> & { id: string }>
75
+ }
76
+
77
+ /** Navegação controlada para Sidebar. Header/footer continuam slots explícitos do pai. */
78
+ export function SidebarNav({ groups, activeId, onSelect, navLabel = 'Navegação', className }: { groups: SidebarNavGroup[]; activeId?: string; onSelect: (id: string) => void; navLabel?: string; className?: string }): React.ReactElement {
79
+ const collapsed = useContext(SidebarContext)?.collapsed ?? false
80
+ const body = (
81
+ <nav aria-label={navLabel} data-slot="sidebar-nav" className={cn('space-y-4 p-2', className)}>
82
+ {groups.map((group, gi) => <div key={gi} className="space-y-0.5">
83
+ {!collapsed && group.label?.trim() && <div className="px-2.5 pb-1 text-xs font-medium text-muted-foreground/70">{group.label}</div>}
84
+ {group.items.map(({ id, ...item }) => <SidebarItem key={id} {...item} active={id === activeId} onClick={() => onSelect(id)} />)}
85
+ </div>)}
86
+ </nav>
87
+ )
88
+ return collapsed ? <TooltipProvider delayDuration={200}>{body}</TooltipProvider> : body
89
+ }
@@ -0,0 +1,93 @@
1
+ import { Children, isValidElement, type CSSProperties, type ReactElement, type ReactNode } from 'react'
2
+ import { cn } from '../../lib/cn.ts'
3
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
4
+
5
+ export interface PaneProps {
6
+ /** Fração inicial do split, em %. O pane que cresce recebe o restante. */
7
+ initialSize?: number
8
+ /** Fração mínima, em %, quando o split é redimensionável. */
9
+ minSize?: number
10
+ /** Fração máxima, em %, quando o split é redimensionável. */
11
+ maxSize?: number
12
+ /** Ocupa o espaço remanescente no layout simples. */
13
+ grow?: boolean
14
+ /** Respiro interno padrão. Use `none` para chrome, navegação ou conteúdo com inset próprio. */
15
+ inset?: 'none' | 'sm' | 'md' | 'lg'
16
+ className?: string
17
+ children: ReactNode
18
+ }
19
+
20
+ /** Um conteúdo encaixável. Sua posição é dada pela ordem dentro de <Split>. */
21
+ export function Pane({ initialSize, grow = false, inset = 'md', className, children }: PaneProps): React.ReactElement {
22
+ const style: CSSProperties | undefined = initialSize === undefined ? undefined : { flexBasis: `${initialSize}%` }
23
+ const insets = { none: '', sm: 'p-2', md: 'p-4', lg: 'p-6' } as const
24
+ return (
25
+ <div data-slot="pane" data-inset={inset} className={cn('min-h-0 min-w-0 shrink-0', grow && 'flex-1', insets[inset], className)} style={style}>
26
+ {children}
27
+ </div>
28
+ )
29
+ }
30
+
31
+ export interface SplitProps {
32
+ direction?: 'horizontal' | 'vertical'
33
+ /** Quando ligado, cada fronteira ganha um handle e os tamanhos passam a ser percentuais. */
34
+ resizable?: boolean
35
+ handle?: boolean
36
+ className?: string
37
+ children: ReactNode
38
+ }
39
+
40
+ function panesOf(children: ReactNode): ReactElement<PaneProps>[] {
41
+ return Children.toArray(children).filter(
42
+ (child): child is ReactElement<PaneProps> => isValidElement<PaneProps>(child) && child.type === Pane,
43
+ )
44
+ }
45
+
46
+ // react-resizable-panels v4 interpreta `number` como pixels. O contrato de Split/Pane
47
+ // é percentual (e o layout flex simples abaixo também usa `%`), então a unidade precisa
48
+ // ser explícita na fronteira com o primitive.
49
+ function percent(value: number | undefined): string | undefined {
50
+ return value === undefined ? undefined : `${value}%`
51
+ }
52
+
53
+ /**
54
+ * Relação espacial mínima: uma sequência de panes, horizontal ou vertical. Usa flex
55
+ * quando fixa e o primitive Resizable quando `resizable`; quem consome não troca de
56
+ * modelo de layout para ganhar arraste.
57
+ */
58
+ export function Split({ direction = 'horizontal', resizable = false, handle = false, className, children }: SplitProps): React.ReactElement {
59
+ const panes = panesOf(children)
60
+ const vertical = direction === 'vertical'
61
+
62
+ if (!resizable) {
63
+ return (
64
+ <div data-slot="split" data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
65
+ {panes}
66
+ </div>
67
+ )
68
+ }
69
+
70
+ const fixed = panes.reduce((sum, pane) => sum + (pane.props.initialSize ?? 0), 0)
71
+ const growing = panes.filter((pane) => pane.props.grow)
72
+ const rest = Math.max(0, 100 - fixed)
73
+ const fallback = panes.length === 0 ? 100 : 100 / panes.length
74
+
75
+ return (
76
+ <ResizablePanelGroup orientation={direction} data-slot="split" className={cn('min-h-0 min-w-0 flex-1', className)}>
77
+ {panes.flatMap((pane, index) => {
78
+ const size = pane.props.initialSize ?? (pane.props.grow ? rest / Math.max(1, growing.length) : fallback)
79
+ const panel = (
80
+ <ResizablePanel
81
+ key={pane.key ?? index}
82
+ defaultSize={percent(size)}
83
+ minSize={percent(pane.props.minSize)}
84
+ maxSize={percent(pane.props.maxSize)}
85
+ >
86
+ <Pane {...pane.props} className={cn('h-full w-full', pane.props.className)} />
87
+ </ResizablePanel>
88
+ )
89
+ return index === 0 ? [panel] : [<ResizableHandle key={`handle-${pane.key ?? index}`} withHandle={handle} />, panel]
90
+ })}
91
+ </ResizablePanelGroup>
92
+ )
93
+ }