@softize/opus 10.0.0 → 11.0.0

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/docs/shellnav.md DELETED
@@ -1,131 +0,0 @@
1
- # Proposta: `ShellNav` — o menu como primitivo pivotável
2
-
3
- > **Status: EM DISCUSSÃO.** Desenhado com o João (2026-07-22), a partir do trabalho na
4
- > cabine do Maestro (a nav do projeto virou `SectionShell`; a sidebar ganhou Início +
5
- > Configurações). Pendente de aprovação e de uma versão do Opus. Refina o
6
- > [`ownership-vs-shadcn-lock`](ownership-vs-shadcn-lock.md): o item de menu é o próximo
7
- > primitivo a sair do "cada app copia ad-hoc".
8
-
9
- ## O problema
10
-
11
- O `AppShell` entrega a **moldura** da sidebar (o slot `sidebarNav`) e o `SectionShell`
12
- entrega a **nav de seção** (nav + painel, dentro do conteúdo). Mas o **item de menu** — a
13
- linha com ícone + label + estado ativo + badge — não é primitivo de ninguém: cada app
14
- escreve o `<button className="…">` na mão. Resultado, medido (não suposto):
15
-
16
- | Menu | Onde | Como é feito hoje |
17
- |---|---|---|
18
- | Aplicação (sidebar) | `AppShell.sidebarNav` | `<button>` à mão — sem primitivo |
19
- | Configurações | dentro do conteúdo | `SectionShell` (primitivo) ✓ |
20
- | Relatórios | dentro do conteúdo | `<aside>`/`<nav>` à mão — **não usa o SectionShell**, mesmo sendo a mesma forma |
21
-
22
- E isso se repete em **três produtos** (`softize/apps/maestro`, `softize/apps/main/web`,
23
- `empresa-x/apps/main`), cada um com a própria cópia do item — e, dentro de cada um, o
24
- markup ainda se repete por estado (ativo/inativo/recolhido) e nível (pai/filho).
25
-
26
- Dois fatos que calibram a urgência:
27
-
28
- - **Não é drift visível — é duplicação + risco.** Diff token a token: mesma versão do Opus
29
- (2.37), mesmo `--radius` (0.625rem), classes de forma **byte-a-byte iguais**
30
- (`rounded-md px-2.5 py-1.5 gap-2.5`). Só continuam iguais porque um humano copia; nada
31
- cobra. A "sensação de divergência" (item mais retangular numa app que noutra) vem de
32
- comparar **componentes diferentes** (nav de app × nav de seção × switcher), não de drift
33
- do mesmo componente.
34
- - **Já há uma inconsistência viva:** o menu de Relatórios do GB é uma nav-dentro-do-conteúdo
35
- — a MESMA forma do Configurações — e mesmo assim hand-rola em vez de usar o `SectionShell`.
36
-
37
- ## A proposta
38
-
39
- Um primitivo só: **`ShellNav`** — **pivotável e controlado** (presentacional, como o
40
- `SectionShell` já é). O app passa **dados**, não markup, e o mesmo componente serve os dois
41
- lares sem flag de "modo app" vs "modo seção":
42
-
43
- - Na **sidebar do AppShell** (`sidebarNav={<ShellNav …/>}`): lê o estado de recolhido do
44
- contexto `useAppShell` e vira ícone-só sozinho.
45
- - **Dentro do conteúdo** (o que o `SectionShell` faz): sem esse contexto, renderiza
46
- expandido. O `SectionShell` **passa a compor** `ShellNav` (a nav) + o painel — um nav,
47
- dois lares.
48
-
49
- Isto é o "tanto faz onde, mesmo comportamento": o componente **se adapta ao contexto** via
50
- `useAppShell`, sem o consumidor configurar nada.
51
-
52
- ### Heading com espaço pra botão
53
-
54
- Slot `heading` — título + ação opcional. É o "+" de novo relatório, o título da seção, a
55
- marca na sidebar. Trivial e resolve o gesto que hoje mora à mão em cada nav.
56
-
57
- ### Árvore embutida; DnD composto (a régua)
58
-
59
- - **Aninhamento/árvore → dentro do `ShellNav`.** Não é feature nova: o accordion da sidebar
60
- (Vendas → Leads/Propostas, no GB) já é uma árvore de um nível. Barato, já necessário.
61
- - **Drag-drop de reordenar → COMPOSTO por fora**, estilo `dnd-kit`: o `ShellNav` expõe a
62
- costura (um wrapper de item + um slot pro indicador de drop); um companheiro
63
- (`useNavReorder`) pluga o arrasto só onde se opta. O peso — estado de drag, indicadores,
64
- toque e principalmente a **a11y** (nav é *landmark*; reorder é *widget de manipulação* —
65
- identidades diferentes) — vive fora do primitivo.
66
-
67
- **O critério (viaja pra próximas decisões de primitivo): embute o que a maioria
68
- compartilha; compõe o que um só precisa.** Reordenar é feature de um consumidor (só o
69
- Relatórios); embuti-la faria os muitos pagarem a complexidade pelo um.
70
-
71
- ### GB é a aparência de referência
72
-
73
- Das três, a implementação do GB é a mais madura (tem rail/colapso, accordion, estados
74
- ativos, âncora inferior de Configurações). **O `ShellNav` codifica o look do GB como
75
- default** — nada regride: o GB migra pro primitivo e renderiza igual; a cabine (já alinhada
76
- às classes do GB) passa a ser *literalmente* o mesmo componente, e a "sensação de
77
- retangular" some. A **identidade por app** continua nos **tokens** (`--radius`, cores — o
78
- Opus já permite sobrescrever no `theme.css` do app), não num fork do componente: a forma é
79
- compartilhada; a pele é temável.
80
-
81
- ## API (esboço)
82
-
83
- ```tsx
84
- interface ShellNavItem {
85
- id: string
86
- label: string
87
- icon?: ReactNode
88
- badge?: ReactNode
89
- disabled?: boolean
90
- children?: ShellNavItem[] // accordion (app) / pasta (relatórios)
91
- }
92
- interface ShellNavGroup { label?: string; items: ShellNavItem[] }
93
-
94
- interface ShellNavProps {
95
- groups: ShellNavGroup[]
96
- activeId?: string
97
- onSelect: (id: string) => void
98
- heading?: ReactNode // título + espaço pra botão (o "+" do Relatórios)
99
- footer?: ReactNode // âncora inferior (Configurações, no app)
100
- collapsed?: boolean // override; default lê do useAppShell quando presente
101
- navLabel?: string
102
- reorder?: NavReorderSeam // opt-in; sem isto, ZERO DnD (o dnd-kit/useNavReorder pluga aqui)
103
- }
104
-
105
- // Conveniência do heading (padroniza título + ação):
106
- <ShellNavHeading title="Relatórios" action={<Button size="icon" icon={Plus} …/>} />
107
- ```
108
-
109
- O `SectionShell` reescreve por dentro pra `<ShellNav/>` + painel; sua API pública não muda
110
- (consumidores existentes seguem).
111
-
112
- ## Hoje × proposto
113
-
114
- | Menu | Hoje | Proposto |
115
- |---|---|---|
116
- | App (sidebar) | `<button>` à mão, N cópias por estado | `AppShell sidebarNav={<ShellNav groups footer=Configurações/>}` — colapso via contexto |
117
- | Configurações | `SectionShell` (nav+painel à mão por dentro) | `SectionShell` compõe `ShellNav` — sem mudança pro consumidor |
118
- | Relatórios | `<aside>` + tree + DnD, tudo à mão | `<ShellNav heading={título+"+"} …/>` + `useNavReorder` ao lado |
119
-
120
- ## Consequências / plano
121
-
122
- - **Escopo:** sessão dedicada no Opus (não é ajuste de app). Ciclo: desenhar a API →
123
- publicar versão do Opus → migrar os consumidores (cada `<button>` à mão vira `groups={…}`).
124
- - **Custo:** baixo em código, alto em coordenação (bump + migração dos 3 produtos + gates
125
- em cada). O `SectionShell` é o precedente vivo — foi o mesmo movimento, um nível abaixo.
126
- - **Ordem sugerida:** (1) `ShellNav` + `ShellNavHeading` no Opus com o look do GB como
127
- default; (2) `SectionShell` passa a compor; (3) migrar a cabine do Maestro (primeiro
128
- cliente, já alinhada); (4) migrar o back-office; (5) migrar o Relatórios do GB pra
129
- `ShellNav` + `useNavReorder` (fecha a inconsistência viva).
130
- - **Não-objetivo:** o `ShellNav` não vira dono de DnD, tree-editor ou roteamento — segue
131
- presentacional/controlado; o app é dono do roteamento e do estado.
@@ -1,227 +0,0 @@
1
- /**
2
- * <AppShell /> — o chrome da aplicação: sidebar de navegação + conteúdo, com rail
3
- * opcional à direita (chat de agente, inspetor).
4
- *
5
- * Padroniza o esqueleto que os apps copiavam à mão (mesmas classes, várias vezes):
6
- * raiz `flex h-screen`, aside `w-64` com header/nav/rodapé (o nav rola; o rodapé
7
- * ancora), e o conteúdo. Com `rail`, conteúdo e rail viram painéis redimensionáveis
8
- * (Resizable). Presentacional de propósito: o QUE vai em cada slot (PaneHeader do
9
- * app, ChatRail, preview…) é do consumidor — o shell é o quadro, não o conteúdo.
10
- *
11
- * RECOLHER (`collapsible`): o shell controla a LARGURA e publica o estado; quem decide
12
- * o que some é o conteúdo, porque o `sidebarNav` é do consumidor. O aside expõe
13
- * `data-collapsed` e o grupo `sidebar`, então rótulo some por CSS, sem estado no
14
- * consumidor: `className="group-data-[collapsed=true]/sidebar:hidden"`. Quem precisa do
15
- * estado em JS usa `useAppShell()`; o botão é o `<AppShellTrigger />`, posicionado pelo
16
- * consumidor (numa `<AppShellBar>`, por exemplo) — o shell não escolhe por ele.
17
- */
18
-
19
- import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
20
- import { PanelLeft } from 'lucide-react'
21
- import { cn } from '../../lib/cn.ts'
22
- import { Button } from '../primitives/button.tsx'
23
- import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../primitives/resizable.tsx'
24
-
25
- interface AppShellContextValue {
26
- /** O shell permite recolher? Sem isso o trigger não se renderiza. */
27
- collapsible: boolean
28
- collapsed: boolean
29
- setCollapsed: (collapsed: boolean) => void
30
- toggle: () => void
31
- }
32
-
33
- const AppShellContext = createContext<AppShellContextValue | null>(null)
34
-
35
- /** Estado do shell (recolhido/expandido) pra quem precisa dele em JS. Em CSS, prefira o
36
- * seletor de grupo (`group-data-[collapsed=true]/sidebar:…`) — não precisa de hook. */
37
- export function useAppShell(): AppShellContextValue {
38
- const ctx = useContext(AppShellContext)
39
- if (ctx === null) throw new Error('useAppShell precisa de um <AppShell> acima na árvore.')
40
- return ctx
41
- }
42
-
43
- interface SidebarSlotValue {
44
- collapsed: boolean
45
- }
46
- /** Contexto presente SÓ dentro dos slots da sidebar (header/nav/rodapé), AUSENTE no
47
- * conteúdo. `useAppShell` não distingue os dois (o colapso vale a árvore toda); este sim.
48
- * É como um nav PIVOTÁVEL (o `<ShellNav>`) sabe se está NA sidebar — e se ela está
49
- * recolhida — sem o app passar isso em duas mãos (fecha a nota n=2 do gb-nav-rail). */
50
- const SidebarSlotContext = createContext<SidebarSlotValue | null>(null)
51
- /** Null quando FORA da sidebar (ex.: um `<ShellNav>` dentro do conteúdo, num SectionShell). */
52
- export function useSidebarSlot(): SidebarSlotValue | null {
53
- return useContext(SidebarSlotContext)
54
- }
55
-
56
- export interface AppShellProps {
57
- /** Topo da sidebar (marca, seletor de contexto/usuário). */
58
- sidebarHeader?: ReactNode
59
- /** Miolo da sidebar — a navegação; rola sozinho quando passa da altura. */
60
- sidebarNav: ReactNode
61
- /** Rodapé da sidebar, ancorado embaixo (status, ações globais). */
62
- sidebarFooter?: ReactNode
63
- /** Rail opcional à direita (chat de agente, inspetor). Sem ele, o conteúdo ocupa tudo. */
64
- rail?: ReactNode
65
- /** Tamanho inicial (%) do painel de conteúdo quando há rail. Default: 62. */
66
- mainDefaultSize?: number
67
- /** Tamanho mínimo (%) do painel de conteúdo. Default: 35. */
68
- mainMinSize?: number
69
- /** Tamanho mínimo (%) do rail. Default: 25. */
70
- railMinSize?: number
71
- /** Classes da raiz. Default: altura da tela (`h-screen`); em embeds, passe ex. `h-96`. */
72
- className?: string
73
- /** Classes do aside. Default: `w-64 shrink-0 flex-col p-2`. Largura passada aqui manda no
74
- * estado EXPANDIDO; no recolhido quem vence é `sidebarCollapsedClassName` (recolher é
75
- * estado, não estilo — senão o aside ficaria meio-recolhido). */
76
- sidebarClassName?: string
77
- /** Permite recolher a sidebar — OPT-IN: sem isso o shell é o de sempre. */
78
- collapsible?: boolean
79
- /** Recolhida? Passe junto com `onCollapsedChange` pra controlar de fora (ex.: persistir
80
- * a preferência); omita e o shell guarda o estado sozinho. */
81
- collapsed?: boolean
82
- /** Avisa que o estado mudou (obrigatório na forma controlada). */
83
- onCollapsedChange?: (collapsed: boolean) => void
84
- /** Largura do aside recolhido — só o suficiente pros ícones. Default: `w-14`. */
85
- sidebarCollapsedClassName?: string
86
- /** Chrome RENTE ao browser (sem inset): sidebar sem padding e conteúdo full-bleed
87
- * apartado pelo filete (`border-l`) — o shell NÃO pinta fundo (o canvas vem do
88
- * body); superfície branca é decisão do conteúdo (pinte `bg-background` no seu
89
- * contêiner). Combine com `<AppShellBar>` na sidebar e no topo do conteúdo pra
90
- * linha do header atravessar a tela inteira. Default: false (chrome clássico). */
91
- flush?: boolean
92
- children: ReactNode
93
- }
94
-
95
- export function AppShell({
96
- sidebarHeader,
97
- sidebarNav,
98
- sidebarFooter,
99
- rail,
100
- mainDefaultSize = 62,
101
- mainMinSize = 35,
102
- railMinSize = 25,
103
- className,
104
- sidebarClassName,
105
- collapsible = false,
106
- collapsed: collapsedProp,
107
- onCollapsedChange,
108
- sidebarCollapsedClassName = 'w-14',
109
- flush = false,
110
- children,
111
- }: AppShellProps): React.ReactElement {
112
- // Controlado quando `collapsed` vem de fora; senão o shell guarda sozinho.
113
- const [internal, setInternal] = useState(false)
114
- const isControlled = collapsedProp !== undefined
115
- const collapsed = collapsible && (isControlled ? collapsedProp : internal)
116
-
117
- const setCollapsed = useCallback(
118
- (next: boolean) => {
119
- if (!isControlled) setInternal(next)
120
- onCollapsedChange?.(next)
121
- },
122
- [isControlled, onCollapsedChange],
123
- )
124
- const ctx = useMemo<AppShellContextValue>(
125
- () => ({ collapsible, collapsed, setCollapsed, toggle: () => setCollapsed(!collapsed) }),
126
- [collapsible, collapsed, setCollapsed],
127
- )
128
- const sidebarSlot = useMemo<SidebarSlotValue>(() => ({ collapsed: collapsible && collapsed }), [collapsible, collapsed])
129
-
130
- const main = (
131
- <div
132
- data-slot="app-shell-main"
133
- className={cn('flex h-full min-w-0 flex-1 flex-col', flush && 'border-l border-border')}
134
- >
135
- {children}
136
- </div>
137
- )
138
- return (
139
- <AppShellContext.Provider value={ctx}>
140
- <div data-slot="app-shell" className={cn('flex h-screen', className)}>
141
- <aside
142
- data-slot="app-shell-sidebar"
143
- // `data-collapsed` + grupo nomeado: o conteúdo do consumidor reage por CSS.
144
- data-collapsed={collapsible ? String(collapsed) : undefined}
145
- className={cn(
146
- 'group/sidebar flex shrink-0 flex-col',
147
- collapsible && 'transition-[width] duration-200 ease-out',
148
- 'w-64',
149
- flush ? 'p-0' : 'p-2',
150
- sidebarClassName,
151
- // DEPOIS do `sidebarClassName` de propósito: recolhido é ESTADO, não estilo
152
- // default. Quem passa a própria largura (`sidebarClassName="w-72"`) manda no
153
- // expandido, mas não pode impedir o aside de estreitar — senão o shell ficaria
154
- // meio-recolhido (rótulo some, largura fica) sem erro nenhum.
155
- collapsed && sidebarCollapsedClassName,
156
- )}
157
- >
158
- <SidebarSlotContext.Provider value={sidebarSlot}>
159
- {sidebarHeader}
160
- <div data-slot="app-shell-nav" className="min-h-0 flex-1 overflow-y-auto">
161
- {sidebarNav}
162
- </div>
163
- {sidebarFooter}
164
- </SidebarSlotContext.Provider>
165
- </aside>
166
- {rail === undefined ? (
167
- main
168
- ) : (
169
- <ResizablePanelGroup orientation="horizontal" className="min-w-0 flex-1">
170
- <ResizablePanel defaultSize={mainDefaultSize} minSize={mainMinSize}>
171
- {main}
172
- </ResizablePanel>
173
- <ResizableHandle withHandle />
174
- <ResizablePanel defaultSize={100 - mainDefaultSize} minSize={railMinSize}>
175
- <div data-slot="app-shell-rail" className="h-full">
176
- {rail}
177
- </div>
178
- </ResizablePanel>
179
- </ResizablePanelGroup>
180
- )}
181
- </div>
182
- </AppShellContext.Provider>
183
- )
184
- }
185
-
186
- /**
187
- * <AppShellTrigger /> — recolhe/expande a sidebar. Some sozinho quando o shell não é
188
- * `collapsible`, então dá pra deixar fixo no layout sem condicional no consumidor.
189
- */
190
- export function AppShellTrigger({ className }: { className?: string }): React.ReactElement | null {
191
- const { collapsible, collapsed, toggle } = useAppShell()
192
- if (!collapsible) return null
193
- return (
194
- <Button
195
- variant="ghost"
196
- size="icon"
197
- onClick={toggle}
198
- aria-label={collapsed ? 'Expandir a barra lateral' : 'Recolher a barra lateral'}
199
- aria-expanded={!collapsed}
200
- className={className}
201
- >
202
- <PanelLeft className={cn('transition-transform duration-200', collapsed && 'rotate-180')} />
203
- </Button>
204
- )
205
- }
206
-
207
- /**
208
- * <AppShellBar /> — a linha de header do chrome `flush`: faixa h-12 com `border-b`.
209
- * Use UMA na sidebar (a marca) e OUTRA no topo do conteúdo (breadcrumb/ações): as
210
- * alturas casam e as bordas emendam — uma régua contínua atravessando a tela.
211
- */
212
- export function AppShellBar({
213
- className,
214
- children,
215
- }: {
216
- className?: string
217
- children?: ReactNode
218
- }): React.ReactElement {
219
- return (
220
- <div
221
- data-slot="app-shell-bar"
222
- className={cn('flex h-12 shrink-0 items-center gap-2.5 border-b border-border px-4', className)}
223
- >
224
- {children}
225
- </div>
226
- )
227
- }
@@ -1,246 +0,0 @@
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
- }