@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/CHANGELOG.md +27 -0
- 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/protocol.md +2 -2
- package/package.json +1 -1
- 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/docs/DocBrowser.tsx +25 -10
- package/src/ui/docs/content/confirm.md +2 -2
- package/src/ui/docs/content/sidebar.md +21 -1
- package/src/ui/docs/registry.tsx +0 -6
- package/src/ui/meta.ts +2 -23
- package/src/ui/react.tsx +4 -19
- 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
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,33 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
11
11
|
> tinha ficado sem registro nenhum, o que é exatamente o caso que este arquivo existe
|
|
12
12
|
> pra cobrir.
|
|
13
13
|
|
|
14
|
+
## 11.0.0 — 2026-08-21
|
|
15
|
+
|
|
16
|
+
**Breaking — deprecateds removidos na fronteira pública.** O major anterior manteve por
|
|
17
|
+
engano APIs cuja própria documentação prometia remoção na próxima versão; esta versão
|
|
18
|
+
fecha a migração em vez de carregar duas taxonomias indefinidamente.
|
|
19
|
+
|
|
20
|
+
- `AppShell`, `AppShellBar`, `AppShellTrigger`, `useAppShell`, `useSidebarSlot` e
|
|
21
|
+
`SectionShell` (incluindo seus tipos `Section*`) saem. Componha chrome e seções com
|
|
22
|
+
`Split`, `Pane`, `Sidebar`, `PaneHeader`, `PaneContent`, `PaneFooter` e `SidebarNav`.
|
|
23
|
+
- `ResizablePanelGroup`, `ResizablePanel` e `ResizableHandle` deixam de ser exports
|
|
24
|
+
públicos. Use `<Split resizable>` e declare tamanho/limites nos `<Pane>`.
|
|
25
|
+
- `SidebarHeader`, `SidebarContent` e `SidebarFooter` saem; eram aliases de
|
|
26
|
+
`PaneHeader`, `PaneContent` e `PaneFooter`.
|
|
27
|
+
- `DomainConfig.models` sai. Declare entidades em `DomainConfig.entities`; os comandos
|
|
28
|
+
`opus db` e o gerador agora leem somente esse campo. O manifest deixa de emitir o array
|
|
29
|
+
redundante `models`; os nomes continuam disponíveis em `entities[].name`.
|
|
30
|
+
|
|
31
|
+
`SidebarNav` passa a aceitar `subgroups`, cobrindo navegação contextual e a árvore de três
|
|
32
|
+
níveis da documentação sem um shell especializado. `ShellNav` agora deriva o estado
|
|
33
|
+
recolhido diretamente da `Sidebar`. O próprio `DocBrowser` foi migrado para a composição
|
|
34
|
+
canônica e serve como consumidor de referência.
|
|
35
|
+
|
|
36
|
+
**Migração:** substitua shells prontos pela composição `Split > Pane > Sidebar`; troque os
|
|
37
|
+
aliases `Sidebar*` pelos slots `Pane*`; envolva painéis ajustáveis em `<Split resizable>`;
|
|
38
|
+
renomeie `domain.models` para `domain.entities` e, ao consumir manifest, derive nomes de
|
|
39
|
+
`domain.entities.map(entity => entity.name)`.
|
|
40
|
+
|
|
14
41
|
## 10.0.0 — 2026-08-20
|
|
15
42
|
|
|
16
43
|
**Breaking — forma usa a escala do Tailwind e superfícies sempre carregam seu foreground.**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Contrato do `opus.config.ts` pros comandos `db`:
|
|
10
10
|
* - `entities?: EntityConfig[]` — entidades explícitas (opcional)
|
|
11
|
-
* - `domains?: DomainConfig[]` — entidades coletadas de `domain.
|
|
11
|
+
* - `domains?: DomainConfig[]` — entidades coletadas de `domain.entities`
|
|
12
12
|
* - `database: () => Kysely | Promise<Kysely>` — factory LAZY do banco.
|
|
13
13
|
* É factory (não instância) de propósito: o `gen` importa o mesmo config
|
|
14
14
|
* sem nunca chamar `database()`, então não abre conexão.
|
|
@@ -29,9 +29,9 @@ function emit(obj) {
|
|
|
29
29
|
|
|
30
30
|
function collectEntities(domain, out) {
|
|
31
31
|
if (domain === null || typeof domain !== 'object') return
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
for (const value of Object.values(
|
|
32
|
+
const entities = domain.entities
|
|
33
|
+
if (entities !== undefined && entities !== null && typeof entities === 'object') {
|
|
34
|
+
for (const value of Object.values(entities)) {
|
|
35
35
|
if (isEntityConfig(value)) out.push(value)
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -59,7 +59,7 @@ async function main() {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
if (entities.length === 0) {
|
|
62
|
-
emit({ ok: false, error: 'nenhuma entidade encontrada (config.entities ou domain.
|
|
62
|
+
emit({ ok: false, error: 'nenhuma entidade encontrada (config.entities ou domain.entities)' })
|
|
63
63
|
return
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -28,9 +28,9 @@ function emit(obj) {
|
|
|
28
28
|
|
|
29
29
|
function collectEntities(domain, out) {
|
|
30
30
|
if (domain === null || typeof domain !== 'object') return
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
for (const value of Object.values(
|
|
31
|
+
const entities = domain.entities
|
|
32
|
+
if (entities !== undefined && entities !== null && typeof entities === 'object') {
|
|
33
|
+
for (const value of Object.values(entities)) if (isEntityConfig(value)) out.push(value)
|
|
34
34
|
}
|
|
35
35
|
if (Array.isArray(domain.subdomains)) for (const sub of domain.subdomains) collectEntities(sub, out)
|
|
36
36
|
}
|
|
@@ -23,9 +23,9 @@ function emit(obj) {
|
|
|
23
23
|
|
|
24
24
|
function collectEntities(domain, out) {
|
|
25
25
|
if (domain === null || typeof domain !== 'object') return
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
for (const value of Object.values(
|
|
26
|
+
const entities = domain.entities
|
|
27
|
+
if (entities !== undefined && entities !== null && typeof entities === 'object') {
|
|
28
|
+
for (const value of Object.values(entities)) if (isEntityConfig(value)) out.push(value)
|
|
29
29
|
}
|
|
30
30
|
if (Array.isArray(domain.subdomains)) for (const sub of domain.subdomains) collectEntities(sub, out)
|
|
31
31
|
}
|
|
@@ -48,7 +48,7 @@ async function main() {
|
|
|
48
48
|
if (Array.isArray(config.entities)) for (const e of config.entities) if (isEntityConfig(e)) entities.push(e)
|
|
49
49
|
if (Array.isArray(config.domains)) for (const d of config.domains) collectEntities(d, entities)
|
|
50
50
|
if (entities.length === 0) {
|
|
51
|
-
emit({ ok: false, error: 'nenhuma entidade encontrada (config.entities ou domain.
|
|
51
|
+
emit({ ok: false, error: 'nenhuma entidade encontrada (config.entities ou domain.entities)' })
|
|
52
52
|
return
|
|
53
53
|
}
|
|
54
54
|
if (typeof config.database !== 'function') {
|
package/bin/lib/db.mjs
CHANGED
|
@@ -249,7 +249,7 @@ Flags:
|
|
|
249
249
|
|
|
250
250
|
O opus.config.ts precisa expor, pros comandos db:
|
|
251
251
|
database: () => Kysely factory LAZY do banco (gen não a chama)
|
|
252
|
-
entities | domains entidades (explícitas ou via domain.
|
|
252
|
+
entities | domains entidades (explícitas ou via domain.entities)
|
|
253
253
|
schema?: string script SQL idempotente (default: ./db/schema.sql)
|
|
254
254
|
migrations?: string pasta dos rascunhos do scaffold (default: ./migrations)
|
|
255
255
|
|
package/bin/lib/gen-manifest.mjs
CHANGED
|
@@ -25,10 +25,7 @@ function shapeDomain(d) {
|
|
|
25
25
|
dicts: d.dicts,
|
|
26
26
|
repository: d.hasRepository,
|
|
27
27
|
service: d.hasService,
|
|
28
|
-
// `entities` = a fonte estrutural+negócio (campos + docs). `models` = nomes
|
|
29
|
-
// (legado, removido no PR de rename). Ambos por ora pra não quebrar a lente.
|
|
30
28
|
entities: d.entities ?? [],
|
|
31
|
-
models: d.modelNames,
|
|
32
29
|
actions: d.actions.map(shapeAction),
|
|
33
30
|
reactions: d.reactions,
|
|
34
31
|
schedules: d.schedules,
|
package/bin/lib/gen-runner.mjs
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* SerializedDomain:
|
|
31
31
|
* {
|
|
32
32
|
* name, dicts, actions, reactions, schedules, subdomains,
|
|
33
|
-
* hasRepository, hasService
|
|
33
|
+
* hasRepository, hasService
|
|
34
34
|
* }
|
|
35
35
|
*
|
|
36
36
|
* SerializedAction (todos os ActionDef preservam suas strings):
|
|
@@ -143,17 +143,12 @@ async function main() {
|
|
|
143
143
|
// =============================================================================
|
|
144
144
|
|
|
145
145
|
function serializeDomain(domain, ctx) {
|
|
146
|
-
|
|
147
|
-
const entitySrc = domain.entities ?? domain.models
|
|
148
|
-
const entityList = serializeEntities(entitySrc)
|
|
146
|
+
const entityList = serializeEntities(domain.entities)
|
|
149
147
|
return {
|
|
150
148
|
name: domain.name,
|
|
151
149
|
description: nullable(domain.description),
|
|
152
150
|
hasRepository: domain.repository !== undefined,
|
|
153
151
|
hasService: domain.service !== undefined,
|
|
154
|
-
// Compat: nomes ainda expostos; `entities` traz a estrutura+doc completa.
|
|
155
|
-
hasModels: entityList.length > 0,
|
|
156
|
-
modelNames: entityList.map((e) => e.name),
|
|
157
152
|
entities: entityList,
|
|
158
153
|
dicts: serializeDicts(domain.dicts),
|
|
159
154
|
actions: Array.from(iterateActions(domain.actions), (a) =>
|
package/docs/data-layer.md
CHANGED
|
@@ -90,7 +90,7 @@ infra do `gen`, não a do `check`). Contrato do config **pros comandos `db`**:
|
|
|
90
90
|
|
|
91
91
|
```ts
|
|
92
92
|
export default {
|
|
93
|
-
domains: [...], // entidades coletadas de domain.
|
|
93
|
+
domains: [...], // entidades coletadas de domain.entities
|
|
94
94
|
entities: [Deal, Company], // ou explícitas (opcional)
|
|
95
95
|
schema: './src/db/schema.sql', // o script idempotente (default: ./db/schema.sql)
|
|
96
96
|
database: () => new Kysely({ ... }), // factory LAZY do banco
|
|
@@ -139,7 +139,7 @@ import { Kysely, PostgresDialect, CamelCasePlugin } from 'kysely'
|
|
|
139
139
|
import { Deal } from './src/domains/deals/deal.entity.ts'
|
|
140
140
|
|
|
141
141
|
export default {
|
|
142
|
-
entities: [Deal], // ou domains: [...] (entidades via domain.
|
|
142
|
+
entities: [Deal], // ou domains: [...] (entidades via domain.entities)
|
|
143
143
|
naming: 'snake', // colunas snake no banco
|
|
144
144
|
migrations: './migrations',
|
|
145
145
|
database: () => new Kysely({
|
package/docs/protocol.md
CHANGED
|
@@ -2053,14 +2053,14 @@ defineAction({
|
|
|
2053
2053
|
|
|
2054
2054
|
### Entidades dentro de um domínio
|
|
2055
2055
|
|
|
2056
|
-
`DomainConfig.
|
|
2056
|
+
`DomainConfig.entities` recebe as entidades do recorte. O domínio agrupa; a entidade declara.
|
|
2057
2057
|
|
|
2058
2058
|
```ts
|
|
2059
2059
|
import { defineDomain } from '@softize/opus'
|
|
2060
2060
|
|
|
2061
2061
|
export const crm = defineDomain({
|
|
2062
2062
|
name: 'crm',
|
|
2063
|
-
|
|
2063
|
+
entities: { Deal, Company, Contact },
|
|
2064
2064
|
actions: { /* deal.create, deal.search, ... */ },
|
|
2065
2065
|
})
|
|
2066
2066
|
```
|
package/package.json
CHANGED
package/src/core/domain.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* tbdlib — `defineDomain` factory + flatten helper.
|
|
3
3
|
*
|
|
4
4
|
* Domain agrupa as peças que pertencem a um mesmo recorte funcional
|
|
5
|
-
* (dicts,
|
|
5
|
+
* (dicts, entities, repository, service, actions, reactions, schedules,
|
|
6
6
|
* subdomains). É puramente declarativo: nenhum efeito colateral acontece
|
|
7
7
|
* em `defineDomain`. O runtime consome o objeto via `flattenDomain` quando
|
|
8
8
|
* recebe um `DomainConfig` em `register(...)`.
|
|
@@ -55,12 +55,9 @@ export interface DomainConfig {
|
|
|
55
55
|
dicts?: Record<string, unknown>
|
|
56
56
|
|
|
57
57
|
/** Entidades do domínio (`defineEntity`). Map de nome → EntityConfig. É a fonte
|
|
58
|
-
* estrutural+negócio que vai pro manifest.
|
|
58
|
+
* estrutural+negócio que vai pro manifest. */
|
|
59
59
|
entities?: Record<string, unknown>
|
|
60
60
|
|
|
61
|
-
/** @deprecated Use `entities`. Mantido por compat — o runtime/gen lê os dois. */
|
|
62
|
-
models?: Record<string, unknown>
|
|
63
|
-
|
|
64
61
|
/** Repository class do domínio. */
|
|
65
62
|
repository?: unknown
|
|
66
63
|
|
|
@@ -152,7 +149,7 @@ export function isDomainConfig(value: unknown): value is DomainConfig {
|
|
|
152
149
|
'schedules' in v ||
|
|
153
150
|
'subdomains' in v ||
|
|
154
151
|
'dicts' in v ||
|
|
155
|
-
'
|
|
152
|
+
'entities' in v ||
|
|
156
153
|
'repository' in v ||
|
|
157
154
|
'service' in v
|
|
158
155
|
)
|
package/src/schema/entity.ts
CHANGED
|
@@ -310,7 +310,7 @@ export function entityTable(config: EntityConfig): string {
|
|
|
310
310
|
|
|
311
311
|
/**
|
|
312
312
|
* `true` se `value` parece um `EntityConfig`. Usado pra coletar entidades de
|
|
313
|
-
* `domain.
|
|
313
|
+
* `domain.entities` (que é `unknown`) sem confundir com action/reaction/model.
|
|
314
314
|
* Heurística: tem `name` string + `fields` objeto e **não** tem `kind`
|
|
315
315
|
* (descarta `ActionDef`).
|
|
316
316
|
*/
|
|
@@ -2,11 +2,8 @@
|
|
|
2
2
|
* <ShellNav /> — o menu como primitivo PIVOTÁVEL. Um nav (grupos → itens, com heading e
|
|
3
3
|
* âncora inferior) que serve os DOIS lares do chrome sem flag:
|
|
4
4
|
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
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).
|
|
5
|
+
* - dentro de <Sidebar> → recolhe pra ícone-só (com tooltip) junto com a coluna;
|
|
6
|
+
* - fora de <Sidebar> → permanece expandido.
|
|
10
7
|
*
|
|
11
8
|
* "Tanto faz onde": o componente se adapta ao lugar, sem "modo" configurado.
|
|
12
9
|
*
|
|
@@ -17,7 +14,7 @@
|
|
|
17
14
|
|
|
18
15
|
import type { ReactNode } from 'react'
|
|
19
16
|
import { cn } from '../../lib/cn.ts'
|
|
20
|
-
import {
|
|
17
|
+
import { useSidebarCollapsed } from './sidebar.tsx'
|
|
21
18
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
|
|
22
19
|
|
|
23
20
|
export interface ShellNavItem {
|
|
@@ -49,7 +46,7 @@ export interface ShellNavProps {
|
|
|
49
46
|
footer?: ShellNavGroup[]
|
|
50
47
|
/** Rótulo da landmark `<nav>`. */
|
|
51
48
|
navLabel?: string
|
|
52
|
-
/** Classes da raiz (a coluna). A
|
|
49
|
+
/** Classes da raiz (a coluna). A largura vem do pane/sidebar que a contém. */
|
|
53
50
|
className?: string
|
|
54
51
|
}
|
|
55
52
|
|
|
@@ -109,8 +106,7 @@ function renderGroup(group: ShellNavGroup, gi: number, activeId: string | undefi
|
|
|
109
106
|
}
|
|
110
107
|
|
|
111
108
|
export function ShellNav({ groups, activeId, onSelect, heading, footer, navLabel = 'Navegação', className }: ShellNavProps): React.ReactElement {
|
|
112
|
-
|
|
113
|
-
const railed = useSidebarSlot()?.collapsed ?? false
|
|
109
|
+
const railed = useSidebarCollapsed()
|
|
114
110
|
|
|
115
111
|
const body = (
|
|
116
112
|
<div data-slot="shell-nav" className={cn('flex h-full min-h-0 flex-col', className)}>
|
|
@@ -5,6 +5,11 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../pri
|
|
|
5
5
|
interface SidebarContextValue { collapsed: boolean }
|
|
6
6
|
const SidebarContext = createContext<SidebarContextValue | null>(null)
|
|
7
7
|
|
|
8
|
+
/** Estado da Sidebar mais próxima. Uso interno por navegações que se adaptam ao colapso. */
|
|
9
|
+
export function useSidebarCollapsed(): boolean {
|
|
10
|
+
return useContext(SidebarContext)?.collapsed ?? false
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
export interface SidebarProps {
|
|
9
14
|
collapsed?: boolean
|
|
10
15
|
/** Desliga a borda quando o Split já desenha a divisória. */
|
|
@@ -39,27 +44,21 @@ export function PaneFooter({ className, children }: { className?: string; childr
|
|
|
39
44
|
return <div data-slot="pane-footer" className={cn('shrink-0 border-t border-border p-2', className)}>{children}</div>
|
|
40
45
|
}
|
|
41
46
|
|
|
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
47
|
export interface SidebarItemProps {
|
|
50
48
|
label: string
|
|
51
49
|
icon?: ReactNode
|
|
52
50
|
badge?: ReactNode
|
|
53
51
|
active?: boolean
|
|
54
52
|
disabled?: boolean
|
|
53
|
+
className?: string
|
|
55
54
|
onClick: () => void
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
/** 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 =
|
|
58
|
+
export function SidebarItem({ label, icon, badge, active = false, disabled = false, className, onClick }: SidebarItemProps): React.ReactElement {
|
|
59
|
+
const collapsed = useSidebarCollapsed()
|
|
61
60
|
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')}>
|
|
61
|
+
<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', className)}>
|
|
63
62
|
{icon !== undefined && <span className="flex shrink-0">{icon}</span>}
|
|
64
63
|
{!collapsed && <span className="min-w-0 flex-1 truncate">{label}</span>}
|
|
65
64
|
{!collapsed && badge !== undefined && <span className="shrink-0">{badge}</span>}
|
|
@@ -71,17 +70,43 @@ export function SidebarItem({ label, icon, badge, active = false, disabled = fal
|
|
|
71
70
|
|
|
72
71
|
export interface SidebarNavGroup {
|
|
73
72
|
label?: string
|
|
74
|
-
items
|
|
73
|
+
items?: SidebarNavItem[]
|
|
74
|
+
subgroups?: SidebarNavSubgroup[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type SidebarNavItem = Omit<SidebarItemProps, 'active' | 'onClick'> & { id: string }
|
|
78
|
+
|
|
79
|
+
export interface SidebarNavSubgroup {
|
|
80
|
+
label?: string
|
|
81
|
+
items: SidebarNavItem[]
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
const titled = (label?: string): boolean => (label ?? '').trim() !== ''
|
|
85
|
+
const subFilled = (subgroup: SidebarNavSubgroup): boolean => subgroup.items.length > 0
|
|
86
|
+
const groupFilled = (group: SidebarNavGroup): boolean =>
|
|
87
|
+
(group.items?.length ?? 0) > 0 || (group.subgroups?.some(subFilled) ?? false)
|
|
88
|
+
|
|
77
89
|
/** Navegação controlada para Sidebar. Header/footer continuam slots explícitos do pai. */
|
|
78
90
|
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 =
|
|
91
|
+
const collapsed = useSidebarCollapsed()
|
|
92
|
+
const renderItem = (item: SidebarNavItem, nested = false): React.ReactElement => (
|
|
93
|
+
<SidebarItem
|
|
94
|
+
key={item.id}
|
|
95
|
+
{...item}
|
|
96
|
+
className={cn(nested && !collapsed && 'pl-6', item.className)}
|
|
97
|
+
active={item.id === activeId}
|
|
98
|
+
onClick={() => onSelect(item.id)}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
80
101
|
const body = (
|
|
81
102
|
<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
|
|
84
|
-
{group.items
|
|
103
|
+
{groups.map((group, gi) => !groupFilled(group) ? null : <div key={gi} className="space-y-0.5">
|
|
104
|
+
{!collapsed && titled(group.label) && <div className="px-2.5 pb-1 text-xs font-medium text-muted-foreground/70">{group.label}</div>}
|
|
105
|
+
{group.items?.map((item) => renderItem(item))}
|
|
106
|
+
{group.subgroups?.map((subgroup, si) => !subFilled(subgroup) ? null : <div key={si} className="space-y-0.5">
|
|
107
|
+
{!collapsed && titled(subgroup.label) && <div className="pb-0.5 pl-4 pr-2.5 pt-1.5 text-[11px] font-medium text-muted-foreground/50">{subgroup.label}</div>}
|
|
108
|
+
{subgroup.items.map((item) => renderItem(item, titled(subgroup.label)))}
|
|
109
|
+
</div>)}
|
|
85
110
|
</div>)}
|
|
86
111
|
</nav>
|
|
87
112
|
)
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { useEffect } from 'react'
|
|
16
16
|
import { DOC_SECTIONS, type DocSection, type DocEntry } from './registry'
|
|
17
|
-
import {
|
|
17
|
+
import { Pane, Split } from '../components/patterns/split.tsx'
|
|
18
|
+
import { PaneContent, Sidebar, SidebarNav, type SidebarNavGroup } from '../components/patterns/sidebar.tsx'
|
|
18
19
|
import { navigate, usePathname } from '../router.ts'
|
|
19
20
|
|
|
20
21
|
function findPage(sections: DocSection[], slug: string): DocEntry | undefined {
|
|
@@ -70,21 +71,35 @@ export function DocBrowser({
|
|
|
70
71
|
// Seção → grupo, e os grupos da seção → subgrupos. O tier do meio NÃO pode ser
|
|
71
72
|
// achatado: `docSectionsFromFolder` o preenche a partir de sub-pasta (ou do
|
|
72
73
|
// frontmatter `group:`), que é o caminho do `opusDocs({ source })`.
|
|
73
|
-
const navGroups:
|
|
74
|
+
const navGroups: SidebarNavGroup[] = sections.map((section) => ({
|
|
74
75
|
label: section.label,
|
|
75
76
|
subgroups: section.groups.map((g) => ({
|
|
76
77
|
label: g.label,
|
|
77
|
-
items: g.pages.map((p) => ({
|
|
78
|
+
items: g.pages.map((p) => ({
|
|
79
|
+
id: p.slug,
|
|
80
|
+
label: p.title,
|
|
81
|
+
badge: p.badge === undefined ? undefined : <span className="rounded border border-border/60 px-1 font-mono text-[10px] leading-tight text-muted-foreground/60">{p.badge}</span>,
|
|
82
|
+
})),
|
|
78
83
|
})),
|
|
79
84
|
}))
|
|
80
85
|
|
|
81
86
|
return (
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
<Split className="h-full">
|
|
88
|
+
<Pane inset="none" className="w-56">
|
|
89
|
+
<Sidebar className="w-full">
|
|
90
|
+
<PaneContent>
|
|
91
|
+
<SidebarNav
|
|
92
|
+
groups={navGroups}
|
|
93
|
+
activeId={page?.slug}
|
|
94
|
+
navLabel="Navegação da documentação"
|
|
95
|
+
onSelect={(slug) => go(`${basePath}/${slug}`)}
|
|
96
|
+
/>
|
|
97
|
+
</PaneContent>
|
|
98
|
+
</Sidebar>
|
|
99
|
+
</Pane>
|
|
100
|
+
<Pane key={page?.slug} grow inset="none" className="overflow-y-auto">
|
|
101
|
+
<div className="mx-auto max-w-3xl px-8 py-8">{page?.render()}</div>
|
|
102
|
+
</Pane>
|
|
103
|
+
</Split>
|
|
89
104
|
)
|
|
90
105
|
}
|
|
@@ -75,11 +75,11 @@ render(<Demo />)
|
|
|
75
75
|
Monte **um** `<DialogHost />` no shell do app, ao lado do `<Toaster />`:
|
|
76
76
|
|
|
77
77
|
```tsx
|
|
78
|
-
|
|
78
|
+
<>
|
|
79
79
|
{rotas}
|
|
80
80
|
<Toaster />
|
|
81
81
|
<DialogHost />
|
|
82
|
-
|
|
82
|
+
</>
|
|
83
83
|
```
|
|
84
84
|
|
|
85
85
|
Sem ele, os três **lançam** — em vez de devolver uma promise que nunca resolve. Promise pendurada viraria clique sem efeito, o pior desfecho pra uma interrupção que exige resposta: a pessoa acha que respondeu, ou clica de novo. (`<ConfirmHost />` segue valendo como alias de `<DialogHost />`.)
|
|
@@ -27,7 +27,7 @@ render(
|
|
|
27
27
|
|
|
28
28
|
## Colapso
|
|
29
29
|
|
|
30
|
-
`collapsed` pertence à própria `Sidebar`; `SidebarItem` e `
|
|
30
|
+
`collapsed` pertence à própria `Sidebar`; `SidebarItem`, `SidebarNav` e `ShellNav` adaptam-se automaticamente para botões `size-9` centralizados, ícones e tooltips. `PaneHeader`, `PaneContent` e `PaneFooter` são os slots do pane: a aplicação mantém a identidade e ações que lhe pertencem sem atribuí-las artificialmente à sidebar.
|
|
31
31
|
|
|
32
32
|
```tsx
|
|
33
33
|
<Sidebar collapsed={collapsed}>
|
|
@@ -36,3 +36,23 @@ render(
|
|
|
36
36
|
<PaneFooter><UserMenu /></PaneFooter>
|
|
37
37
|
</Sidebar>
|
|
38
38
|
```
|
|
39
|
+
|
|
40
|
+
## Navegação contextual
|
|
41
|
+
|
|
42
|
+
`SidebarNav` aceita grupos e subgrupos. Isso cobre tanto a navegação global quanto seções internas, como Configurações ou documentação, sem outro shell especializado.
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
<SidebarNav
|
|
46
|
+
groups={[
|
|
47
|
+
{
|
|
48
|
+
label: 'Configurações',
|
|
49
|
+
subgroups: [
|
|
50
|
+
{ label: 'Acesso', items: [{ id: 'users', label: 'Usuários' }] },
|
|
51
|
+
{ label: 'Dados', items: [{ id: 'imports', label: 'Importações' }] },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
]}
|
|
55
|
+
activeId={active}
|
|
56
|
+
onSelect={go}
|
|
57
|
+
/>
|
|
58
|
+
```
|
package/src/ui/docs/registry.tsx
CHANGED
|
@@ -80,7 +80,6 @@ import paginationMd from './content/pagination.md?raw'
|
|
|
80
80
|
import popoverMd from './content/popover.md?raw'
|
|
81
81
|
import progressMd from './content/progress.md?raw'
|
|
82
82
|
import radioGroupMd from './content/radio-group.md?raw'
|
|
83
|
-
import resizableMd from './content/resizable.md?raw'
|
|
84
83
|
import scrollAreaMd from './content/scroll-area.md?raw'
|
|
85
84
|
import selectMd from './content/select.md?raw'
|
|
86
85
|
import separatorMd from './content/separator.md?raw'
|
|
@@ -114,8 +113,6 @@ import actionViewMd from './content/action-view.md?raw'
|
|
|
114
113
|
import actionTriggerMd from './content/action-trigger.md?raw'
|
|
115
114
|
import actionSearchDialogMd from './content/action-list-dialog.md?raw'
|
|
116
115
|
import pageMd from './content/page.md?raw'
|
|
117
|
-
import appShellMd from './content/app-shell.md?raw'
|
|
118
|
-
import sectionShellMd from './content/section-shell.md?raw'
|
|
119
116
|
import sidebarMd from './content/sidebar.md?raw'
|
|
120
117
|
import splitMd from './content/split.md?raw'
|
|
121
118
|
import routerMd from './content/router.md?raw'
|
|
@@ -295,11 +292,8 @@ export const UI_SECTIONS: DocSection[] = [
|
|
|
295
292
|
{ slug: 'aspect-ratio', title: 'Aspect Ratio', render: comp('Aspect Ratio', 'aspect-ratio', aspectRatioMd) },
|
|
296
293
|
{ slug: 'card', title: 'Card', render: comp('Card', 'card', cardMd) },
|
|
297
294
|
{ slug: 'collapsible', title: 'Collapsible', render: comp('Collapsible', 'collapsible', collapsibleMd) },
|
|
298
|
-
{ slug: 'resizable', title: 'Resizable', badge: componentMeta.resizable.deprecated ? 'deprecated' : undefined, render: comp('Resizable', 'resizable', resizableMd) },
|
|
299
295
|
{ slug: 'scroll-area', title: 'Scroll Area', render: comp('Scroll Area', 'scroll-area', scrollAreaMd) },
|
|
300
296
|
{ slug: 'separator', title: 'Separator', render: comp('Separator', 'separator', separatorMd) },
|
|
301
|
-
{ slug: 'app-shell', title: 'App Shell', badge: componentMeta['app-shell'].deprecated ? 'deprecated' : undefined, render: pattern('App Shell', 'app-shell', appShellMd) },
|
|
302
|
-
{ slug: 'section-shell', title: 'Section Shell', badge: componentMeta['section-shell'].deprecated ? 'deprecated' : undefined, render: pattern('Section Shell', 'section-shell', sectionShellMd) },
|
|
303
297
|
],
|
|
304
298
|
},
|
|
305
299
|
{
|
package/src/ui/meta.ts
CHANGED
|
@@ -283,24 +283,17 @@ export const componentMeta = {
|
|
|
283
283
|
whenToUse:
|
|
284
284
|
'Escolha única entre opções mutuamente exclusivas, todas visíveis ao mesmo tempo (Radix). Cada RadioGroupItem tem um `value`; o item escolhido vira o `value` do RadioGroup, controlado por `value`/`onValueChange` (ou `defaultValue` no modo não controlado). Pareie cada item com um Label. Pra poucas opções que cabem na tela; com muitas, prefira Select; pra ligar/desligar um único item, Checkbox ou Switch.',
|
|
285
285
|
},
|
|
286
|
-
'resizable': {
|
|
287
|
-
name: 'resizable',
|
|
288
|
-
ancestry: 'shadcn',
|
|
289
|
-
whenToUse:
|
|
290
|
-
'Painéis redimensionáveis por arraste (react-resizable-panels). Compõe ResizablePanelGroup > ResizablePanel + ResizableHandle entre eles; `orientation` (horizontal/vertical) define a direção do arraste, `defaultSize`/`minSize`/`maxSize` (em %) limitam cada painel e `withHandle` desenha a pega na divisória. O grupo ocupa a altura do pai (h-full), então dê tamanho ao container. Pra alternar entre painéis sem dividir a vista, use Tabs.',
|
|
291
|
-
deprecated: { alternative: '`Split resizable` + `Pane`', since: 'próxima versão' },
|
|
292
|
-
},
|
|
293
286
|
'split': {
|
|
294
287
|
name: 'split',
|
|
295
288
|
ancestry: 'opus',
|
|
296
289
|
whenToUse:
|
|
297
|
-
'Divide uma área em panes em sequência horizontal ou vertical. Use `resizable` quando a pessoa deve ajustar a fronteira; o mesmo `<Split>` vira flex simples sem ele. Cada `<Pane>` declara tamanho inicial/mínimo e inset. É o mecanismo espacial para sidebar, conteúdo e rail
|
|
290
|
+
'Divide uma área em panes em sequência horizontal ou vertical. Use `resizable` quando a pessoa deve ajustar a fronteira; o mesmo `<Split>` vira flex simples sem ele. Cada `<Pane>` declara tamanho inicial/mínimo e inset. É o mecanismo espacial para sidebar, conteúdo e rail.',
|
|
298
291
|
},
|
|
299
292
|
'sidebar': {
|
|
300
293
|
name: 'sidebar',
|
|
301
294
|
ancestry: 'opus',
|
|
302
295
|
whenToUse:
|
|
303
|
-
'Chrome e navegação de uma coluna lateral, encaixada onde um Split decidir. `Sidebar` possui o colapso; `PaneHeader`, `PaneContent` e `PaneFooter` estruturam qualquer pane, e `SidebarNav`/`SidebarItem` apresentam
|
|
296
|
+
'Chrome e navegação de uma coluna lateral, encaixada onde um Split decidir. `Sidebar` possui o colapso; `PaneHeader`, `PaneContent` e `PaneFooter` estruturam qualquer pane, e `SidebarNav`/`SidebarItem` apresentam navegação com grupos e subgrupos. Serve tanto a barra global quanto uma nav contextual; em Split redimensionável passe `divider={false}` para não duplicar a divisória.',
|
|
304
297
|
},
|
|
305
298
|
'scroll-area': {
|
|
306
299
|
name: 'scroll-area',
|
|
@@ -381,20 +374,6 @@ export const componentMeta = {
|
|
|
381
374
|
whenToUse:
|
|
382
375
|
'O esqueleto de página do back-office: <main> + container de largura cheia (className="max-w-5xl" estreita e centra) + header com título, descrição e ação à direita (em geral o criar). Presentacional (a página agrega N fontes); o action-driven mora dentro. Pra listagem em modal, ActionListDialog.',
|
|
383
376
|
},
|
|
384
|
-
'app-shell': {
|
|
385
|
-
name: 'app-shell',
|
|
386
|
-
ancestry: 'opus',
|
|
387
|
-
whenToUse:
|
|
388
|
-
'O chrome da aplicação: sidebar de navegação (header + nav rolável + rodapé ancorado) + conteúdo, com rail opcional à direita (chat de agente, inspetor) em painéis redimensionáveis. É o quadro — o que vai em cada slot é do app. Pro esqueleto de UMA página (título, descrição, ação), Page. Chrome `flush` opcional (full-bleed, filete à esquerda) com <AppShellBar> — a faixa h-12 border-b usada na sidebar (marca) e no topo do conteúdo pra linha do header atravessar a tela. Sidebar recolhível com `collapsible` (opt-in): o shell controla a largura e publica `data-collapsed` no aside; o rótulo some por CSS (`group-data-[collapsed=true]/sidebar:hidden`) porque o nav é do app. O botão é o <AppShellTrigger />, posicionado pelo consumidor; `useAppShell()` dá o estado em JS.',
|
|
389
|
-
deprecated: { alternative: '`Split` + `Pane` + `Sidebar`', since: 'próxima versão' },
|
|
390
|
-
},
|
|
391
|
-
'section-shell': {
|
|
392
|
-
name: 'section-shell',
|
|
393
|
-
ancestry: 'opus',
|
|
394
|
-
whenToUse:
|
|
395
|
-
'Uma SEÇÃO com navegação própria — o nível entre AppShell (o app) e Page (uma tela): nav w-56 com filete + painel, pras telas irmãs de Configurações, Relatórios ou doc. Use quando a seção é visitada raro e navegada por dentro quando visitada (não vale queimar item na sidebar do app), ou quando a lista é dinâmica demais pra um nav estático. Pra facetas do MESMO objeto, Tabs. Controlado (activeId + onSelect): o roteamento é do app. Grupos aceitam `items` e/ou `subgroups` (o 3º nível, ex.: sub-pasta na doc). O painel remonta quando o `activeId` muda — é o que zera o scroll; `scrollResetKey` sobrescreve a chave nos dois sentidos: constante = nunca remonta (preserva o scroll), mais fina que o activeId = remonta também dentro da mesma tela.',
|
|
396
|
-
deprecated: { alternative: '`Split` + `Pane` + `Sidebar`', since: 'próxima versão' },
|
|
397
|
-
},
|
|
398
377
|
'router': {
|
|
399
378
|
name: 'router',
|
|
400
379
|
ancestry: 'opus',
|
package/src/ui/react.tsx
CHANGED
|
@@ -164,7 +164,6 @@ export { Kbd, KbdGroup } from './components/primitives/kbd.tsx'
|
|
|
164
164
|
export { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from './components/primitives/pagination.tsx'
|
|
165
165
|
export { Progress } from './components/primitives/progress.tsx'
|
|
166
166
|
export { RadioGroup, RadioGroupItem } from './components/primitives/radio-group.tsx'
|
|
167
|
-
export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from './components/primitives/resizable.tsx'
|
|
168
167
|
export { ScrollArea, ScrollBar } from './components/primitives/scroll-area.tsx'
|
|
169
168
|
export { Slider } from './components/primitives/slider.tsx'
|
|
170
169
|
export { Switch } from './components/primitives/switch.tsx'
|
|
@@ -206,29 +205,15 @@ export type { DataStateProps } from './components/patterns/data-state.tsx'
|
|
|
206
205
|
export { Page } from './components/patterns/page.tsx'
|
|
207
206
|
export type { PageProps } from './components/patterns/page.tsx'
|
|
208
207
|
|
|
209
|
-
// Chrome da aplicação (sidebar + conteúdo + rail opcional redimensionável).
|
|
210
|
-
export { AppShell, AppShellBar, AppShellTrigger, useAppShell, useSidebarSlot } from './components/patterns/app-shell.tsx'
|
|
211
|
-
export type { AppShellProps } from './components/patterns/app-shell.tsx'
|
|
212
|
-
|
|
213
208
|
// Layout composicional: Split decide a relação espacial; Pane carrega conteúdo com inset.
|
|
214
209
|
export { Split, Pane } from './components/patterns/split.tsx'
|
|
215
210
|
export type { SplitProps, PaneProps } from './components/patterns/split.tsx'
|
|
216
211
|
|
|
217
212
|
// Barra lateral composicional: header/conteúdo/footer e navegação, sem possuir o layout.
|
|
218
|
-
export { PaneHeader, PaneContent, PaneFooter, Sidebar,
|
|
219
|
-
export type { SidebarProps, SidebarItemProps, SidebarNavGroup } from './components/patterns/sidebar.tsx'
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
export { SectionShell } from './components/patterns/section-shell.tsx'
|
|
223
|
-
export type {
|
|
224
|
-
SectionShellProps,
|
|
225
|
-
SectionNavGroup,
|
|
226
|
-
SectionNavSubgroup,
|
|
227
|
-
SectionNavItem,
|
|
228
|
-
} from './components/patterns/section-shell.tsx'
|
|
229
|
-
|
|
230
|
-
// O menu como primitivo PIVOTÁVEL: mesmo nav na sidebar do AppShell ou dentro do conteúdo
|
|
231
|
-
// (colapso por CSS via o grupo `sidebar`). Ver docs/shellnav.md.
|
|
213
|
+
export { PaneHeader, PaneContent, PaneFooter, Sidebar, SidebarItem, SidebarNav } from './components/patterns/sidebar.tsx'
|
|
214
|
+
export type { SidebarProps, SidebarItemProps, SidebarNavGroup, SidebarNavSubgroup, SidebarNavItem } from './components/patterns/sidebar.tsx'
|
|
215
|
+
|
|
216
|
+
// O menu como primitivo pivotável: recolhe quando composto dentro de Sidebar.
|
|
232
217
|
export { ShellNav, ShellNavHeading } from './components/patterns/shell-nav.tsx'
|
|
233
218
|
export type { ShellNavProps, ShellNavGroup, ShellNavItem, ShellNavHeadingProps } from './components/patterns/shell-nav.tsx'
|
|
234
219
|
|