@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.
- package/CHANGELOG.md +1616 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/cli.mjs +528 -0
- package/bin/lib/check.mjs +307 -0
- package/bin/lib/components.mjs +151 -0
- package/bin/lib/create.mjs +208 -0
- package/bin/lib/db-check-runner.mjs +86 -0
- package/bin/lib/db-migrate-runner.mjs +89 -0
- package/bin/lib/db-scaffold-runner.mjs +84 -0
- package/bin/lib/db.mjs +261 -0
- package/bin/lib/docs-include.mjs +48 -0
- package/bin/lib/gen-dicts.mjs +134 -0
- package/bin/lib/gen-docs.mjs +288 -0
- package/bin/lib/gen-manifest.mjs +102 -0
- package/bin/lib/gen-openapi.mjs +195 -0
- package/bin/lib/gen-runner.mjs +472 -0
- package/bin/lib/gen-stubs.mjs +463 -0
- package/bin/lib/gen.mjs +311 -0
- package/bin/lib/init.mjs +514 -0
- package/bin/lib/introspect.mjs +107 -0
- package/bin/lib/mcp.mjs +85 -0
- package/bin/lib/postinstall.mjs +56 -0
- package/docs/chat-event-protocol.md +85 -0
- package/docs/code-style.md +16 -0
- package/docs/data-layer.md +246 -0
- package/docs/ownership-vs-shadcn-lock.md +102 -0
- package/docs/protocol.md +2053 -0
- package/docs/releasing.md +110 -0
- package/docs/shellnav.md +131 -0
- package/package.json +338 -0
- package/registry/hooks/hooks.json +26 -0
- package/registry/hooks/link-memory-on-start.mjs +46 -0
- package/registry/hooks/opus-check-on-stop.mjs +114 -0
- package/registry/skills/create-action/SKILL.md +49 -0
- package/registry/skills/create-action/scaffold.mjs +122 -0
- package/registry/templates/app/_gitignore +3 -0
- package/registry/templates/app/_npmrc +1 -0
- package/registry/templates/app/_opus/_gitignore +5 -0
- package/registry/templates/app/_prettierrc.json +6 -0
- package/registry/templates/app/index.html +13 -0
- package/registry/templates/app/opus.config.ts +16 -0
- package/registry/templates/app/package.json +43 -0
- package/registry/templates/app/pnpm-workspace.yaml +11 -0
- package/registry/templates/app/public/favicon.svg +4 -0
- package/registry/templates/app/src/App.tsx +37 -0
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
- package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
- package/registry/templates/app/src/domains/tasks/index.ts +13 -0
- package/registry/templates/app/src/index.css +18 -0
- package/registry/templates/app/src/main.tsx +25 -0
- package/registry/templates/app/tsconfig.json +20 -0
- package/registry/templates/app/vite.config.ts +46 -0
- package/registry/templates/monorepo/_gitignore +3 -0
- package/registry/templates/monorepo/_npmrc +1 -0
- package/registry/templates/monorepo/package.json +9 -0
- package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
- package/src/ai/ask.ts +64 -0
- package/src/ai/drivers/anthropic.ts +309 -0
- package/src/ai/index.ts +17 -0
- package/src/audit/drivers/console.ts +117 -0
- package/src/audit/drivers/pg.ts +172 -0
- package/src/audit/index.ts +51 -0
- package/src/auth/drivers/better-auth.ts +103 -0
- package/src/auth/drivers/jwt.ts +188 -0
- package/src/auth/index.ts +9 -0
- package/src/client/drivers/fetch.ts +202 -0
- package/src/client/index.ts +22 -0
- package/src/core/actions.ts +110 -0
- package/src/core/audit.ts +239 -0
- package/src/core/contracts.ts +137 -0
- package/src/core/domain.ts +310 -0
- package/src/core/errors.ts +181 -0
- package/src/core/index.ts +174 -0
- package/src/core/logical-type.ts +31 -0
- package/src/core/reactions.ts +81 -0
- package/src/core/runtime.ts +1167 -0
- package/src/core/schedules.ts +41 -0
- package/src/core/types.ts +1356 -0
- package/src/data/drivers/kysely.ts +389 -0
- package/src/data/index.ts +10 -0
- package/src/data/readonly-pool.ts +160 -0
- package/src/dsl/eval.ts +136 -0
- package/src/dsl/index.ts +29 -0
- package/src/dsl/kysely.ts +230 -0
- package/src/dsl/loads.ts +123 -0
- package/src/dsl/parser.ts +423 -0
- package/src/dsl/types.ts +113 -0
- package/src/events/drivers/mitt.ts +70 -0
- package/src/events/index.ts +9 -0
- package/src/log/drivers/pino.ts +57 -0
- package/src/log/index.ts +9 -0
- package/src/mcp/index.ts +62 -0
- package/src/queue/drivers/bullmq.ts +190 -0
- package/src/queue/index.ts +9 -0
- package/src/scheduler/drivers/node-cron.ts +93 -0
- package/src/scheduler/every.ts +45 -0
- package/src/scheduler/index.ts +9 -0
- package/src/schema/drivers/zod.ts +765 -0
- package/src/schema/entity.ts +439 -0
- package/src/schema/format/locale.ts +144 -0
- package/src/schema/index.ts +65 -0
- package/src/schema/openapi.ts +302 -0
- package/src/schema/scaffold.ts +160 -0
- package/src/server/drivers/fastify.ts +224 -0
- package/src/server/drivers/node.ts +386 -0
- package/src/server/index.ts +142 -0
- package/src/storage/drivers/fs.ts +90 -0
- package/src/storage/drivers/s3.ts +117 -0
- package/src/storage/index.ts +27 -0
- package/src/testing/fake.ts +298 -0
- package/src/testing/index.ts +324 -0
- package/src/ui/components/patterns/action-form-card.tsx +48 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
- package/src/ui/components/patterns/app-shell.tsx +227 -0
- package/src/ui/components/patterns/confirm.tsx +226 -0
- package/src/ui/components/patterns/data-state.tsx +75 -0
- package/src/ui/components/patterns/form-dialog.tsx +64 -0
- package/src/ui/components/patterns/form.tsx +584 -0
- package/src/ui/components/patterns/list.tsx +1488 -0
- package/src/ui/components/patterns/page.tsx +46 -0
- package/src/ui/components/patterns/section-shell.tsx +246 -0
- package/src/ui/components/patterns/shell-nav.tsx +150 -0
- package/src/ui/components/patterns/sidebar.tsx +89 -0
- package/src/ui/components/patterns/split.tsx +93 -0
- package/src/ui/components/patterns/trigger.tsx +196 -0
- package/src/ui/components/patterns/view.tsx +84 -0
- package/src/ui/components/primitives/accordion.tsx +64 -0
- package/src/ui/components/primitives/alert-dialog.tsx +190 -0
- package/src/ui/components/primitives/alert.tsx +116 -0
- package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
- package/src/ui/components/primitives/avatar.tsx +107 -0
- package/src/ui/components/primitives/badge.tsx +37 -0
- package/src/ui/components/primitives/breadcrumb.tsx +109 -0
- package/src/ui/components/primitives/button-group.tsx +83 -0
- package/src/ui/components/primitives/button.tsx +102 -0
- package/src/ui/components/primitives/calendar.tsx +218 -0
- package/src/ui/components/primitives/card.tsx +56 -0
- package/src/ui/components/primitives/carousel.tsx +239 -0
- package/src/ui/components/primitives/chat.tsx +407 -0
- package/src/ui/components/primitives/checkbox.tsx +30 -0
- package/src/ui/components/primitives/collapsible.tsx +31 -0
- package/src/ui/components/primitives/command.tsx +182 -0
- package/src/ui/components/primitives/composer.tsx +121 -0
- package/src/ui/components/primitives/copyable.tsx +50 -0
- package/src/ui/components/primitives/dialog.tsx +147 -0
- package/src/ui/components/primitives/drawer.tsx +141 -0
- package/src/ui/components/primitives/empty.tsx +104 -0
- package/src/ui/components/primitives/field.tsx +246 -0
- package/src/ui/components/primitives/icon-picker.tsx +180 -0
- package/src/ui/components/primitives/input-group.tsx +168 -0
- package/src/ui/components/primitives/input-otp.tsx +75 -0
- package/src/ui/components/primitives/input.tsx +72 -0
- package/src/ui/components/primitives/item.tsx +193 -0
- package/src/ui/components/primitives/kbd.tsx +28 -0
- package/src/ui/components/primitives/label.tsx +22 -0
- package/src/ui/components/primitives/markdown.tsx +35 -0
- package/src/ui/components/primitives/menu.tsx +255 -0
- package/src/ui/components/primitives/pagination.tsx +127 -0
- package/src/ui/components/primitives/popover.tsx +87 -0
- package/src/ui/components/primitives/progress.tsx +29 -0
- package/src/ui/components/primitives/radio-group.tsx +43 -0
- package/src/ui/components/primitives/resizable.tsx +51 -0
- package/src/ui/components/primitives/scroll-area.tsx +56 -0
- package/src/ui/components/primitives/select.tsx +479 -0
- package/src/ui/components/primitives/separator.tsx +26 -0
- package/src/ui/components/primitives/skeleton.tsx +13 -0
- package/src/ui/components/primitives/slider.tsx +61 -0
- package/src/ui/components/primitives/sonner.tsx +46 -0
- package/src/ui/components/primitives/spinner.tsx +29 -0
- package/src/ui/components/primitives/switch.tsx +33 -0
- package/src/ui/components/primitives/table.tsx +114 -0
- package/src/ui/components/primitives/tabs.tsx +104 -0
- package/src/ui/components/primitives/textarea.tsx +18 -0
- package/src/ui/components/primitives/toggle-group.tsx +81 -0
- package/src/ui/components/primitives/toggle.tsx +45 -0
- package/src/ui/components/primitives/tooltip.tsx +55 -0
- package/src/ui/components/primitives/truncate.tsx +49 -0
- package/src/ui/docs/DocBrowser.tsx +90 -0
- package/src/ui/docs/changelog.tsx +80 -0
- package/src/ui/docs/content/accordion.md +86 -0
- package/src/ui/docs/content/action-form-card.md +24 -0
- package/src/ui/docs/content/action-form-dialog.md +30 -0
- package/src/ui/docs/content/action-form.md +125 -0
- package/src/ui/docs/content/action-list-dialog.md +68 -0
- package/src/ui/docs/content/action-list.md +194 -0
- package/src/ui/docs/content/action-trigger.md +72 -0
- package/src/ui/docs/content/action-view.md +47 -0
- package/src/ui/docs/content/actions.md +138 -0
- package/src/ui/docs/content/ai.md +112 -0
- package/src/ui/docs/content/alert-dialog.md +73 -0
- package/src/ui/docs/content/alert.md +69 -0
- package/src/ui/docs/content/app-shell.md +155 -0
- package/src/ui/docs/content/aspect-ratio.md +66 -0
- package/src/ui/docs/content/audit.md +84 -0
- package/src/ui/docs/content/auth.md +70 -0
- package/src/ui/docs/content/avatar.md +94 -0
- package/src/ui/docs/content/badge.md +48 -0
- package/src/ui/docs/content/breadcrumb.md +87 -0
- package/src/ui/docs/content/button-group.md +71 -0
- package/src/ui/docs/content/button.md +60 -0
- package/src/ui/docs/content/calendar.md +62 -0
- package/src/ui/docs/content/card.md +49 -0
- package/src/ui/docs/content/carousel.md +85 -0
- package/src/ui/docs/content/chat.md +69 -0
- package/src/ui/docs/content/checkbox.md +75 -0
- package/src/ui/docs/content/cli.md +58 -0
- package/src/ui/docs/content/collapsible.md +64 -0
- package/src/ui/docs/content/command.md +56 -0
- package/src/ui/docs/content/composer.md +50 -0
- package/src/ui/docs/content/confirm.md +120 -0
- package/src/ui/docs/content/copyable.md +30 -0
- package/src/ui/docs/content/customization.md +110 -0
- package/src/ui/docs/content/cycle.md +34 -0
- package/src/ui/docs/content/data-state.md +47 -0
- package/src/ui/docs/content/data.md +99 -0
- package/src/ui/docs/content/dialog.md +60 -0
- package/src/ui/docs/content/drawer.md +55 -0
- package/src/ui/docs/content/empty.md +66 -0
- package/src/ui/docs/content/events.md +61 -0
- package/src/ui/docs/content/field.md +58 -0
- package/src/ui/docs/content/getting-started.md +109 -0
- package/src/ui/docs/content/icon-picker.md +51 -0
- package/src/ui/docs/content/input-group.md +78 -0
- package/src/ui/docs/content/input-otp.md +72 -0
- package/src/ui/docs/content/input.md +78 -0
- package/src/ui/docs/content/item.md +84 -0
- package/src/ui/docs/content/kbd.md +62 -0
- package/src/ui/docs/content/label.md +32 -0
- package/src/ui/docs/content/log.md +55 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/content/mcp.md +44 -0
- package/src/ui/docs/content/menu.md +114 -0
- package/src/ui/docs/content/microcopy.md +83 -0
- package/src/ui/docs/content/page.md +34 -0
- package/src/ui/docs/content/pagination.md +99 -0
- package/src/ui/docs/content/popover.md +49 -0
- package/src/ui/docs/content/progress.md +69 -0
- package/src/ui/docs/content/queue.md +62 -0
- package/src/ui/docs/content/radio-group.md +77 -0
- package/src/ui/docs/content/resizable.md +86 -0
- package/src/ui/docs/content/router.md +56 -0
- package/src/ui/docs/content/runtime.md +77 -0
- package/src/ui/docs/content/scheduler.md +66 -0
- package/src/ui/docs/content/scroll-area.md +89 -0
- package/src/ui/docs/content/section-shell.md +121 -0
- package/src/ui/docs/content/select.md +342 -0
- package/src/ui/docs/content/separator.md +33 -0
- package/src/ui/docs/content/sidebar.md +38 -0
- package/src/ui/docs/content/skeleton.md +34 -0
- package/src/ui/docs/content/slider.md +64 -0
- package/src/ui/docs/content/spinner.md +37 -0
- package/src/ui/docs/content/split.md +33 -0
- package/src/ui/docs/content/storage.md +69 -0
- package/src/ui/docs/content/switch.md +69 -0
- package/src/ui/docs/content/table.md +102 -0
- package/src/ui/docs/content/tabs.md +94 -0
- package/src/ui/docs/content/testing.md +89 -0
- package/src/ui/docs/content/textarea.md +30 -0
- package/src/ui/docs/content/toast.md +67 -0
- package/src/ui/docs/content/toggle-group.md +81 -0
- package/src/ui/docs/content/toggle.md +72 -0
- package/src/ui/docs/content/tokens.md +171 -0
- package/src/ui/docs/content/tooltip.md +50 -0
- package/src/ui/docs/content/truncate.md +37 -0
- package/src/ui/docs/content/ui.md +40 -0
- package/src/ui/docs/content/upgrading.md +48 -0
- package/src/ui/docs/doc-client.tsx +214 -0
- package/src/ui/docs/doc.tsx +301 -0
- package/src/ui/docs/folder.tsx +149 -0
- package/src/ui/docs/index.ts +21 -0
- package/src/ui/docs/markdown.tsx +130 -0
- package/src/ui/docs/md-raw.d.ts +4 -0
- package/src/ui/docs/plugin.ts +104 -0
- package/src/ui/docs/registry.tsx +424 -0
- package/src/ui/docs/standalone.tsx +107 -0
- package/src/ui/drivers/react.tsx +627 -0
- package/src/ui/index.ts +92 -0
- package/src/ui/lib/cn.ts +10 -0
- package/src/ui/lib/zod-pt-br.ts +38 -0
- package/src/ui/meta.ts +412 -0
- package/src/ui/react.tsx +235 -0
- package/src/ui/router.ts +96 -0
- package/src/ui/theme.css +234 -0
- package/src/vite/design.ts +652 -0
- 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
|
+
}
|