@softize/opus 12.3.0 → 12.5.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 +45 -0
- package/bin/cli.mjs +2 -2
- package/bin/lib/copy.mjs +4 -0
- package/docs/releasing.md +2 -2
- package/package.json +11 -3
- package/registry/templates/app/package.json +1 -1
- package/src/cache/drivers/memory.ts +82 -0
- package/src/cache/index.ts +15 -0
- package/src/core/index.ts +2 -0
- package/src/core/runtime.ts +8 -0
- package/src/core/types.ts +25 -0
- package/src/testing/index.ts +3 -0
- package/src/ui/components/patterns/dock.tsx +182 -0
- package/src/ui/components/patterns/shell-nav.tsx +1 -0
- package/src/ui/components/patterns/sidebar.tsx +55 -12
- package/src/ui/components/primitives/dot.tsx +39 -0
- package/src/ui/components/primitives/select.tsx +5 -2
- package/src/ui/components/primitives/table.tsx +12 -2
- package/src/ui/components/primitives/truncate.tsx +9 -20
- package/src/ui/docs/content/dock.md +69 -0
- package/src/ui/docs/content/dot.md +17 -0
- package/src/ui/docs/content/select.md +1 -0
- package/src/ui/docs/content/sidebar.md +21 -0
- package/src/ui/docs/content/table.md +25 -24
- package/src/ui/docs/content/truncate.md +26 -0
- package/src/ui/docs/registry.tsx +4 -0
- package/src/ui/lib/overflow.ts +34 -0
- package/src/ui/meta.ts +14 -2
- package/src/ui/react.tsx +12 -1
- package/src/ui/theme.css +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,51 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
|
|
|
7
7
|
`opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
|
|
8
8
|
mudança cobra do seu código.
|
|
9
9
|
|
|
10
|
+
## 12.5.0 — 2026-08-24
|
|
11
|
+
|
|
12
|
+
Cache vira porta do protocolo. `CacheAdapter` chega aos handlers como `ctx.cache`, com a superfície
|
|
13
|
+
mínima que um handler usa — `get`, `set`, `delete` — e cresce por reincidência, como a do storage.
|
|
14
|
+
O **miss é o `null`**: quem observa a execução distingue acerto de ausência pelo retorno, sem o
|
|
15
|
+
adapter reportar métrica por fora, e erro de infraestrutura continua sendo exceção — cache
|
|
16
|
+
indisponível não deve virar "não estava em cache".
|
|
17
|
+
|
|
18
|
+
`cache/memory` é o driver de processo, para desenvolvimento e para quem roda numa instância só.
|
|
19
|
+
Expira preguiçosamente (a entrada vencida some quando alguém a procura, sem timer permanente),
|
|
20
|
+
tem teto de entradas para não crescer sem limite dentro de um servidor de longa duração, e expõe
|
|
21
|
+
`sweep()` e `size()` para quem quiser forçar a limpeza ou observar o tamanho.
|
|
22
|
+
|
|
23
|
+
Nada é obrigatório: sem adapter montado, `ctx.cache` é `null` e o handler decide o que fazer.
|
|
24
|
+
|
|
25
|
+
## 12.4.0 — 2026-08-24
|
|
26
|
+
|
|
27
|
+
Superfícies de trabalho — canvas, editor, preview — ganham os dois lugares que faltavam. `Dock`
|
|
28
|
+
ancora as ferramentas à própria superfície, com `DockGroup` separando intenções e `DockAction`
|
|
29
|
+
distinguindo modo de execução; a barra é uma `toolbar` de verdade, com setas e Home/End entre as
|
|
30
|
+
ações. `SurfaceStatus` recebe o que a superfície diz sobre si — salvamento, versão, execução
|
|
31
|
+
percorrida — num canto estável, fora da barra: estado não é ação, e uma toolbar que carrega texto
|
|
32
|
+
vivo deixa de ser navegável como toolbar.
|
|
33
|
+
|
|
34
|
+
O truncamento passa a ter uma segunda forma. `Truncate` aceita `fade`, e a utilitária
|
|
35
|
+
`truncate-fade` esmaece o fim da linha no lugar das reticências. O hook `useOverflowing`, extraído
|
|
36
|
+
do próprio `Truncate`, fica exposto para quem precisa do sinal de corte sem o tooltip — é o que
|
|
37
|
+
mantém a máscara fora do texto que cabe inteiro.
|
|
38
|
+
|
|
39
|
+
`SidebarItem` vira a linha das árvores de navegação: `actions`, `onToggle`/`expanded` e
|
|
40
|
+
`dropPosition` são irmãos do destino, nunca botões aninhados, e o rótulo cortado esmaece até a
|
|
41
|
+
borda. Ele e o item do `ShellNav` também ganham foco visível com o anel do `Button` — antes a
|
|
42
|
+
navegação por teclado caía no anel do navegador, que destoa do tema.
|
|
43
|
+
|
|
44
|
+
Entram ainda `Dot`, para estado compacto onde o contexto já explica o significado, e
|
|
45
|
+
`Table variant="framed"`, que traz para o componente a moldura de datagrid antes montada à mão em
|
|
46
|
+
cada consumidor. `Select` passa a aplicar `className` na raiz do controle em todos os modos.
|
|
47
|
+
|
|
48
|
+
O `opus copy` reconhece `Dock.label`, `DockAction.label` e `DockAction.hint`: componente com texto
|
|
49
|
+
em prop precisa entrar no mapa de papéis, senão a copy sai do inventário em silêncio quando uma
|
|
50
|
+
superfície migra de `aria-label` para prop.
|
|
51
|
+
|
|
52
|
+
Tudo é aditivo e não exige migração. Consumidores que montavam a moldura da tabela à mão podem
|
|
53
|
+
trocar o wrapper por `variant="framed"` quando quiserem.
|
|
54
|
+
|
|
10
55
|
## 12.3.0 — 2026-08-23
|
|
11
56
|
|
|
12
57
|
O Opus deixa de definir `font-size` em `html`. A fonte raiz volta a pertencer ao navegador e à
|
package/bin/cli.mjs
CHANGED
|
@@ -509,8 +509,8 @@ Fundação de UI (só apps web — preset Tailwind + tema + flags do Opus):
|
|
|
509
509
|
tema/tsconfig/dep — avisa o que falta plugar (não edita seus arquivos: sem clobber)
|
|
510
510
|
|
|
511
511
|
Este comando materializa somente os artefatos Opus. O template encadeia
|
|
512
|
-
\`opus setup && base setup\`: a Base
|
|
513
|
-
está habilitado; Maestro continua opcional.
|
|
512
|
+
\`opus setup && base setup\`: a versão da Base registrada em base.json é obrigatória quando
|
|
513
|
+
o gate universal de copy está habilitado; Maestro continua opcional.
|
|
514
514
|
`)
|
|
515
515
|
return
|
|
516
516
|
}
|
package/bin/lib/copy.mjs
CHANGED
|
@@ -130,6 +130,10 @@ const JSX_PROP_ROLES = new Map([
|
|
|
130
130
|
['Alert', new Map([['title', 'title'], ['description', 'message']])],
|
|
131
131
|
['CommandInput', new Map([['placeholder', 'placeholder']])],
|
|
132
132
|
['DataState', new Map([['emptyText', 'empty-state'], ['errorText', 'error']])],
|
|
133
|
+
// A Dock nomeia a barra e cada ação por prop. Sem estas linhas, a copy sairia do inventário
|
|
134
|
+
// exatamente quando uma superfície migra de <Button aria-label> para <DockAction label>.
|
|
135
|
+
['Dock', new Map([['label', 'label']])],
|
|
136
|
+
['DockAction', new Map([['label', 'label'], ['hint', 'description']])],
|
|
133
137
|
['Input', new Map([['placeholder', 'placeholder']])],
|
|
134
138
|
['InputGroupInput', new Map([['placeholder', 'placeholder']])],
|
|
135
139
|
['InputGroupTextarea', new Map([['placeholder', 'placeholder']])],
|
package/docs/releasing.md
CHANGED
|
@@ -53,7 +53,7 @@ pra testar publicação seria a guarda atrapalhando quem está experimentando.
|
|
|
53
53
|
|
|
54
54
|
**Gate de qualidade**: depois do bump e da materialização, mas antes de publicar, roda
|
|
55
55
|
`pnpm typecheck` + `pnpm test` + `pnpm copy:check` + `base copy check` e **aborta a release
|
|
56
|
-
se qualquer um falhar**. O Opus declara `@softize/base ^2.
|
|
56
|
+
se qualquer um falhar**. O Opus declara `@softize/base ^2.1.0` em `dependencies`, pois usa
|
|
57
57
|
suas APIs públicas de filesystem em runtime, e materializa os artefatos Base no próprio repo;
|
|
58
58
|
a release não baixa uma política ad hoc. Como o
|
|
59
59
|
Opus **ship source** (`.ts`, sem build), essa é a última barreira antes do tarball — sem ela,
|
|
@@ -63,7 +63,7 @@ um `tsc` vermelho, inventário desatualizado ou violação da política vaza pro
|
|
|
63
63
|
**Smoke do esqueleto**: depois do bump e antes do publish, o `release.sh` gera um app com
|
|
64
64
|
`opus create`, instala o **tarball exato** que vai ser publicado e roda os gates dele
|
|
65
65
|
(typecheck · test · `opus check` · `opus copy --check` · `base copy check` · manifest ·
|
|
66
|
-
build). O template exige `@softize/base ^2.
|
|
66
|
+
build). O template exige `@softize/base ^2.1.0`; publique a Base compatível antes do Opus.
|
|
67
67
|
O `minimumReleaseAgeExclude` do template inclui os dois pacotes, e o smoke executa os
|
|
68
68
|
fragmentos de pre-push para provar que o layout pnpm instalado resolve ambos os CLIs.
|
|
69
69
|
É o que pega o que typecheck+test não
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softize/opus",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.5.0",
|
|
4
4
|
"description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -122,6 +122,14 @@
|
|
|
122
122
|
"./storage": "./src/storage/index.ts",
|
|
123
123
|
"./storage/fs": "./src/storage/drivers/fs.ts",
|
|
124
124
|
"./storage/s3": "./src/storage/drivers/s3.ts",
|
|
125
|
+
"./cache": {
|
|
126
|
+
"types": "./src/cache/index.ts",
|
|
127
|
+
"default": "./src/cache/index.ts"
|
|
128
|
+
},
|
|
129
|
+
"./cache/memory": {
|
|
130
|
+
"types": "./src/cache/drivers/memory.ts",
|
|
131
|
+
"default": "./src/cache/drivers/memory.ts"
|
|
132
|
+
},
|
|
125
133
|
"./ai": "./src/ai/index.ts",
|
|
126
134
|
"./ai/anthropic": "./src/ai/drivers/anthropic.ts",
|
|
127
135
|
"./mcp": "./src/mcp/index.ts",
|
|
@@ -201,7 +209,6 @@
|
|
|
201
209
|
},
|
|
202
210
|
"dependencies": {
|
|
203
211
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
204
|
-
"@softize/base": "^2.0.0",
|
|
205
212
|
"@radix-ui/react-checkbox": "^1.1.3",
|
|
206
213
|
"@radix-ui/react-dialog": "^1.1.4",
|
|
207
214
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
|
@@ -211,6 +218,7 @@
|
|
|
211
218
|
"@radix-ui/react-slot": "^1.1.1",
|
|
212
219
|
"@radix-ui/react-tabs": "^1.1.2",
|
|
213
220
|
"@radix-ui/react-tooltip": "^1.1.6",
|
|
221
|
+
"@softize/base": "^2.1.0",
|
|
214
222
|
"@tailwindcss/typography": "^0.5.20",
|
|
215
223
|
"@types/markdown-it": "^14.1.2",
|
|
216
224
|
"class-variance-authority": "^0.7.1",
|
|
@@ -234,11 +242,11 @@
|
|
|
234
242
|
"zod-to-json-schema": "^3.23.0"
|
|
235
243
|
},
|
|
236
244
|
"peerDependencies": {
|
|
237
|
-
"@opentelemetry/api": "^1.9.0",
|
|
238
245
|
"@anthropic-ai/sdk": ">=0.35.0",
|
|
239
246
|
"@aws-sdk/client-s3": "^3.0.0",
|
|
240
247
|
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
241
248
|
"@hookform/resolvers": "^3.0.0",
|
|
249
|
+
"@opentelemetry/api": "^1.9.0",
|
|
242
250
|
"@tanstack/react-query": "^5.0.0",
|
|
243
251
|
"bullmq": "^5.0.0",
|
|
244
252
|
"fastify": "^5.0.0",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driver de cache em memória do processo.
|
|
3
|
+
*
|
|
4
|
+
* Serve desenvolvimento e o caso de um único processo. Não compartilha nada entre
|
|
5
|
+
* instâncias — quem precisa disso usa um driver distribuído, e a troca é só a montagem.
|
|
6
|
+
*
|
|
7
|
+
* A expiração é preguiçosa: a entrada vencida some quando alguém a procura. Sem varredura
|
|
8
|
+
* de fundo, um cache de processo não justifica um timer permanente; `sweep()` existe para
|
|
9
|
+
* quem quiser forçar a limpeza.
|
|
10
|
+
*/
|
|
11
|
+
import type { CacheAdapter, CacheSetOptions } from '../../core/index.ts'
|
|
12
|
+
|
|
13
|
+
interface Entry {
|
|
14
|
+
value: unknown
|
|
15
|
+
/** Epoch em milissegundos; `null` = não expira. */
|
|
16
|
+
expiresAt: number | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MemoryCacheOptions {
|
|
20
|
+
/** Expiração padrão quando a chamada não passa `ttlSeconds`. Ausente = não expira. */
|
|
21
|
+
defaultTtlSeconds?: number
|
|
22
|
+
/** Teto de entradas. Passando dele, a mais antiga sai — um cache de processo não deve
|
|
23
|
+
* crescer sem limite dentro de um servidor de longa duração. */
|
|
24
|
+
maxEntries?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MemoryCache extends CacheAdapter {
|
|
28
|
+
/** Remove as entradas vencidas agora. */
|
|
29
|
+
sweep(): number
|
|
30
|
+
/** Quantas entradas o cache guarda neste momento. */
|
|
31
|
+
size(): number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function memoryCache(options: MemoryCacheOptions = {}): MemoryCache {
|
|
35
|
+
const entries = new Map<string, Entry>()
|
|
36
|
+
const maxEntries = options.maxEntries ?? 10_000
|
|
37
|
+
|
|
38
|
+
const expired = (entry: Entry, now: number): boolean => entry.expiresAt !== null && entry.expiresAt <= now
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
name: 'memory',
|
|
42
|
+
kind: 'cache',
|
|
43
|
+
get<T>(key: string): Promise<T | null> {
|
|
44
|
+
const entry = entries.get(key)
|
|
45
|
+
if (entry === undefined) return Promise.resolve(null)
|
|
46
|
+
if (expired(entry, Date.now())) {
|
|
47
|
+
entries.delete(key)
|
|
48
|
+
return Promise.resolve(null)
|
|
49
|
+
}
|
|
50
|
+
return Promise.resolve(entry.value as T)
|
|
51
|
+
},
|
|
52
|
+
set<T>(key: string, value: T, opts: CacheSetOptions = {}): Promise<void> {
|
|
53
|
+
const ttl = opts.ttlSeconds ?? options.defaultTtlSeconds
|
|
54
|
+
// Reinserir move a chave para o fim da ordem do Map, que é o que faz o descarte
|
|
55
|
+
// abaixo tirar a MAIS ANTIGA, e não a que acabou de ser escrita.
|
|
56
|
+
entries.delete(key)
|
|
57
|
+
entries.set(key, { value, expiresAt: ttl === undefined ? null : Date.now() + ttl * 1000 })
|
|
58
|
+
if (entries.size > maxEntries) {
|
|
59
|
+
const oldest = entries.keys().next()
|
|
60
|
+
if (!oldest.done) entries.delete(oldest.value)
|
|
61
|
+
}
|
|
62
|
+
return Promise.resolve()
|
|
63
|
+
},
|
|
64
|
+
delete(key: string): Promise<void> {
|
|
65
|
+
entries.delete(key)
|
|
66
|
+
return Promise.resolve()
|
|
67
|
+
},
|
|
68
|
+
sweep(): number {
|
|
69
|
+
const now = Date.now()
|
|
70
|
+
let removed = 0
|
|
71
|
+
for (const [key, entry] of entries) {
|
|
72
|
+
if (expired(entry, now)) {
|
|
73
|
+
entries.delete(key)
|
|
74
|
+
removed += 1
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return removed
|
|
78
|
+
},
|
|
79
|
+
size: () => entries.size,
|
|
80
|
+
healthCheck: () => Promise.resolve({ ok: true as const, details: { entries: entries.size } }),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/cache — helpers compartilhados dos drivers de cache.
|
|
3
|
+
*
|
|
4
|
+
* EXPERIMENTAL. O contrato (`CacheAdapter`) vive no core; o driver de memória fica em
|
|
5
|
+
* `cache/memory`. A superfície é a mínima que um handler usa — guardar, ler, remover — e
|
|
6
|
+
* cresce por reincidência, não por especulação.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type { CacheAdapter, CacheSetOptions } from '../core/index.ts'
|
|
10
|
+
|
|
11
|
+
/** Monta uma chave estável a partir de partes. Evita a concatenação à mão que produz
|
|
12
|
+
* chaves parecidas e colidentes entre domínios. */
|
|
13
|
+
export function cacheKey(...parts: (string | number)[]): string {
|
|
14
|
+
return parts.map((part) => String(part)).join(':')
|
|
15
|
+
}
|
package/src/core/index.ts
CHANGED
package/src/core/runtime.ts
CHANGED
|
@@ -61,6 +61,7 @@ import type {
|
|
|
61
61
|
SchedulerAdapter,
|
|
62
62
|
ServerAdapter,
|
|
63
63
|
StorageAdapter,
|
|
64
|
+
CacheAdapter,
|
|
64
65
|
AiAdapter,
|
|
65
66
|
AiRunOptions,
|
|
66
67
|
BoundAiRunOptions,
|
|
@@ -97,6 +98,8 @@ export interface RuntimeSetup {
|
|
|
97
98
|
client?: ClientAdapter
|
|
98
99
|
/** EXPERIMENTAL — storage de objetos (arquivos); chega nos handlers via `ctx.storage`. */
|
|
99
100
|
storage?: StorageAdapter
|
|
101
|
+
/** EXPERIMENTAL — cache de leitura; chega nos handlers via `ctx.cache`. */
|
|
102
|
+
cache?: CacheAdapter
|
|
100
103
|
/** EXPERIMENTAL — IA generativa (complete/extract); chega nos handlers via `ctx.ai`. */
|
|
101
104
|
ai?: AiAdapter
|
|
102
105
|
|
|
@@ -243,6 +246,7 @@ export class Runtime {
|
|
|
243
246
|
private readonly scheduler: SchedulerAdapter | undefined
|
|
244
247
|
private readonly client: ClientAdapter | undefined
|
|
245
248
|
private readonly storage: StorageAdapter | undefined
|
|
249
|
+
private readonly cache: CacheAdapter | undefined
|
|
246
250
|
private readonly ai: AiAdapter | undefined
|
|
247
251
|
/** Cache das tools derivadas das actions `ai:enabled` (registry é estático pós-start). */
|
|
248
252
|
private aiToolsCache: AiTool[] | null = null
|
|
@@ -293,6 +297,7 @@ export class Runtime {
|
|
|
293
297
|
this.scheduler = setup.scheduler
|
|
294
298
|
this.client = setup.client
|
|
295
299
|
this.storage = setup.storage
|
|
300
|
+
this.cache = setup.cache
|
|
296
301
|
this.ai = setup.ai
|
|
297
302
|
}
|
|
298
303
|
|
|
@@ -764,6 +769,7 @@ export class Runtime {
|
|
|
764
769
|
if (this.scheduler !== undefined) yield this.scheduler
|
|
765
770
|
if (this.client !== undefined) yield this.client
|
|
766
771
|
if (this.storage !== undefined) yield this.storage
|
|
772
|
+
if (this.cache !== undefined) yield this.cache
|
|
767
773
|
if (this.ai !== undefined) yield this.ai
|
|
768
774
|
}
|
|
769
775
|
|
|
@@ -860,6 +866,7 @@ export class Runtime {
|
|
|
860
866
|
log,
|
|
861
867
|
emit: this.buildEmit(action, actionId, base, trace),
|
|
862
868
|
storage: this.storage ?? null,
|
|
869
|
+
cache: this.cache ?? null,
|
|
863
870
|
ai: this.bindAi({ ...base, ...(trace !== undefined ? { trace } : {}) }),
|
|
864
871
|
provenance,
|
|
865
872
|
...(trace !== undefined ? { trace } : {}),
|
|
@@ -1192,6 +1199,7 @@ export class Runtime {
|
|
|
1192
1199
|
log: reactionLog,
|
|
1193
1200
|
emit: noopEmit,
|
|
1194
1201
|
storage: this.storage ?? null,
|
|
1202
|
+
cache: this.cache ?? null,
|
|
1195
1203
|
// Reação = contexto de sistema; o agente só alcança actions públicas (can nega por
|
|
1196
1204
|
// padrão — nada de LLM disparado por evento chamando action gated sem gate explícito).
|
|
1197
1205
|
ai: this.bindAi({
|
package/src/core/types.ts
CHANGED
|
@@ -277,6 +277,7 @@ export interface ActionContext {
|
|
|
277
277
|
log: Logger
|
|
278
278
|
emit: EmitFn
|
|
279
279
|
storage: StorageAdapter | null
|
|
280
|
+
cache: CacheAdapter | null
|
|
280
281
|
ai: BoundAi | null
|
|
281
282
|
provenance: Provenance
|
|
282
283
|
trace?: TraceContext
|
|
@@ -294,6 +295,7 @@ export interface ReactionContext {
|
|
|
294
295
|
log: Logger
|
|
295
296
|
emit: EmitFn
|
|
296
297
|
storage: StorageAdapter | null
|
|
298
|
+
cache: CacheAdapter | null
|
|
297
299
|
ai: BoundAi | null
|
|
298
300
|
provenance: Provenance
|
|
299
301
|
trace?: TraceContext
|
|
@@ -941,6 +943,7 @@ export type AdapterKind =
|
|
|
941
943
|
| 'scheduler'
|
|
942
944
|
| 'client'
|
|
943
945
|
| 'storage'
|
|
946
|
+
| 'cache'
|
|
944
947
|
| 'ai'
|
|
945
948
|
| 'ui'
|
|
946
949
|
| 'schema'
|
|
@@ -1167,6 +1170,28 @@ export interface StorageAdapter extends Adapter {
|
|
|
1167
1170
|
url(key: string, opts?: { expiresInSeconds?: number }): Promise<string>
|
|
1168
1171
|
}
|
|
1169
1172
|
|
|
1173
|
+
/**
|
|
1174
|
+
* EXPERIMENTAL — cache de leitura. A superfície é a mínima que um handler usa e cresce
|
|
1175
|
+
* por reincidência, como a do storage.
|
|
1176
|
+
*
|
|
1177
|
+
* O MISS é o `null`: quem observa a execução distingue acerto de ausência pelo retorno,
|
|
1178
|
+
* sem o adapter precisar reportar métrica por fora. Erro de infraestrutura é outra coisa
|
|
1179
|
+
* e continua sendo exceção — cache indisponível não deve virar "não estava em cache".
|
|
1180
|
+
*/
|
|
1181
|
+
export interface CacheAdapter extends Adapter {
|
|
1182
|
+
kind: 'cache'
|
|
1183
|
+
/** Valor guardado, ou `null` quando a chave não está presente (ou expirou). */
|
|
1184
|
+
get<T>(key: string): Promise<T | null>
|
|
1185
|
+
/** Guarda o valor. Sem `ttlSeconds`, a expiração é a que o driver definir. */
|
|
1186
|
+
set<T>(key: string, value: T, opts?: CacheSetOptions): Promise<void>
|
|
1187
|
+
/** Remove. Idempotente: remover o que não existe não é erro. */
|
|
1188
|
+
delete(key: string): Promise<void>
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
export interface CacheSetOptions {
|
|
1192
|
+
ttlSeconds?: number
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1170
1195
|
/** Opções por chamada do `AiAdapter` — tudo tem default do driver. */
|
|
1171
1196
|
export interface AiCompleteOptions {
|
|
1172
1197
|
/** System prompt da chamada. */
|
package/src/testing/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import type {
|
|
|
43
43
|
Paginated,
|
|
44
44
|
Schema,
|
|
45
45
|
StorageAdapter,
|
|
46
|
+
CacheAdapter,
|
|
46
47
|
StorageObject,
|
|
47
48
|
StoragePutOptions,
|
|
48
49
|
User,
|
|
@@ -72,6 +73,7 @@ export interface TestContextOptions {
|
|
|
72
73
|
can?: ActionContext['can']
|
|
73
74
|
db?: unknown
|
|
74
75
|
storage?: StorageAdapter | null
|
|
76
|
+
cache?: CacheAdapter | null
|
|
75
77
|
ai?: AiAdapter | null
|
|
76
78
|
meta?: Record<string, unknown>
|
|
77
79
|
trace?: TraceContext
|
|
@@ -133,6 +135,7 @@ export function testContext(options: TestContextOptions = {}): TestContext {
|
|
|
133
135
|
return Promise.resolve()
|
|
134
136
|
},
|
|
135
137
|
storage: options.storage ?? null,
|
|
138
|
+
cache: options.cache ?? null,
|
|
136
139
|
ai: options.ai ? toBoundAi(options.ai) : null,
|
|
137
140
|
provenance: { kind: 'system', source: 'test' },
|
|
138
141
|
...(options.trace !== undefined ? { trace: options.trace } : {}),
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <Dock /> — a barra de ferramentas que flutua sobre uma superfície de trabalho.
|
|
3
|
+
*
|
|
4
|
+
* Canvas, editor e preview têm o mesmo problema: as ferramentas não cabem num cabeçalho
|
|
5
|
+
* sem empilhar mais uma faixa de chrome sobre a trilha que a aplicação já desenha. A Dock
|
|
6
|
+
* resolve isso ancorando as ações à própria superfície, agrupadas e sempre visíveis.
|
|
7
|
+
*
|
|
8
|
+
* O componente é dono da moldura, dos separadores entre grupos, da forma dos botões e da
|
|
9
|
+
* semântica de toolbar (`role="toolbar"` com navegação por setas). QUAIS ferramentas
|
|
10
|
+
* existem e o que cada uma faz continua sendo da aplicação.
|
|
11
|
+
*
|
|
12
|
+
* Requer `<TooltipProvider>` na raiz do app — cada ação nomeia-se por tooltip.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as React from 'react'
|
|
16
|
+
import { cn } from '../../lib/cn.ts'
|
|
17
|
+
import { Button } from '../primitives/button.tsx'
|
|
18
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip.tsx'
|
|
19
|
+
|
|
20
|
+
const POSITION = {
|
|
21
|
+
bottom: 'bottom-3 left-1/2 -translate-x-1/2',
|
|
22
|
+
'bottom-left': 'bottom-3 left-3',
|
|
23
|
+
'bottom-right': 'bottom-3 right-3',
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
export interface DockProps {
|
|
27
|
+
/** Aresta da superfície onde a barra se ancora. */
|
|
28
|
+
position?: keyof typeof POSITION
|
|
29
|
+
/** Nome acessível da barra; é o que a tecnologia assistiva anuncia ao entrar nela. */
|
|
30
|
+
label: string
|
|
31
|
+
className?: string
|
|
32
|
+
children: React.ReactNode
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Barra flutuante ancorada à superfície. O contêiner precisa ser `relative`. */
|
|
36
|
+
export function Dock({ position = 'bottom', label, className, children }: DockProps): React.ReactElement {
|
|
37
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
38
|
+
|
|
39
|
+
// Navegação de toolbar: as setas andam entre as ações, e Home/End vão às pontas. Sem isso
|
|
40
|
+
// uma barra com dez ícones cobra dez Tabs de quem navega por teclado.
|
|
41
|
+
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
42
|
+
const keys = ['ArrowRight', 'ArrowLeft', 'Home', 'End']
|
|
43
|
+
if (!keys.includes(event.key) || ref.current === null) return
|
|
44
|
+
const items = Array.from(ref.current.querySelectorAll<HTMLButtonElement>('button:not([disabled])'))
|
|
45
|
+
if (items.length === 0) return
|
|
46
|
+
const current = items.indexOf(document.activeElement as HTMLButtonElement)
|
|
47
|
+
if (current === -1) return
|
|
48
|
+
event.preventDefault()
|
|
49
|
+
const next =
|
|
50
|
+
event.key === 'Home'
|
|
51
|
+
? 0
|
|
52
|
+
: event.key === 'End'
|
|
53
|
+
? items.length - 1
|
|
54
|
+
: event.key === 'ArrowRight'
|
|
55
|
+
? (current + 1) % items.length
|
|
56
|
+
: (current - 1 + items.length) % items.length
|
|
57
|
+
items[next]?.focus()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
ref={ref}
|
|
63
|
+
data-slot="dock"
|
|
64
|
+
role="toolbar"
|
|
65
|
+
aria-label={label}
|
|
66
|
+
aria-orientation="horizontal"
|
|
67
|
+
onKeyDown={onKeyDown}
|
|
68
|
+
className={cn(
|
|
69
|
+
'absolute z-10 flex max-w-[calc(100%-1.5rem)] flex-row items-center gap-1.5 overflow-x-auto',
|
|
70
|
+
'rounded-2xl border border-border bg-background/95 p-1.5 shadow-lg backdrop-blur',
|
|
71
|
+
POSITION[position],
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Grupo de ações afins. A divisória entre grupos é do componente, não do consumidor. */
|
|
81
|
+
export function DockGroup({ className, children }: { className?: string; children: React.ReactNode }): React.ReactElement {
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
data-slot="dock-group"
|
|
85
|
+
className={cn(
|
|
86
|
+
'flex shrink-0 flex-row items-center gap-1 border-l border-border pl-1.5',
|
|
87
|
+
'first:border-l-0 first:pl-0',
|
|
88
|
+
className,
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
{children}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface DockActionProps {
|
|
97
|
+
icon: React.ReactNode
|
|
98
|
+
/** Nome da ação: vai para o tooltip e para o nome acessível. */
|
|
99
|
+
label: string
|
|
100
|
+
/** Presente quando a ação liga um modo; ausente quando ela apenas executa. */
|
|
101
|
+
pressed?: boolean
|
|
102
|
+
disabled?: boolean
|
|
103
|
+
/** Conteúdo do tooltip quando ele precisa dizer mais que o rótulo. */
|
|
104
|
+
hint?: React.ReactNode
|
|
105
|
+
className?: string
|
|
106
|
+
onClick: () => void
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Ação de ícone da Dock. Com `pressed`, comunica modo ligado em vez de execução. */
|
|
110
|
+
export function DockAction({ icon, label, pressed, disabled = false, hint, className, onClick }: DockActionProps): React.ReactElement {
|
|
111
|
+
return (
|
|
112
|
+
<Tooltip>
|
|
113
|
+
<TooltipTrigger asChild>
|
|
114
|
+
<Button
|
|
115
|
+
data-slot="dock-action"
|
|
116
|
+
size="icon"
|
|
117
|
+
variant={pressed === true ? 'default' : 'ghost'}
|
|
118
|
+
aria-label={label}
|
|
119
|
+
aria-pressed={pressed}
|
|
120
|
+
disabled={disabled}
|
|
121
|
+
onClick={onClick}
|
|
122
|
+
className={cn('size-9 shrink-0 rounded-xl', className)}
|
|
123
|
+
>
|
|
124
|
+
{icon}
|
|
125
|
+
</Button>
|
|
126
|
+
</TooltipTrigger>
|
|
127
|
+
<TooltipContent>{hint ?? label}</TooltipContent>
|
|
128
|
+
</Tooltip>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const STATUS_POSITION = {
|
|
133
|
+
'top-right': 'top-3 right-3',
|
|
134
|
+
'top-left': 'top-3 left-3',
|
|
135
|
+
} as const
|
|
136
|
+
|
|
137
|
+
export interface SurfaceStatusProps {
|
|
138
|
+
/** Canto da superfície onde o estado se ancora. */
|
|
139
|
+
position?: keyof typeof STATUS_POSITION
|
|
140
|
+
/** Ações do recurso aberto — em geral o mesmo menu que a linha dele tem na navegação. */
|
|
141
|
+
actions?: React.ReactNode
|
|
142
|
+
className?: string
|
|
143
|
+
children?: React.ReactNode
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* O que a superfície diz sobre SI — salvamento, versão, execução percorrida — flutuando no canto,
|
|
148
|
+
* separado das ferramentas. Fica fora da Dock de propósito: estado não é ação, e uma toolbar que
|
|
149
|
+
* carrega texto vivo deixa de ser navegável como toolbar.
|
|
150
|
+
*
|
|
151
|
+
* A região é `role="status"` com `aria-live="polite"`: a mudança é anunciada sem roubar o foco.
|
|
152
|
+
* Mensagem de sucesso é transitória por natureza; falha permanece até o estado mudar. Quem decide
|
|
153
|
+
* isso é a aplicação, que conhece o ciclo — o componente só reserva o lugar.
|
|
154
|
+
*/
|
|
155
|
+
export function SurfaceStatus({ position = 'top-right', actions, className, children }: SurfaceStatusProps): React.ReactElement {
|
|
156
|
+
const hasActions = actions !== undefined && actions !== null
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
data-slot="surface-status"
|
|
160
|
+
className={cn(
|
|
161
|
+
'pointer-events-none absolute z-10 flex max-w-[calc(100%-1.5rem)] flex-row items-center gap-2',
|
|
162
|
+
'rounded-full border border-border bg-background/95 py-1.5 pl-3 text-xs text-muted-foreground shadow-sm backdrop-blur',
|
|
163
|
+
hasActions ? 'pr-1' : 'pr-3',
|
|
164
|
+
STATUS_POSITION[position],
|
|
165
|
+
className,
|
|
166
|
+
)}
|
|
167
|
+
>
|
|
168
|
+
{/* Só o texto é região viva: anunciar o menu a cada mudança de estado seria ruído. */}
|
|
169
|
+
<span role="status" aria-live="polite" className="flex min-w-0 flex-row items-center gap-2">
|
|
170
|
+
{children}
|
|
171
|
+
</span>
|
|
172
|
+
{hasActions && (
|
|
173
|
+
<span
|
|
174
|
+
data-slot="surface-status-actions"
|
|
175
|
+
className="pointer-events-auto flex shrink-0 items-center [&_button]:size-6 [&_button]:rounded-full [&_svg]:size-3.5"
|
|
176
|
+
>
|
|
177
|
+
{actions}
|
|
178
|
+
</span>
|
|
179
|
+
)}
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
@@ -74,6 +74,7 @@ function renderItem(item: ShellNavItem, activeId: string | undefined, onSelect:
|
|
|
74
74
|
className={cn(
|
|
75
75
|
'flex w-full items-center gap-2.5 rounded-md py-1.5 text-left text-sm transition-colors',
|
|
76
76
|
'disabled:pointer-events-none disabled:opacity-40',
|
|
77
|
+
'outline-none focus-visible:ring-2 focus-visible:ring-ring/50',
|
|
77
78
|
railed ? 'justify-center px-0' : 'px-2.5',
|
|
78
79
|
active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60',
|
|
79
80
|
)}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { createContext, useContext, type ReactNode } from 'react'
|
|
1
|
+
import { createContext, useContext, type HTMLAttributes, type ReactNode } from 'react'
|
|
2
|
+
import { ChevronRight } from 'lucide-react'
|
|
3
|
+
import { useOverflowing } from '../../lib/overflow.ts'
|
|
2
4
|
import { cn } from '../../lib/cn.ts'
|
|
3
5
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
|
|
4
6
|
|
|
@@ -50,22 +52,58 @@ export interface SidebarItemProps {
|
|
|
50
52
|
badge?: ReactNode
|
|
51
53
|
active?: boolean
|
|
52
54
|
disabled?: boolean
|
|
55
|
+
/** Controles irmãos do alvo de navegação (ex.: menu de ações). */
|
|
56
|
+
actions?: ReactNode
|
|
57
|
+
/** Presente quando a linha desdobra filhos; o controle fica fora do botão principal. */
|
|
58
|
+
onToggle?: () => void
|
|
59
|
+
expanded?: boolean
|
|
60
|
+
/** Posição indicada durante drag-and-drop; bordas inserem, centro recebe dentro. */
|
|
61
|
+
dropPosition?: 'before' | 'inside' | 'after'
|
|
62
|
+
/** Props de drag-and-drop aplicadas à linha, sem acoplar a um driver. */
|
|
63
|
+
dragProps?: HTMLAttributes<HTMLDivElement>
|
|
64
|
+
/** Complemento do tooltip no rail recolhido. */
|
|
65
|
+
tooltipHint?: ReactNode
|
|
53
66
|
className?: string
|
|
54
67
|
onClick: () => void
|
|
55
68
|
}
|
|
56
69
|
|
|
57
|
-
/**
|
|
58
|
-
export function SidebarItem({ label, icon, badge, active = false, disabled = false, className, onClick }: SidebarItemProps): React.ReactElement {
|
|
70
|
+
/** Linha única de navegação: o alvo principal e seus controles são irmãos, nunca botões aninhados. */
|
|
71
|
+
export function SidebarItem({ label, icon, badge, active = false, disabled = false, actions, onToggle, expanded = false, dropPosition, dragProps, tooltipHint, className, onClick }: SidebarItemProps): React.ReactElement {
|
|
59
72
|
const collapsed = useSidebarCollapsed()
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
const hasActions = actions !== undefined && actions !== null
|
|
74
|
+
// O rótulo cortado esmaece em vez de terminar em reticências; a medição do transbordo
|
|
75
|
+
// mantém a máscara fora do rótulo que cabe inteiro na linha.
|
|
76
|
+
const { ref: labelRef, overflowing: labelCut } = useOverflowing<HTMLSpanElement>(label)
|
|
77
|
+
if (collapsed) {
|
|
78
|
+
const button = (
|
|
79
|
+
<button type="button" disabled={disabled} aria-current={active ? 'page' : undefined} onClick={onClick} className={cn('mx-auto grid size-9 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40', 'outline-none focus-visible:ring-2 focus-visible:ring-ring/50', active ? 'bg-muted font-medium text-foreground' : 'text-foreground/80 hover:bg-muted/60', className)}>
|
|
80
|
+
{icon}
|
|
81
|
+
</button>
|
|
82
|
+
)
|
|
83
|
+
return <div {...dragProps}><Tooltip><TooltipTrigger asChild>{button}</TooltipTrigger><TooltipContent side="right">{label}{tooltipHint}</TooltipContent></Tooltip></div>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div data-slot="sidebar-item" className={cn('group/sidebar-item relative flex items-center rounded-md transition-colors', disabled ? 'text-muted-foreground/50 opacity-40' : 'hover:bg-muted/60', active && 'bg-muted', dropPosition === 'inside' && 'bg-primary/5 ring-2 ring-inset ring-primary/60', className)} {...dragProps}>
|
|
88
|
+
{(dropPosition === 'before' || dropPosition === 'after') && <span data-slot="sidebar-item-drop-indicator" className={cn('pointer-events-none absolute inset-x-1 z-20 h-0.5 rounded-full bg-primary before:absolute before:-left-1 before:top-1/2 before:size-2 before:-translate-y-1/2 before:rounded-full before:border-2 before:border-primary before:bg-background', dropPosition === 'before' ? '-top-px' : '-bottom-px')} />}
|
|
89
|
+
{icon !== undefined && (
|
|
90
|
+
<span data-slot="sidebar-item-leading" className="pointer-events-none absolute left-2.5 top-1/2 z-10 grid size-4 -translate-y-1/2 place-items-center">
|
|
91
|
+
<span className={cn('absolute inset-0 flex items-center justify-center transition-opacity', onToggle !== undefined && 'group-hover/sidebar-item:opacity-0 group-has-[[data-slot=sidebar-item-toggle]:focus-visible]/sidebar-item:opacity-0')}>{icon}</span>
|
|
92
|
+
{onToggle !== undefined && (
|
|
93
|
+
<button type="button" data-slot="sidebar-item-toggle" onClick={onToggle} aria-label={expanded ? `Fechar ${label}` : `Abrir ${label}`} aria-expanded={expanded} className="pointer-events-none absolute inset-0 grid place-items-center rounded-sm text-muted-foreground opacity-0 transition-[color,opacity] outline-none hover:text-foreground group-hover/sidebar-item:pointer-events-auto group-hover/sidebar-item:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50">
|
|
94
|
+
<ChevronRight className={cn('size-3 transition-transform', expanded && 'rotate-90')} />
|
|
95
|
+
</button>
|
|
96
|
+
)}
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
<button type="button" disabled={disabled} aria-current={active ? 'page' : undefined} onClick={onClick} className={cn('flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm disabled:pointer-events-none', 'outline-none focus-visible:ring-2 focus-visible:ring-ring/50', disabled ? 'text-muted-foreground/50' : active ? 'font-medium text-foreground' : 'text-foreground/80')}>
|
|
100
|
+
{icon !== undefined && <span data-slot="sidebar-item-leading-space" aria-hidden className="size-4 shrink-0" />}
|
|
101
|
+
<span ref={labelRef} data-slot="sidebar-item-label" className={cn('min-w-0 flex-1 overflow-hidden whitespace-nowrap group-has-[[data-slot=dot]]/sidebar-item:mr-[1.1875rem]', labelCut && 'truncate-fade')}>{label}</span>
|
|
102
|
+
{badge !== undefined && <span data-slot="sidebar-item-badge" className={cn('shrink-0 transition-opacity has-[[data-slot=dot]]:absolute has-[[data-slot=dot]]:right-1 has-[[data-slot=dot]]:top-1/2 has-[[data-slot=dot]]:z-10 has-[[data-slot=dot]]:grid has-[[data-slot=dot]]:size-6 has-[[data-slot=dot]]:-translate-y-1/2 has-[[data-slot=dot]]:place-items-center', hasActions && 'group-hover/sidebar-item:has-[[data-slot=dot]]:opacity-0')}>{badge}</span>}
|
|
103
|
+
</button>
|
|
104
|
+
{hasActions && <div data-slot="sidebar-item-actions" className="pointer-events-none absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center opacity-0 transition-opacity group-hover/sidebar-item:pointer-events-auto group-hover/sidebar-item:opacity-100 has-[:focus-visible]:pointer-events-auto has-[:focus-visible]:opacity-100 has-[[data-state=open]]:pointer-events-auto has-[[data-state=open]]:opacity-100 [&_button]:grid [&_button]:size-6 [&_button]:place-items-center [&_button]:p-0 [&_svg]:size-3.5">{actions}</div>}
|
|
105
|
+
</div>
|
|
66
106
|
)
|
|
67
|
-
if (!collapsed) return button
|
|
68
|
-
return <Tooltip><TooltipTrigger asChild>{button}</TooltipTrigger><TooltipContent side="right">{label}</TooltipContent></Tooltip>
|
|
69
107
|
}
|
|
70
108
|
|
|
71
109
|
export interface SidebarNavGroup {
|
|
@@ -86,6 +124,11 @@ const subFilled = (subgroup: SidebarNavSubgroup): boolean => subgroup.items.leng
|
|
|
86
124
|
const groupFilled = (group: SidebarNavGroup): boolean =>
|
|
87
125
|
(group.items?.length ?? 0) > 0 || (group.subgroups?.some(subFilled) ?? false)
|
|
88
126
|
|
|
127
|
+
/** Rótulo discreto de um grupo de navegação, compartilhado por listas e árvores. */
|
|
128
|
+
export function SidebarGroupLabel({ className, children }: { className?: string; children: ReactNode }): React.ReactElement {
|
|
129
|
+
return <div data-slot="sidebar-group-label" className={cn('px-2.5 pb-1 text-sm font-medium text-muted-foreground/70', className)}>{children}</div>
|
|
130
|
+
}
|
|
131
|
+
|
|
89
132
|
/** Navegação controlada para Sidebar. Header/footer continuam slots explícitos do pai. */
|
|
90
133
|
export function SidebarNav({ groups, activeId, onSelect, navLabel = 'Navegação', className }: { groups: SidebarNavGroup[]; activeId?: string; onSelect: (id: string) => void; navLabel?: string; className?: string }): React.ReactElement {
|
|
91
134
|
const collapsed = useSidebarCollapsed()
|
|
@@ -101,7 +144,7 @@ export function SidebarNav({ groups, activeId, onSelect, navLabel = 'Navegação
|
|
|
101
144
|
const body = (
|
|
102
145
|
<nav aria-label={navLabel} data-slot="sidebar-nav" className={cn('space-y-4 p-2', className)}>
|
|
103
146
|
{groups.map((group, gi) => !groupFilled(group) ? null : <div key={gi} className="space-y-0.5">
|
|
104
|
-
{!collapsed && titled(group.label) && <
|
|
147
|
+
{!collapsed && titled(group.label) && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
|
|
105
148
|
{group.items?.map((item) => renderItem(item))}
|
|
106
149
|
{group.subgroups?.map((subgroup, si) => !subFilled(subgroup) ? null : <div key={si} className="space-y-0.5">
|
|
107
150
|
{!collapsed && titled(subgroup.label) && <div className="pb-0.5 pl-4 pr-2.5 pt-1.5 text-[0.6875rem] font-medium text-muted-foreground/50">{subgroup.label}</div>}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
3
|
+
import { cn } from '../../lib/cn.ts'
|
|
4
|
+
|
|
5
|
+
export const dotVariants = cva('inline-block size-1.5 shrink-0 rounded-full', {
|
|
6
|
+
variants: {
|
|
7
|
+
variant: {
|
|
8
|
+
default: 'bg-primary',
|
|
9
|
+
secondary: 'bg-muted-foreground/45',
|
|
10
|
+
destructive: 'bg-destructive',
|
|
11
|
+
outline: 'border border-border bg-transparent',
|
|
12
|
+
success: 'bg-emerald-500',
|
|
13
|
+
warning: 'bg-amber-500',
|
|
14
|
+
info: 'bg-blue-500',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
defaultVariants: {
|
|
18
|
+
variant: 'default',
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export interface DotProps extends React.ComponentProps<'span'>, VariantProps<typeof dotVariants> {
|
|
23
|
+
/** Nome acessível quando a cor comunica estado; sem label, o ponto é decorativo. */
|
|
24
|
+
label?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Indicador compacto de estado; use Badge quando o texto precisar permanecer visível. */
|
|
28
|
+
export function Dot({ className, variant, label, ...props }: DotProps): React.ReactElement {
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
data-slot="dot"
|
|
32
|
+
role={label === undefined ? undefined : 'img'}
|
|
33
|
+
aria-label={label}
|
|
34
|
+
aria-hidden={label === undefined ? true : undefined}
|
|
35
|
+
className={cn(dotVariants({ variant }), className)}
|
|
36
|
+
{...props}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -52,6 +52,7 @@ interface SelectBaseProps {
|
|
|
52
52
|
/** Texto do campo vazio. */
|
|
53
53
|
placeholder?: string
|
|
54
54
|
disabled?: boolean
|
|
55
|
+
/** Classes da raiz do controle, incluindo campo, ícones e ações. */
|
|
55
56
|
className?: string
|
|
56
57
|
id?: string
|
|
57
58
|
/** Ícone LEADING dentro do campo — identifica o filtro sem jogar o ícone ao lado. */
|
|
@@ -133,7 +134,10 @@ function NativeSelect({
|
|
|
133
134
|
}: SelectNativeProps): React.ReactElement {
|
|
134
135
|
const groups = byGroup(options)
|
|
135
136
|
return (
|
|
136
|
-
<div
|
|
137
|
+
<div
|
|
138
|
+
className={cn('group/select relative w-full min-w-0 has-[select:disabled]:opacity-50', className)}
|
|
139
|
+
data-slot="select-wrapper"
|
|
140
|
+
>
|
|
137
141
|
{icon ? (
|
|
138
142
|
<span
|
|
139
143
|
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground select-none [&_svg]:size-4"
|
|
@@ -157,7 +161,6 @@ function NativeSelect({
|
|
|
157
161
|
'focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50',
|
|
158
162
|
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
|
159
163
|
icon ? 'pl-9' : '',
|
|
160
|
-
className,
|
|
161
164
|
)}
|
|
162
165
|
>
|
|
163
166
|
{placeholder !== undefined && (
|
|
@@ -2,11 +2,21 @@ import * as React from "react"
|
|
|
2
2
|
|
|
3
3
|
import { cn } from '../../lib/cn.ts'
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
export interface TableProps extends React.ComponentProps<"table"> {
|
|
6
|
+
/** `framed` aplica a moldura canônica de datagrid no contêiner da tabela. */
|
|
7
|
+
variant?: 'plain' | 'framed'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function Table({ className, variant = 'plain', ...props }: TableProps) {
|
|
6
11
|
return (
|
|
7
12
|
<div
|
|
8
13
|
data-slot="table-container"
|
|
9
|
-
|
|
14
|
+
data-variant={variant}
|
|
15
|
+
className={cn(
|
|
16
|
+
"relative w-full overflow-x-auto",
|
|
17
|
+
variant === 'framed' &&
|
|
18
|
+
'rounded-lg border border-border [&_[data-slot=table-header]]:bg-muted/20',
|
|
19
|
+
)}
|
|
10
20
|
>
|
|
11
21
|
<table
|
|
12
22
|
data-slot="table"
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip.tsx'
|
|
3
|
+
import { useOverflowing } from '../../lib/overflow.ts'
|
|
3
4
|
import { cn } from '../../lib/cn.ts'
|
|
4
5
|
|
|
5
6
|
export interface TruncateProps extends React.ComponentProps<'span'> {
|
|
6
7
|
/** Conteúdo do tooltip quando transborda. Default: os próprios children. */
|
|
7
8
|
tooltip?: React.ReactNode
|
|
9
|
+
/** Sinaliza o corte esmaecendo o fim da linha, no lugar das reticências. */
|
|
10
|
+
fade?: boolean
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
/**
|
|
@@ -12,29 +15,15 @@ export interface TruncateProps extends React.ComponentProps<'span'> {
|
|
|
12
15
|
* `scrollWidth > clientWidth`, re-medido em resize). Substitui a composição
|
|
13
16
|
* `block truncate` + `title` sempre presente — title em texto que não corta é ruído.
|
|
14
17
|
* Requer `<TooltipProvider>` na raiz do app (o esqueleto do `opus create` já monta).
|
|
18
|
+
* Com `fade`, a mesma medição decide quando esmaecer o fim da linha em vez de cortar
|
|
19
|
+
* em reticências — sem transbordo, nada é aplicado.
|
|
15
20
|
*/
|
|
16
|
-
export function Truncate({ tooltip, children, className, ...props }: TruncateProps): React.ReactElement {
|
|
17
|
-
const ref =
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const measure = React.useCallback(() => {
|
|
21
|
-
const el = ref.current
|
|
22
|
-
if (el === null) return
|
|
23
|
-
setOverflowing(el.scrollWidth > el.clientWidth)
|
|
24
|
-
}, [])
|
|
25
|
-
|
|
26
|
-
React.useLayoutEffect(() => {
|
|
27
|
-
measure()
|
|
28
|
-
const el = ref.current
|
|
29
|
-
if (el === null || typeof ResizeObserver === 'undefined') return
|
|
30
|
-
const ro = new ResizeObserver(measure)
|
|
31
|
-
ro.observe(el)
|
|
32
|
-
return () => ro.disconnect()
|
|
33
|
-
// children na dependência: texto novo re-mede (o RO só vê mudança de CAIXA).
|
|
34
|
-
}, [measure, children])
|
|
21
|
+
export function Truncate({ tooltip, fade = false, children, className, ...props }: TruncateProps): React.ReactElement {
|
|
22
|
+
const { ref, overflowing } = useOverflowing<HTMLSpanElement>(children)
|
|
23
|
+
const cut = fade ? (overflowing ? 'truncate-fade' : 'overflow-hidden whitespace-nowrap') : 'truncate'
|
|
35
24
|
|
|
36
25
|
const span = (
|
|
37
|
-
<span ref={ref} data-slot="truncate" className={cn('block
|
|
26
|
+
<span ref={ref} data-slot="truncate" className={cn('block', cut, className)} {...props}>
|
|
38
27
|
{children}
|
|
39
28
|
</span>
|
|
40
29
|
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Dock
|
|
2
|
+
|
|
3
|
+
Ferramentas ancoradas à superfície de trabalho. Um canvas ou um editor raramente comporta mais
|
|
4
|
+
uma faixa de chrome no topo: a trilha da aplicação já ocupa esse papel, e um segundo cabeçalho
|
|
5
|
+
empilha duas faixas com a mesma função. A Dock coloca as ações sobre a própria superfície,
|
|
6
|
+
agrupadas e sempre alcançáveis.
|
|
7
|
+
|
|
8
|
+
```tsx preview
|
|
9
|
+
<div className="relative h-40 w-full rounded-lg border border-border bg-muted/20">
|
|
10
|
+
<Dock label="Ferramentas do fluxo">
|
|
11
|
+
<DockGroup>
|
|
12
|
+
<DockAction icon={<MousePointer2 />} label="Selecionar e mover" pressed onClick={() => {}} />
|
|
13
|
+
</DockGroup>
|
|
14
|
+
<DockGroup>
|
|
15
|
+
<DockAction icon={<Plus />} label="Adicionar etapa" onClick={() => {}} />
|
|
16
|
+
<DockAction icon={<GitBranch />} label="Adicionar condição" onClick={() => {}} />
|
|
17
|
+
</DockGroup>
|
|
18
|
+
<DockGroup>
|
|
19
|
+
<DockAction icon={<Sparkles />} label="Revisar com IA" onClick={() => {}} />
|
|
20
|
+
</DockGroup>
|
|
21
|
+
</Dock>
|
|
22
|
+
<SurfaceStatus>Salvo</SurfaceStatus>
|
|
23
|
+
</div>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
O contêiner precisa ser `relative` — a Dock se ancora nele, não na janela. `position` escolhe a
|
|
27
|
+
aresta: `bottom` (centro), `bottom-left` e `bottom-right`. Uma superfície pode ter mais de uma
|
|
28
|
+
barra quando os papéis são distintos, como ferramentas ao centro e controles de viewport à
|
|
29
|
+
esquerda.
|
|
30
|
+
|
|
31
|
+
## Grupos e divisórias
|
|
32
|
+
|
|
33
|
+
A divisória entre grupos pertence ao componente: o consumidor declara `DockGroup` e a linha
|
|
34
|
+
aparece entre grupos consecutivos, nunca antes do primeiro. Agrupe por intenção — modo, criação,
|
|
35
|
+
IA, publicação — em vez de espalhar ícones numa fileira única.
|
|
36
|
+
|
|
37
|
+
## Estado da superfície
|
|
38
|
+
|
|
39
|
+
Estado não é ferramenta, e por isso não mora na barra: `SurfaceStatus` flutua num canto da mesma
|
|
40
|
+
superfície — `top-right` por padrão — e recebe salvamento, versão publicada, execução percorrida.
|
|
41
|
+
A região é `role="status"` com `aria-live="polite"`, então a mudança é anunciada sem roubar o foco.
|
|
42
|
+
Separar os dois preserva a barra como toolbar navegável e dá ao estado um lugar estável, que não
|
|
43
|
+
muda de posição conforme o número de ferramentas.
|
|
44
|
+
|
|
45
|
+
Mensagem de sucesso é transitória por natureza — some depois de confirmar — enquanto falha
|
|
46
|
+
permanece até o estado mudar. Essa decisão é da aplicação, que conhece o ciclo; o componente
|
|
47
|
+
apenas reserva o lugar.
|
|
48
|
+
|
|
49
|
+
`actions` recebe o que o recurso aberto permite fazer — em geral o mesmo menu que a linha dele
|
|
50
|
+
tem na navegação. Assim a superfície deixa de ser o único lugar onde renomear ou excluir não
|
|
51
|
+
alcançam o item que está na tela. Apenas o texto é região viva: anunciar o menu a cada mudança de
|
|
52
|
+
estado seria ruído para quem usa leitor de tela.
|
|
53
|
+
|
|
54
|
+
## Modo e execução
|
|
55
|
+
|
|
56
|
+
`DockAction` sem `pressed` executa uma ação. Com `pressed`, comunica um modo ligado — o botão
|
|
57
|
+
assume o preenchimento e expõe `aria-pressed`. Use `hint` quando o tooltip precisar dizer mais que
|
|
58
|
+
o rótulo, que também é o nome acessível.
|
|
59
|
+
|
|
60
|
+
## Teclado
|
|
61
|
+
|
|
62
|
+
A barra é uma `toolbar`: as setas andam entre as ações e Home/End vão às pontas. Cada ação nomeia-se
|
|
63
|
+
por tooltip, então `<TooltipProvider>` precisa existir na raiz do app — o esqueleto do `opus create`
|
|
64
|
+
já monta.
|
|
65
|
+
|
|
66
|
+
| Prop | Tipo | Default | O que faz |
|
|
67
|
+
| --- | --- | --- | --- |
|
|
68
|
+
| `position` | `'bottom' \| 'bottom-left' \| 'bottom-right'` | `'bottom'` | Aresta do contêiner onde a barra se ancora. |
|
|
69
|
+
| `label` | `string` | | Nome acessível da barra. |
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Dot
|
|
2
|
+
|
|
3
|
+
Indicador visual compacto para estados que já têm contexto. O tamanho permanece fixo; `variant`
|
|
4
|
+
seleciona somente a intenção semântica. Quando a cor carrega significado, `label` fornece o nome
|
|
5
|
+
acessível. Sem `label`, o ponto é decorativo.
|
|
6
|
+
|
|
7
|
+
```tsx preview
|
|
8
|
+
<div className="flex items-center gap-4">
|
|
9
|
+
<Dot variant="success" label="Ativo" />
|
|
10
|
+
<Dot variant="warning" label="Atenção" />
|
|
11
|
+
<Dot variant="destructive" label="Falhou" />
|
|
12
|
+
<Dot variant="info" label="Publicado" />
|
|
13
|
+
<Dot variant="secondary" aria-hidden />
|
|
14
|
+
</div>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Use `Badge` quando o estado precisar permanecer legível sem depender do contexto ao redor.
|
|
@@ -340,3 +340,4 @@ render(
|
|
|
340
340
|
| `size` | `'default' \| 'sm'` | `'default'` | Altura: default (h-9, a do Input e do Button) ou sm (h-8) pra toolbar densa. |
|
|
341
341
|
| `disabled` | `boolean` | `false` | Esmaece e trava o controle. |
|
|
342
342
|
| `id` | `string` | | Vai pro campo — pra parear com o `htmlFor` do Label. |
|
|
343
|
+
| `className` | `string` | | Classes da raiz do controle, incluindo campo, ícones e ações, em todos os modos. |
|
|
@@ -29,6 +29,27 @@ render(
|
|
|
29
29
|
|
|
30
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
|
+
`SidebarItem` também é a linha de árvores de navegação. `actions` e o controle formado por
|
|
33
|
+
`onToggle`/`expanded` são irmãos do botão principal, portanto menus e chevrons não criam
|
|
34
|
+
controles interativos aninhados. No modo recolhido, a linha conserva somente o destino com
|
|
35
|
+
ícone e tooltip. No modo aberto, o chevron ocupa o lugar do ícone da pasta durante o hover da própria linha ou
|
|
36
|
+
foco, e as ações ficam sobrepostas à extremidade direita: controles invisíveis não reduzem o
|
|
37
|
+
espaço disponível para o rótulo. O foco comum no destino não mantém o chevron aberto;
|
|
38
|
+
somente o foco visível no próprio controle o revela para navegação por teclado.
|
|
39
|
+
As ações aparecem no hover da própria linha, no foco visível do próprio controle ou enquanto o menu está
|
|
40
|
+
aberto; focar o destino da linha não revela as reticências.
|
|
41
|
+
O rótulo que não cabe na linha desaparece num gradiente até a borda, em vez de terminar em
|
|
42
|
+
reticências; a linha mede o próprio transbordo, então o rótulo que cabe inteiro fica intacto.
|
|
43
|
+
O foco visível usa o mesmo anel do `Button` (`ring-2 ring-ring/50`), na linha aberta, no botão do
|
|
44
|
+
rail recolhido e no chevron — sem ele a navegação por teclado cairia no anel padrão do navegador,
|
|
45
|
+
que destoa do tema.
|
|
46
|
+
Durante drag-and-drop, `dropPosition="before"` e `"after"` desenham uma linha sobreposta ao
|
|
47
|
+
limite do item; `"inside"` realça a superfície da pasta. O indicador nunca reserva espaço.
|
|
48
|
+
|
|
49
|
+
Árvores montadas por composição usam `SidebarGroupLabel` para os mesmos rótulos discretos
|
|
50
|
+
que o `SidebarNav` desenha automaticamente. O heading permanece `text-sm`; hierarquia vem
|
|
51
|
+
do peso médio e da cor atenuada, não de reduzir legibilidade.
|
|
52
|
+
|
|
32
53
|
```tsx
|
|
33
54
|
<Sidebar collapsed={collapsed}>
|
|
34
55
|
<PaneHeader><MySidebarHeader onToggle={() => setCollapsed(!collapsed)} /></PaneHeader>
|
|
@@ -38,32 +38,33 @@ A Table vem SEM borda externa — só as divisórias de linha (a última o Table
|
|
|
38
38
|
|
|
39
39
|
## Moldura (o datagrid da casa)
|
|
40
40
|
|
|
41
|
-
A
|
|
41
|
+
A variante `framed` aplica no próprio contêiner a borda externa, os cantos arredondados, o
|
|
42
|
+
scroll horizontal contido e o fundo discreto do cabeçalho. A última linha já vem sem divisória,
|
|
43
|
+
então o quadro fecha limpo. O ActionList continua sendo o pattern indicado para coleções
|
|
44
|
+
pesquisáveis derivadas de actions `kind: 'list'`.
|
|
42
45
|
|
|
43
46
|
```tsx preview col
|
|
44
|
-
<
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
<
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
</Table>
|
|
66
|
-
</div>
|
|
47
|
+
<Table variant="framed">
|
|
48
|
+
<TableHeader>
|
|
49
|
+
<TableRow>
|
|
50
|
+
<TableHead>Sessão</TableHead>
|
|
51
|
+
<TableHead>Agente</TableHead>
|
|
52
|
+
<TableHead className="text-right">Duração</TableHead>
|
|
53
|
+
</TableRow>
|
|
54
|
+
</TableHeader>
|
|
55
|
+
<TableBody>
|
|
56
|
+
<TableRow>
|
|
57
|
+
<TableCell className="font-medium">Importar pedidos da transportadora</TableCell>
|
|
58
|
+
<TableCell>developer</TableCell>
|
|
59
|
+
<TableCell className="text-right">42 min</TableCell>
|
|
60
|
+
</TableRow>
|
|
61
|
+
<TableRow>
|
|
62
|
+
<TableCell className="font-medium">Revisar contrato de rastreio</TableCell>
|
|
63
|
+
<TableCell>reviewer</TableCell>
|
|
64
|
+
<TableCell className="text-right">18 min</TableCell>
|
|
65
|
+
</TableRow>
|
|
66
|
+
</TableBody>
|
|
67
|
+
</Table>
|
|
67
68
|
```
|
|
68
69
|
|
|
69
70
|
## Com rodapé (TableFooter)
|
|
@@ -20,6 +20,32 @@ O mesmo componente, com espaço de sobra: sem tooltip, sem atributo, só o texto
|
|
|
20
20
|
</div>
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
## Corte por esmaecimento
|
|
24
|
+
|
|
25
|
+
`fade` troca as reticências por um gradiente: o texto segue até a borda da caixa e desaparece
|
|
26
|
+
ali. A medição é a mesma, então o esmaecimento e a dica aparecem juntos, só quando há texto
|
|
27
|
+
escondido.
|
|
28
|
+
|
|
29
|
+
```tsx preview
|
|
30
|
+
<div className="w-48 rounded-md border p-2">
|
|
31
|
+
<Truncate fade>relatorio-consolidado-vendas-pos-vendas-2026-julho-final-v3.xlsx</Truncate>
|
|
32
|
+
</div>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Uma composição que precisa do esmaecimento sem o tooltip aplica a utilitária `truncate-fade`,
|
|
36
|
+
que cobre o último `1.5rem` da caixa, e decide quando ligá-la com o hook `useOverflowing` — a
|
|
37
|
+
mesma medição que este componente usa. A máscara pressupõe corte real: em texto que cabe
|
|
38
|
+
inteiro, ela esmaeceria o fim de uma palavra visível. É assim que o rótulo do `SidebarItem`
|
|
39
|
+
funciona.
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
const { ref, overflowing } = useOverflowing<HTMLSpanElement>(label)
|
|
43
|
+
|
|
44
|
+
<span ref={ref} className={cn('overflow-hidden whitespace-nowrap', overflowing && 'truncate-fade')}>
|
|
45
|
+
{label}
|
|
46
|
+
</span>
|
|
47
|
+
```
|
|
48
|
+
|
|
23
49
|
## Conteúdo da dica
|
|
24
50
|
|
|
25
51
|
`tooltip` sobrepõe o conteúdo mostrado no hover (default: os próprios children) — útil
|
package/src/ui/docs/registry.tsx
CHANGED
|
@@ -50,6 +50,8 @@ import confirmMd from './content/confirm.md?raw'
|
|
|
50
50
|
import aspectRatioMd from './content/aspect-ratio.md?raw'
|
|
51
51
|
import avatarMd from './content/avatar.md?raw'
|
|
52
52
|
import badgeMd from './content/badge.md?raw'
|
|
53
|
+
import dotMd from './content/dot.md?raw'
|
|
54
|
+
import dockMd from './content/dock.md?raw'
|
|
53
55
|
import breadcrumbMd from './content/breadcrumb.md?raw'
|
|
54
56
|
import buttonMd from './content/button.md?raw'
|
|
55
57
|
import buttonGroupMd from './content/button-group.md?raw'
|
|
@@ -357,6 +359,8 @@ export const UI_SECTIONS: DocSection[] = [
|
|
|
357
359
|
pages: [
|
|
358
360
|
{ slug: 'avatar', title: 'Avatar', render: comp('Avatar', 'avatar', avatarMd) },
|
|
359
361
|
{ slug: 'badge', title: 'Badge', render: comp('Badge', 'badge', badgeMd) },
|
|
362
|
+
{ slug: 'dot', title: 'Dot', render: comp('Dot', 'dot', dotMd) },
|
|
363
|
+
{ slug: 'dock', title: 'Dock', render: comp('Dock', 'dock', dockMd) },
|
|
360
364
|
{ slug: 'carousel', title: 'Carousel', render: comp('Carousel', 'carousel', carouselMd) },
|
|
361
365
|
{ slug: 'copyable', title: 'Copyable', render: comp('Copyable', 'copyable', copyableMd) },
|
|
362
366
|
{ slug: 'item', title: 'Item', render: comp('Item', 'item', itemMd) },
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Diz se uma linha única de texto não cabe na própria caixa (`scrollWidth > clientWidth`).
|
|
5
|
+
* A medida é refeita quando a caixa muda de tamanho e quando o conteúdo passado em `content`
|
|
6
|
+
* muda — o ResizeObserver enxerga a CAIXA, não o texto que entra nela.
|
|
7
|
+
*
|
|
8
|
+
* É o que separa o corte real do corte suposto: quem consome liga o sinal de truncamento
|
|
9
|
+
* (reticências, esmaecimento, tooltip) só quando existe texto escondido.
|
|
10
|
+
*/
|
|
11
|
+
export function useOverflowing<T extends HTMLElement>(content?: React.ReactNode): {
|
|
12
|
+
ref: React.RefObject<T | null>
|
|
13
|
+
overflowing: boolean
|
|
14
|
+
} {
|
|
15
|
+
const ref = React.useRef<T>(null)
|
|
16
|
+
const [overflowing, setOverflowing] = React.useState(false)
|
|
17
|
+
|
|
18
|
+
const measure = React.useCallback(() => {
|
|
19
|
+
const el = ref.current
|
|
20
|
+
if (el === null) return
|
|
21
|
+
setOverflowing(el.scrollWidth > el.clientWidth)
|
|
22
|
+
}, [])
|
|
23
|
+
|
|
24
|
+
React.useLayoutEffect(() => {
|
|
25
|
+
measure()
|
|
26
|
+
const el = ref.current
|
|
27
|
+
if (el === null || typeof ResizeObserver === 'undefined') return
|
|
28
|
+
const ro = new ResizeObserver(measure)
|
|
29
|
+
ro.observe(el)
|
|
30
|
+
return () => ro.disconnect()
|
|
31
|
+
}, [measure, content])
|
|
32
|
+
|
|
33
|
+
return { ref, overflowing }
|
|
34
|
+
}
|
package/src/ui/meta.ts
CHANGED
|
@@ -29,6 +29,18 @@ export const componentMeta = {
|
|
|
29
29
|
whenToUse:
|
|
30
30
|
'Rótulo curto de status/categoria. `variant` pra intenção (default/secondary/destructive/outline) ou tom de status (success/warning/info, fill tingido). Não-interativo — pra clique, use Button ou `asChild` num <a>.',
|
|
31
31
|
},
|
|
32
|
+
'dot': {
|
|
33
|
+
name: 'dot',
|
|
34
|
+
ancestry: 'opus',
|
|
35
|
+
whenToUse:
|
|
36
|
+
'Sinal compacto de estado quando o contexto ou o nome acessível já explica o significado. `variant` segue a intenção semântica (default/secondary/destructive/outline/success/warning/info). Passe `label` quando a cor comunicar informação; sem label, o ponto é decorativo. Para texto visível, use Badge.',
|
|
37
|
+
},
|
|
38
|
+
'dock': {
|
|
39
|
+
name: 'dock',
|
|
40
|
+
ancestry: 'opus',
|
|
41
|
+
whenToUse:
|
|
42
|
+
'Barra de ferramentas ancorada a uma superfície de trabalho — canvas, editor, preview —, quando um cabeçalho empilharia mais uma faixa de chrome sobre a trilha. Compõe Dock > DockGroup > DockAction; o estado da superfície (salvamento, versão) fica no SurfaceStatus, que flutua num canto e não pertence à barra. `position` escolhe a aresta; a divisória entre grupos é do componente. Requer TooltipProvider na raiz. Para ações de uma PÁGINA, use as `actions` do Page; para um conjunto de toggles exclusivos, ToggleGroup.',
|
|
43
|
+
},
|
|
32
44
|
'button': {
|
|
33
45
|
name: 'button',
|
|
34
46
|
ancestry: 'shadcn',
|
|
@@ -137,7 +149,7 @@ export const componentMeta = {
|
|
|
137
149
|
name: 'table',
|
|
138
150
|
ancestry: 'shadcn',
|
|
139
151
|
whenToUse:
|
|
140
|
-
'Tabela de dados. Compõe Table > (TableHeader > TableRow > TableHead, TableBody > TableRow > TableCell).
|
|
152
|
+
'Tabela de dados. Compõe Table > (TableHeader > TableRow > TableHead, TableBody > TableRow > TableCell). `variant="plain"` vem sem borda externa; `variant="framed"` aplica a moldura canônica, recorta o scroll e destaca o cabeçalho. Pra listagem tabular — pro pattern de search use ActionList.',
|
|
141
153
|
},
|
|
142
154
|
'tabs': {
|
|
143
155
|
name: 'tabs',
|
|
@@ -167,7 +179,7 @@ export const componentMeta = {
|
|
|
167
179
|
name: 'truncate',
|
|
168
180
|
ancestry: 'opus',
|
|
169
181
|
whenToUse:
|
|
170
|
-
'Texto truncado com tooltip SÓ quando transborda (medição do overflow, re-medida em resize) — substitui a composição `block truncate` + `title` sempre presente, que mostra dica até em texto que não corta. `tooltip` sobrepõe o conteúdo da dica (default: os children). Requer TooltipProvider na raiz. Pra célula de tabela, nome de arquivo, URL — qualquer linha única que pode estourar.',
|
|
182
|
+
'Texto truncado com tooltip SÓ quando transborda (medição do overflow, re-medida em resize) — substitui a composição `block truncate` + `title` sempre presente, que mostra dica até em texto que não corta. `tooltip` sobrepõe o conteúdo da dica (default: os children); `fade` troca as reticências por um esmaecimento até a borda, ligado pela mesma medição. Requer TooltipProvider na raiz. Pra célula de tabela, nome de arquivo, URL — qualquer linha única que pode estourar.',
|
|
171
183
|
},
|
|
172
184
|
'accordion': {
|
|
173
185
|
name: 'accordion',
|
package/src/ui/react.tsx
CHANGED
|
@@ -135,6 +135,7 @@ export {
|
|
|
135
135
|
TableCell,
|
|
136
136
|
TableCaption,
|
|
137
137
|
} from './components/primitives/table.tsx'
|
|
138
|
+
export type { TableProps } from './components/primitives/table.tsx'
|
|
138
139
|
|
|
139
140
|
// UM seletor: `native` (menu do SO), `searchable` (busca), `multiple` (chips) e
|
|
140
141
|
// `variant="ghost"` (barra de composer) são PROPS, não componentes. Ver select.tsx.
|
|
@@ -172,6 +173,10 @@ export { ToggleGroup, ToggleGroupItem } from './components/primitives/toggle-gro
|
|
|
172
173
|
export { Truncate } from './components/primitives/truncate.tsx'
|
|
173
174
|
export type { TruncateProps } from './components/primitives/truncate.tsx'
|
|
174
175
|
|
|
176
|
+
// A medição de transbordo por trás do Truncate, exposta para composições que precisam
|
|
177
|
+
// do sinal de corte sem o tooltip — por exemplo, esmaecer um rótulo com `truncate-fade`.
|
|
178
|
+
export { useOverflowing } from './lib/overflow.ts'
|
|
179
|
+
|
|
175
180
|
// Patterns (renderizam actions a partir do spec)
|
|
176
181
|
export { ActionForm, ActionFormField, useActionFormContext } from './components/patterns/form.tsx'
|
|
177
182
|
export type { ActionFormProps, ActionFormFieldProps, ActionFormContextValue } from './components/patterns/form.tsx'
|
|
@@ -209,8 +214,12 @@ export type { PageProps } from './components/patterns/page.tsx'
|
|
|
209
214
|
export { Split, Pane } from './components/patterns/split.tsx'
|
|
210
215
|
export type { SplitProps, PaneProps, PaneSize, SplitLayout, SplitLayoutChange } from './components/patterns/split.tsx'
|
|
211
216
|
|
|
217
|
+
// A barra de ferramentas que flutua sobre uma superfície de trabalho (canvas, editor, preview).
|
|
218
|
+
export { Dock, DockGroup, DockAction, SurfaceStatus } from './components/patterns/dock.tsx'
|
|
219
|
+
export type { DockProps, DockActionProps, SurfaceStatusProps } from './components/patterns/dock.tsx'
|
|
220
|
+
|
|
212
221
|
// Barra lateral composicional: header/conteúdo/footer e navegação, sem possuir o layout.
|
|
213
|
-
export { PaneHeader, PaneContent, PaneFooter, Sidebar, SidebarItem, SidebarNav } from './components/patterns/sidebar.tsx'
|
|
222
|
+
export { PaneHeader, PaneContent, PaneFooter, Sidebar, SidebarItem, SidebarGroupLabel, SidebarNav } from './components/patterns/sidebar.tsx'
|
|
214
223
|
export type { SidebarProps, SidebarItemProps, SidebarNavGroup, SidebarNavSubgroup, SidebarNavItem } from './components/patterns/sidebar.tsx'
|
|
215
224
|
|
|
216
225
|
// O menu como primitivo pivotável: recolhe quando composto dentro de Sidebar.
|
|
@@ -221,3 +230,5 @@ export type { ShellNavProps, ShellNavGroup, ShellNavItem, ShellNavHeadingProps }
|
|
|
221
230
|
// reativa da URL + navigate — sem tabela de rotas. Ver src/ui/router.ts.
|
|
222
231
|
export { navigate, usePathname, useSegments, useSearchParams } from './router.ts'
|
|
223
232
|
export type { NavigateOptions } from './router.ts'
|
|
233
|
+
export { Dot, dotVariants } from './components/primitives/dot.tsx'
|
|
234
|
+
export type { DotProps } from './components/primitives/dot.tsx'
|
package/src/ui/theme.css
CHANGED
|
@@ -168,6 +168,18 @@
|
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/* Truncamento por esmaecimento: a linha continua em direção à borda e desaparece num
|
|
172
|
+
* gradiente, em vez de terminar em reticências. A máscara ocupa o último 1.5rem da caixa,
|
|
173
|
+
* então só deve ser aplicada quando o texto REALMENTE corta — em texto que cabe, ela
|
|
174
|
+
* esmaeceria o fim de uma palavra inteira. `Truncate fade` e o rótulo do `SidebarItem`
|
|
175
|
+
* medem o transbordo antes de ligá-la. */
|
|
176
|
+
@utility truncate-fade {
|
|
177
|
+
overflow: hidden;
|
|
178
|
+
white-space: nowrap;
|
|
179
|
+
text-overflow: clip;
|
|
180
|
+
mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent);
|
|
181
|
+
}
|
|
182
|
+
|
|
171
183
|
/* Scrollbar nativa da casa: todo scroller do documento compartilha o mesmo acabamento,
|
|
172
184
|
* inclusive conteúdo renderizado por portal. Isso elimina a variação do macOS entre raiz,
|
|
173
185
|
* elementos aninhados, mouse e trackpad sem trocar o mecanismo nativo nem adicionar trabalho
|