@softize/opus 9.1.1 → 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/CHANGELOG.md +52 -3
- package/bin/lib/check.mjs +37 -1
- package/bin/lib/db-check-runner.mjs +5 -5
- package/bin/lib/db-migrate-runner.mjs +3 -3
- package/bin/lib/db-scaffold-runner.mjs +4 -4
- package/bin/lib/db.mjs +1 -1
- package/bin/lib/gen-manifest.mjs +0 -3
- package/bin/lib/gen-runner.mjs +2 -7
- package/docs/data-layer.md +2 -2
- package/docs/elevation-scale.md +2 -2
- package/docs/protocol.md +2 -2
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +8 -3
- package/registry/skills/build-opus-ui/references/evaluations.md +2 -0
- package/registry/skills/build-opus-ui/references/ui-patterns.md +3 -0
- package/src/core/domain.ts +3 -6
- package/src/schema/entity.ts +1 -1
- package/src/ui/components/patterns/shell-nav.tsx +5 -9
- package/src/ui/components/patterns/sidebar.tsx +40 -15
- package/src/ui/components/primitives/alert-dialog.tsx +1 -1
- package/src/ui/components/primitives/calendar.tsx +1 -1
- package/src/ui/components/primitives/card.tsx +3 -3
- package/src/ui/components/primitives/chat.tsx +1 -1
- package/src/ui/components/primitives/composer.tsx +2 -2
- package/src/ui/components/primitives/dialog.tsx +1 -1
- package/src/ui/components/primitives/menu.tsx +2 -2
- package/src/ui/components/primitives/popover.tsx +1 -1
- package/src/ui/docs/DocBrowser.tsx +25 -10
- package/src/ui/docs/content/card.md +1 -1
- package/src/ui/docs/content/composer.md +1 -1
- package/src/ui/docs/content/confirm.md +2 -2
- package/src/ui/docs/content/sidebar.md +21 -1
- package/src/ui/docs/content/tokens.md +4 -7
- package/src/ui/docs/registry.tsx +0 -6
- package/src/ui/meta.ts +3 -24
- package/src/ui/react.tsx +4 -19
- package/src/ui/theme.css +0 -7
- package/docs/shellnav.md +0 -131
- package/src/ui/components/patterns/app-shell.tsx +0 -227
- package/src/ui/components/patterns/section-shell.tsx +0 -246
- package/src/ui/docs/content/app-shell.md +0 -155
- package/src/ui/docs/content/resizable.md +0 -86
- package/src/ui/docs/content/section-shell.md +0 -121
|
@@ -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
|
-
}
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
## Chrome da aplicação
|
|
2
|
-
|
|
3
|
-
O esqueleto que todo app da casa repete: sidebar de navegação à esquerda (header, nav rolável, rodapé ancorado) e o conteúdo. É o quadro — o que vai em cada slot (a marca, o seletor de contexto, o nav do app) é do consumidor. Pro esqueleto de *uma página* (título, descrição, ação), use o `Page` dentro do conteúdo.
|
|
4
|
-
|
|
5
|
-
```tsx preview col
|
|
6
|
-
render(
|
|
7
|
-
<div className="w-full overflow-hidden rounded-lg border border-border">
|
|
8
|
-
<AppShell
|
|
9
|
-
className="h-96"
|
|
10
|
-
sidebarHeader={<div className="px-2.5 py-2 text-sm font-semibold">◆ Acme</div>}
|
|
11
|
-
sidebarNav={
|
|
12
|
-
<nav className="space-y-1 pt-2">
|
|
13
|
-
{['Clientes', 'Workspaces', 'Agentes'].map((item, index) => (
|
|
14
|
-
<button
|
|
15
|
-
key={item}
|
|
16
|
-
type="button"
|
|
17
|
-
className={
|
|
18
|
-
'flex w-full items-center rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-muted/60 ' +
|
|
19
|
-
(index === 0 ? 'bg-muted' : 'text-foreground/80')
|
|
20
|
-
}
|
|
21
|
-
>
|
|
22
|
-
{item}
|
|
23
|
-
</button>
|
|
24
|
-
))}
|
|
25
|
-
</nav>
|
|
26
|
-
}
|
|
27
|
-
sidebarFooter={<div className="px-2.5 py-2 text-xs text-muted-foreground">ana@acme.com</div>}
|
|
28
|
-
>
|
|
29
|
-
<div className="m-4 flex-1 rounded-lg border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
|
30
|
-
O conteúdo (em geral um Page).
|
|
31
|
-
</div>
|
|
32
|
-
</AppShell>
|
|
33
|
-
</div>,
|
|
34
|
-
)
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Recolher a sidebar
|
|
38
|
-
|
|
39
|
-
`collapsible` é **opt-in** — sem ele o shell é o de sempre. O shell controla a **largura** e publica o estado; **o que some é decisão do conteúdo**, porque o `sidebarNav` é seu. Em vez de te obrigar a gerenciar estado, o aside expõe `data-collapsed` e o grupo `sidebar`: o rótulo some por CSS.
|
|
40
|
-
|
|
41
|
-
```
|
|
42
|
-
<span className="group-data-[collapsed=true]/sidebar:hidden">Clientes</span>
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
O botão é o `<AppShellTrigger />`, que **você** posiciona (numa `AppShellBar`, no header do conteúdo…) — ele some sozinho quando o shell não é `collapsible`, então não precisa de condicional. Pra ler o estado em JS, `useAppShell()`. Pra persistir a preferência, controle de fora com `collapsed` + `onCollapsedChange`; `sidebarCollapsedClassName` ajusta a largura recolhida (default `w-14`).
|
|
46
|
-
|
|
47
|
-
Se você passa a própria largura em `sidebarClassName`, ela vale no **expandido**: no recolhido quem vence é a largura do recolhido — recolher é estado, não estilo default, e o contrário deixaria o aside meio-recolhido (rótulo some, largura fica).
|
|
48
|
-
|
|
49
|
-
```tsx preview col
|
|
50
|
-
render(
|
|
51
|
-
<div className="w-full overflow-hidden rounded-lg border border-border">
|
|
52
|
-
<AppShell
|
|
53
|
-
collapsible
|
|
54
|
-
className="h-96"
|
|
55
|
-
sidebarHeader={
|
|
56
|
-
<div className="flex h-12 items-center gap-2 border-b border-border px-2.5">
|
|
57
|
-
<AppShellTrigger />
|
|
58
|
-
<span className="text-sm font-semibold group-data-[collapsed=true]/sidebar:hidden">◆ Acme</span>
|
|
59
|
-
</div>
|
|
60
|
-
}
|
|
61
|
-
sidebarNav={
|
|
62
|
-
<nav className="space-y-1 pt-2">
|
|
63
|
-
{[
|
|
64
|
-
{ label: 'Clientes', icon: '👤' },
|
|
65
|
-
{ label: 'Workspaces', icon: '▦' },
|
|
66
|
-
{ label: 'Agentes', icon: '✦' },
|
|
67
|
-
].map((item, index) => (
|
|
68
|
-
<button
|
|
69
|
-
key={item.label}
|
|
70
|
-
type="button"
|
|
71
|
-
title={item.label}
|
|
72
|
-
className={
|
|
73
|
-
'flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-muted/60 ' +
|
|
74
|
-
(index === 0 ? 'bg-muted' : 'text-foreground/80')
|
|
75
|
-
}
|
|
76
|
-
>
|
|
77
|
-
<span className="shrink-0">{item.icon}</span>
|
|
78
|
-
<span className="truncate group-data-[collapsed=true]/sidebar:hidden">{item.label}</span>
|
|
79
|
-
</button>
|
|
80
|
-
))}
|
|
81
|
-
</nav>
|
|
82
|
-
}
|
|
83
|
-
>
|
|
84
|
-
<div className="m-4 flex-1 rounded-lg border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
|
85
|
-
Clique no botão da sidebar pra recolher.
|
|
86
|
-
</div>
|
|
87
|
-
</AppShell>
|
|
88
|
-
</div>,
|
|
89
|
-
)
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
## Com rail à direita
|
|
93
|
-
|
|
94
|
-
`rail` liga o terceiro painel — o lugar do chat de agente ou de um inspetor. Conteúdo e rail viram painéis redimensionáveis (arraste a divisória); `mainDefaultSize`/`mainMinSize`/`railMinSize` (em %) calibram a partilha.
|
|
95
|
-
|
|
96
|
-
```tsx preview col
|
|
97
|
-
render(
|
|
98
|
-
<div className="w-full overflow-hidden rounded-lg border border-border">
|
|
99
|
-
<AppShell
|
|
100
|
-
className="h-96"
|
|
101
|
-
sidebarHeader={<div className="px-2.5 py-2 text-sm font-semibold">◆ Acme</div>}
|
|
102
|
-
sidebarNav={
|
|
103
|
-
<nav className="space-y-1 pt-2">
|
|
104
|
-
<button type="button" className="flex w-full items-center rounded-md bg-muted px-2.5 py-1.5 text-left text-sm">
|
|
105
|
-
Painel comercial
|
|
106
|
-
</button>
|
|
107
|
-
</nav>
|
|
108
|
-
}
|
|
109
|
-
rail={
|
|
110
|
-
<div className="flex h-full flex-col border-l bg-background">
|
|
111
|
-
<div className="border-b px-3 py-2 text-xs text-muted-foreground">Conversa</div>
|
|
112
|
-
<div className="flex-1 p-3 text-sm text-muted-foreground">O chat do agente vive aqui.</div>
|
|
113
|
-
</div>
|
|
114
|
-
}
|
|
115
|
-
>
|
|
116
|
-
<div className="m-4 flex-1 rounded-lg border border-dashed border-border p-10 text-center text-sm text-muted-foreground">
|
|
117
|
-
O conteúdo (preview, tabela, página).
|
|
118
|
-
</div>
|
|
119
|
-
</AppShell>
|
|
120
|
-
</div>,
|
|
121
|
-
)
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
O shell não impõe fundo nem borda aos slots — ele é TRANSPARENTE: o canvas (cinza clarinho) vem do `body` (theme.css) e a sidebar o herda; um rail tipicamente traz `border-l bg-background`, como acima.
|
|
125
|
-
|
|
126
|
-
## Chrome flush (linha de header contínua)
|
|
127
|
-
|
|
128
|
-
`flush` tira o inset: a sidebar perde o padding do shell e o conteúdo vira full-bleed
|
|
129
|
-
apartado por um filete à esquerda — o shell NÃO pinta fundo (o canvas do body aparece);
|
|
130
|
-
superfície branca é decisão do conteúdo (`bg-background` no seu contêiner). Combine com `AppShellBar` — a faixa h-12 com `border-b` —
|
|
131
|
-
uma na sidebar (a marca) e outra no topo do conteúdo (breadcrumb/ações): as alturas
|
|
132
|
-
casam e a linha do header atravessa a tela inteira.
|
|
133
|
-
|
|
134
|
-
```tsx preview col lg
|
|
135
|
-
render(
|
|
136
|
-
<AppShell
|
|
137
|
-
className="h-96 rounded-lg border"
|
|
138
|
-
flush
|
|
139
|
-
sidebarHeader={
|
|
140
|
-
<AppShellBar>
|
|
141
|
-
<span className="grid h-6 w-6 place-items-center rounded-md bg-primary/10 text-sm text-primary">◆</span>
|
|
142
|
-
<span className="text-sm font-semibold tracking-tight">Grupo Vetra</span>
|
|
143
|
-
</AppShellBar>
|
|
144
|
-
}
|
|
145
|
-
sidebarNav={<nav className="p-2 text-sm text-muted-foreground">Relatórios…</nav>}
|
|
146
|
-
>
|
|
147
|
-
<AppShellBar>
|
|
148
|
-
<span className="text-muted-foreground">Vetra BI</span>
|
|
149
|
-
<span className="text-muted-foreground/40">›</span>
|
|
150
|
-
<span className="text-sm font-medium">Painel comercial</span>
|
|
151
|
-
</AppShellBar>
|
|
152
|
-
<div className="p-4 text-sm text-muted-foreground">Conteúdo full-bleed.</div>
|
|
153
|
-
</AppShell>,
|
|
154
|
-
)
|
|
155
|
-
```
|