@softize/opus 20.0.0 → 21.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 +141 -0
- package/bin/lib/gen-manifest.mjs +1 -0
- package/bin/lib/gen-runner.mjs +3 -0
- package/bin/lib/introspect.mjs +5 -4
- package/docs/adr/0020-icon-tiles-are-explicitly-composed.md +8 -0
- package/docs/adr/0021-content-carries-structure-and-semantic-element.md +7 -0
- package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +14 -1
- package/docs/adr/0025-content-frame-treatment-does-not-change-semantics.md +134 -0
- package/docs/adr/0026-reactions-apply-declared-delivery-policy.md +123 -0
- package/docs/adr/0027-reactions-publish-declared-events-with-bounded-chains.md +96 -0
- package/docs/adr/0028-schedules-run-actions-as-the-system-by-action-opt-in.md +127 -0
- package/docs/protocol.md +60 -24
- package/package.json +1 -1
- package/src/core/audit.ts +2 -1
- package/src/core/index.ts +1 -1
- package/src/core/reactions.ts +6 -2
- package/src/core/runtime.ts +598 -70
- package/src/core/schedules.ts +3 -2
- package/src/core/types.ts +52 -7
- package/src/testing/index.ts +25 -14
- package/src/ui/components/patterns/content-header.tsx +94 -15
- package/src/ui/components/patterns/list.tsx +2 -14
- package/src/ui/components/patterns/surface-header.tsx +58 -25
- package/src/ui/components/primitives/card.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +1 -1
- package/src/ui/components/primitives/drawer.tsx +1 -1
- package/src/ui/docs/content/action-list.md +7 -6
- package/src/ui/docs/content/actions.md +2 -1
- package/src/ui/docs/content/audit.md +10 -0
- package/src/ui/docs/content/auth.md +5 -1
- package/src/ui/docs/content/content.md +88 -20
- package/src/ui/docs/content/events.md +109 -11
- package/src/ui/docs/content/icon-tile.md +11 -11
- package/src/ui/docs/content/runtime.md +11 -2
- package/src/ui/docs/content/scheduler.md +53 -12
- package/src/ui/docs/content/testing.md +14 -2
- package/src/ui/meta.ts +1 -1
- package/src/ui/react.tsx +1 -0
package/src/core/schedules.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* - Schedule = tempo dispara
|
|
12
12
|
*
|
|
13
13
|
* Runtime registra no SchedulerAdapter no `start()`. Quando o tempo
|
|
14
|
-
* configurado bate, o
|
|
15
|
-
*
|
|
14
|
+
* configurado bate, o runtime executa a action como o sistema, com
|
|
15
|
+
* `ctx.provenance = { kind: 'schedule', scheduleName }`; a action precisa
|
|
16
|
+
* declarar `automatable: true` (ou `public: true`). Ver ADR 0028.
|
|
16
17
|
*
|
|
17
18
|
* Ver §16 do protocolo.
|
|
18
19
|
*/
|
package/src/core/types.ts
CHANGED
|
@@ -255,10 +255,11 @@ export interface User {
|
|
|
255
255
|
export type CanFn = (permission: string, resource?: unknown) => boolean | Promise<boolean>
|
|
256
256
|
|
|
257
257
|
/**
|
|
258
|
-
* Função que emite um `DomainEvent`. Em `ActionContext.emit`, publica
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
258
|
+
* Função que emite um `DomainEvent` pelo `EventBusAdapter`. Em `ActionContext.emit`, publica
|
|
259
|
+
* qualquer evento; um evento fora do `emits` da action gera aviso com
|
|
260
|
+
* `config.warnUndeclaredEmits`. Em `ReactionContext.emit`, publica somente eventos listados no
|
|
261
|
+
* `emits` da reaction e descarta os demais (aviso `runtime.reaction_emit_ignored`); a cadeia de
|
|
262
|
+
* reactions para em `config.maxReactionDepth`. Ver ADR 0027.
|
|
262
263
|
*/
|
|
263
264
|
export type EmitFn = <T = unknown>(
|
|
264
265
|
event: string,
|
|
@@ -295,7 +296,7 @@ export interface ReactionContext {
|
|
|
295
296
|
tenantId: string | null
|
|
296
297
|
db: unknown
|
|
297
298
|
log: Logger
|
|
298
|
-
/**
|
|
299
|
+
/** Publica os eventos declarados em `emits` da reaction; descarta os demais. Ver `EmitFn`. */
|
|
299
300
|
emit: EmitFn
|
|
300
301
|
storage: StorageAdapter | null
|
|
301
302
|
cache: CacheAdapter | null
|
|
@@ -303,6 +304,12 @@ export interface ReactionContext {
|
|
|
303
304
|
provenance: Provenance
|
|
304
305
|
trace?: TraceContext
|
|
305
306
|
meta: Record<string, unknown>
|
|
307
|
+
/**
|
|
308
|
+
* Abortado quando a tentativa excede o `timeout` da reaction. O runtime sempre preenche o
|
|
309
|
+
* campo; ele é opcional para quem monta o contexto à mão em testes. Um handler que ignora o
|
|
310
|
+
* sinal continua executando depois do timeout. Ver ADR 0026.
|
|
311
|
+
*/
|
|
312
|
+
signal?: AbortSignal
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
// =============================================================================
|
|
@@ -387,11 +394,26 @@ export interface DomainEvent<T = unknown> {
|
|
|
387
394
|
actor: { id: string | null; type?: 'user' | 'system' | 'integration' }
|
|
388
395
|
tenant?: string | null
|
|
389
396
|
data: T
|
|
397
|
+
/**
|
|
398
|
+
* A action que iniciou a cadeia. Um evento publicado por reaction herda o `source` do evento
|
|
399
|
+
* que ela processava, e `provenance.sourceActionId` continua batendo com o audit da action.
|
|
400
|
+
*/
|
|
390
401
|
source: {
|
|
391
402
|
action: string
|
|
392
403
|
actionId: string
|
|
393
404
|
correlation?: string
|
|
394
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Presente só em evento publicado por reaction: qual reaction o publicou, a partir de qual
|
|
408
|
+
* evento e a que profundidade da cadeia (1 = primeira reaction depois da action). Evento
|
|
409
|
+
* emitido por action não tem `cause` e está na profundidade 0. Ver ADR 0027.
|
|
410
|
+
*/
|
|
411
|
+
cause?: {
|
|
412
|
+
reaction: string
|
|
413
|
+
eventId: string
|
|
414
|
+
eventType: string
|
|
415
|
+
depth: number
|
|
416
|
+
}
|
|
395
417
|
trace?: TraceContext
|
|
396
418
|
meta?: Record<string, unknown>
|
|
397
419
|
}
|
|
@@ -434,6 +456,12 @@ export interface JobSpec {
|
|
|
434
456
|
requestId?: string
|
|
435
457
|
trace?: TraceContext
|
|
436
458
|
originalProvenance?: Provenance
|
|
459
|
+
/**
|
|
460
|
+
* Presente quando o próprio runtime enfileirou o job numa execução do sistema (o disparo de um
|
|
461
|
+
* schedule). O worker só reconstrói a identidade de sistema com esta marca; a provenance não
|
|
462
|
+
* basta, porque vem do chamador. Ver ADR 0028.
|
|
463
|
+
*/
|
|
464
|
+
system?: true
|
|
437
465
|
}
|
|
438
466
|
config: {
|
|
439
467
|
queue?: string
|
|
@@ -828,6 +856,11 @@ export interface ActionBase<In, Out, ParsedIn = In> {
|
|
|
828
856
|
|
|
829
857
|
// — Integração —
|
|
830
858
|
ai?: boolean | AIConfig
|
|
859
|
+
/**
|
|
860
|
+
* Aceita execução pelo sistema, sem usuário, quando um schedule a dispara. Sem este opt-in,
|
|
861
|
+
* a execução pelo sistema de uma action não pública termina em `auth.system_forbidden`. Não
|
|
862
|
+
* abre a action a chamadores anônimos. Ver `isSystemActor` e ADR 0028.
|
|
863
|
+
*/
|
|
831
864
|
automatable?: boolean
|
|
832
865
|
internal?: boolean
|
|
833
866
|
successStatus?: number
|
|
@@ -950,9 +983,17 @@ export interface ReactionDef<Event = unknown> {
|
|
|
950
983
|
|
|
951
984
|
handler: ReactionHandlerFn<Event>
|
|
952
985
|
|
|
986
|
+
/** Eventos que o handler pode publicar com `ctx.emit`; os demais são descartados (ADR 0027). */
|
|
987
|
+
emits?: string[]
|
|
988
|
+
|
|
989
|
+
// — Política de entrega (ADR 0026) —
|
|
990
|
+
/** Total de execuções do handler, com espera opcional entre elas. Sem `retry`, roda uma vez. */
|
|
953
991
|
retry?: { attempts: number; backoff?: BackoffSpec }
|
|
992
|
+
/** Prazo de cada tentativa, em ms; ao vencer, aborta `ctx.signal`. */
|
|
954
993
|
timeout?: number
|
|
994
|
+
/** Chave de idempotência: evento com chave já vista em `config.dedupCacheTtl` é descartado. */
|
|
955
995
|
dedup?: (event: DomainEvent<Event>) => string
|
|
996
|
+
/** Máximo de tentativas simultâneas desta reaction. Padrão 1. */
|
|
956
997
|
concurrency?: number
|
|
957
998
|
|
|
958
999
|
authorize?: (
|
|
@@ -1419,8 +1460,8 @@ export interface SchedulerAdapter extends Adapter {
|
|
|
1419
1460
|
* Registra um schedule e retorna função pra cancelar. Chamado pelo
|
|
1420
1461
|
* runtime no `start()` pra cada schedule.
|
|
1421
1462
|
*
|
|
1422
|
-
* Quando o tempo bater, o adapter chama `fire()
|
|
1423
|
-
*
|
|
1463
|
+
* Quando o tempo bater, o adapter chama `fire()`, e o runtime executa a
|
|
1464
|
+
* action como o sistema, com provenance schedule (ADR 0028).
|
|
1424
1465
|
*/
|
|
1425
1466
|
register(spec: ScheduleDef, fire: () => Promise<void> | void): Unsubscribe
|
|
1426
1467
|
}
|
|
@@ -1453,6 +1494,8 @@ export interface RuntimeConfig {
|
|
|
1453
1494
|
|
|
1454
1495
|
// — Resiliência —
|
|
1455
1496
|
dedupCacheTtl?: number
|
|
1497
|
+
/** Chaves concluídas que o dedup guarda por reaction; acima disso, descarta a mais antiga. Padrão 10.000. */
|
|
1498
|
+
dedupCacheMaxEntries?: number
|
|
1456
1499
|
defaultRetry?: { attempts: number; backoff?: BackoffSpec }
|
|
1457
1500
|
|
|
1458
1501
|
// — i18n —
|
|
@@ -1464,4 +1507,6 @@ export interface RuntimeConfig {
|
|
|
1464
1507
|
// — Limites —
|
|
1465
1508
|
maxConcurrentActions?: number
|
|
1466
1509
|
maxConcurrentReactions?: number
|
|
1510
|
+
/** Saltos de reaction que uma cadeia de eventos pode dar. Padrão 5 (ADR 0027). */
|
|
1511
|
+
maxReactionDepth?: number
|
|
1467
1512
|
}
|
package/src/testing/index.ts
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* capturado em `emitted`, log capturado em `logged`, `can` configurável,
|
|
10
10
|
* storage plugável (ex.: `memStorage()`).
|
|
11
11
|
* • `runAction(action, input)` — o MESMO pipeline do `runtime.execute`, em unidade:
|
|
12
|
-
* valida input → gate
|
|
13
|
-
* idêntica, `loaded` incluso) → handler →
|
|
14
|
-
* no runtime). Falha estoura o MESMO
|
|
15
|
-
* (`validation.invalid_input`, `auth.unauthenticated`,
|
|
12
|
+
* valida input → gate de autenticação (`public`, usuário ou sistema `automatable`) →
|
|
13
|
+
* `authorize` (string DSL ou closure, semântica idêntica, `loaded` incluso) → handler →
|
|
14
|
+
* valida output (pulado em `list`, como no runtime). Falha estoura o MESMO
|
|
15
|
+
* `ActionError` tipado do runtime (`validation.invalid_input`, `auth.unauthenticated`,
|
|
16
|
+
* `auth.system_forbidden`, `auth.forbidden`…). A opção `schedule` executa como o sistema.
|
|
16
17
|
* Em `OPUS_MODE=design` roteia pro `mockHandler` como o runtime — contrato puro
|
|
17
18
|
* (sem handler) é testável em design; fora dele, erro claro (`testing.no_handler`).
|
|
18
19
|
* • `memStorage()` — StorageAdapter em memória pra testar handler que anexa
|
|
@@ -31,7 +32,7 @@ import { normalizeKey } from '../storage/index.ts'
|
|
|
31
32
|
import { error } from '../core/errors.ts'
|
|
32
33
|
// Import direto do runtime (fora do index público): a régua handler×mockHandler
|
|
33
34
|
// tem UMA fonte — o harness nunca pode divergir do execute().
|
|
34
|
-
import { isOpusDesignMode } from '../core/runtime.ts'
|
|
35
|
+
import { assertAuthenticated, isOpusDesignMode, markSystemActor } from '../core/runtime.ts'
|
|
35
36
|
import type {
|
|
36
37
|
ActionContext,
|
|
37
38
|
ActionKind,
|
|
@@ -201,6 +202,7 @@ export interface TestableAction<In, Out, ParsedIn = In> {
|
|
|
201
202
|
name: string
|
|
202
203
|
kind?: ActionKind
|
|
203
204
|
public?: boolean
|
|
205
|
+
automatable?: boolean
|
|
204
206
|
authorize?: AuthorizeSpec<ParsedIn>
|
|
205
207
|
input: Schema<In, ParsedIn>
|
|
206
208
|
output: Schema<Out> | Schema<unknown, Out>
|
|
@@ -215,6 +217,7 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
|
|
|
215
217
|
name: string
|
|
216
218
|
kind: 'list'
|
|
217
219
|
public?: boolean
|
|
220
|
+
automatable?: boolean
|
|
218
221
|
authorize?: AuthorizeSpec<ParsedIn>
|
|
219
222
|
input: Schema<In, ParsedIn>
|
|
220
223
|
output: Schema<Item> | Schema<unknown, Item>
|
|
@@ -233,6 +236,12 @@ export interface TestableListAction<In, Item, ParsedIn = In> {
|
|
|
233
236
|
export interface RunActionOptions extends TestContextOptions {
|
|
234
237
|
/** Substitui os loaders (não rodam em unidade): vai pro handler e pro evalCtx do authorize. */
|
|
235
238
|
loaded?: Record<string, unknown>
|
|
239
|
+
/**
|
|
240
|
+
* Executa como o sistema, no disparo do schedule com este nome: `user` é `null`, `can` nega
|
|
241
|
+
* tudo, a provenance é `schedule` e `isSystemActor(ctx)` responde `true`. Como no runtime, a
|
|
242
|
+
* action precisa ser `automatable` (ou `public`); senão, `auth.system_forbidden`.
|
|
243
|
+
*/
|
|
244
|
+
schedule?: string
|
|
236
245
|
}
|
|
237
246
|
|
|
238
247
|
export interface RunActionResult<Out> extends TestContext {
|
|
@@ -261,7 +270,7 @@ async function validate<T>(
|
|
|
261
270
|
|
|
262
271
|
/**
|
|
263
272
|
* Roda uma action em UNIDADE com o pipeline do `runtime.execute`: valida input →
|
|
264
|
-
* gate
|
|
273
|
+
* gate de autenticação → authorize → handler → valida output. Sucesso devolve o output
|
|
265
274
|
* validado + o contexto observável (`emitted`, `logged`); falha estoura o mesmo
|
|
266
275
|
* `ActionError` do runtime — afirme com `expect(...).rejects`.
|
|
267
276
|
*
|
|
@@ -284,19 +293,21 @@ export async function runAction<In, Out, ParsedIn = In>(
|
|
|
284
293
|
input: unknown,
|
|
285
294
|
options: RunActionOptions = {},
|
|
286
295
|
): Promise<RunActionResult<Out>> {
|
|
287
|
-
const
|
|
296
|
+
const schedule = options.schedule
|
|
297
|
+
const t = testContext(
|
|
298
|
+
schedule === undefined ? options : { ...options, user: null, can: () => false },
|
|
299
|
+
)
|
|
288
300
|
const { ctx } = t
|
|
301
|
+
if (schedule !== undefined) {
|
|
302
|
+
ctx.provenance = { kind: 'schedule', scheduleName: schedule }
|
|
303
|
+
markSystemActor(ctx)
|
|
304
|
+
}
|
|
289
305
|
const loaded = options.loaded
|
|
290
306
|
|
|
291
307
|
const parsed = await validate<ParsedIn>(action.input, input, 'input')
|
|
292
308
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
code: 'auth.unauthenticated',
|
|
296
|
-
category: 'authentication',
|
|
297
|
-
message: 'Autenticação necessária',
|
|
298
|
-
})
|
|
299
|
-
}
|
|
309
|
+
// O mesmo gate do runtime: pública, com usuário ou, pelo sistema, `automatable`.
|
|
310
|
+
assertAuthenticated(action, ctx)
|
|
300
311
|
|
|
301
312
|
if (action.authorize !== undefined) {
|
|
302
313
|
const spec = action.authorize
|
|
@@ -10,9 +10,14 @@ import {
|
|
|
10
10
|
type ReactNode,
|
|
11
11
|
} from "react";
|
|
12
12
|
import { cn } from "../../lib/cn.ts";
|
|
13
|
+
import { IconTile, type IconTileProps } from "../primitives/icon-tile.tsx";
|
|
13
14
|
import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
|
|
14
15
|
import { headingTags } from "./heading-level.ts";
|
|
15
16
|
import { usePageHeadingPresence } from "./page-heading-context.tsx";
|
|
17
|
+
import {
|
|
18
|
+
SurfaceMedia,
|
|
19
|
+
type SurfaceMediaProps,
|
|
20
|
+
} from "./surface-media.tsx";
|
|
16
21
|
import {
|
|
17
22
|
SurfaceHeader,
|
|
18
23
|
surfaceHeaderClasses,
|
|
@@ -59,7 +64,8 @@ interface ContentSectionBaseProps
|
|
|
59
64
|
Omit<ComponentProps<"section">, "children" | "title"> {
|
|
60
65
|
/** Elemento semântico da região. */
|
|
61
66
|
as?: "section";
|
|
62
|
-
|
|
67
|
+
/** Tratamento visual da moldura: contínua (default) ou integrada ao header. */
|
|
68
|
+
frameVariant?: ContentFrameVariant;
|
|
63
69
|
/** Nível semântico do heading, independente de seu destaque visual. */
|
|
64
70
|
level?: ContentHeaderLevel;
|
|
65
71
|
}
|
|
@@ -78,6 +84,8 @@ interface ContentFieldsetBaseProps
|
|
|
78
84
|
|
|
79
85
|
interface ContentSectionShorthandProps extends ContentSectionBaseProps {
|
|
80
86
|
title: ReactNode;
|
|
87
|
+
/** Ícone ou imagem que identifica a região, antes do título. */
|
|
88
|
+
media?: ReactNode;
|
|
81
89
|
/** Total de itens ao lado do título da seção. A página não carrega contador (ADR 0009). */
|
|
82
90
|
count?: number;
|
|
83
91
|
description?: ReactNode;
|
|
@@ -87,6 +95,8 @@ interface ContentSectionShorthandProps extends ContentSectionBaseProps {
|
|
|
87
95
|
|
|
88
96
|
interface ContentFieldsetShorthandProps extends ContentFieldsetBaseProps {
|
|
89
97
|
title: ReactNode;
|
|
98
|
+
/** Ícone ou imagem decorativa que acompanha o título dentro da legenda. */
|
|
99
|
+
media?: ReactNode;
|
|
90
100
|
count?: never;
|
|
91
101
|
description?: never;
|
|
92
102
|
actions?: never;
|
|
@@ -95,6 +105,7 @@ interface ContentFieldsetShorthandProps extends ContentFieldsetBaseProps {
|
|
|
95
105
|
|
|
96
106
|
interface ContentComposedProps {
|
|
97
107
|
title?: never;
|
|
108
|
+
media?: never;
|
|
98
109
|
count?: never;
|
|
99
110
|
description?: never;
|
|
100
111
|
actions?: never;
|
|
@@ -111,6 +122,7 @@ export type ContentProps =
|
|
|
111
122
|
export function Content({
|
|
112
123
|
as = "section",
|
|
113
124
|
title,
|
|
125
|
+
media,
|
|
114
126
|
count,
|
|
115
127
|
description,
|
|
116
128
|
actions,
|
|
@@ -164,6 +176,7 @@ export function Content({
|
|
|
164
176
|
const anatomy = shorthand ? (
|
|
165
177
|
<>
|
|
166
178
|
<ContentHeader>
|
|
179
|
+
{hasContentMedia(media) && <ContentMedia>{media}</ContentMedia>}
|
|
167
180
|
<ContentTitle>{title}</ContentTitle>
|
|
168
181
|
{count !== undefined && <ContentMeta>{count}</ContentMeta>}
|
|
169
182
|
{description !== undefined && (
|
|
@@ -214,7 +227,12 @@ export function Content({
|
|
|
214
227
|
aria-labelledby={ariaLabelledBy}
|
|
215
228
|
className={cn(
|
|
216
229
|
"space-y-3",
|
|
217
|
-
framed &&
|
|
230
|
+
framed &&
|
|
231
|
+
frameVariant === "default" &&
|
|
232
|
+
"rounded-lg border border-border p-4",
|
|
233
|
+
framed &&
|
|
234
|
+
frameVariant === "legend" &&
|
|
235
|
+
"rounded-lg border border-border px-4 pb-4",
|
|
218
236
|
fill.filling && fillRegionClassName,
|
|
219
237
|
className,
|
|
220
238
|
)}
|
|
@@ -244,20 +262,15 @@ export function ContentHeader({
|
|
|
244
262
|
"Content",
|
|
245
263
|
);
|
|
246
264
|
if (content.element === "fieldset") {
|
|
247
|
-
const nodes = Children.toArray(children);
|
|
248
|
-
if (
|
|
249
|
-
nodes.length !== 1 ||
|
|
250
|
-
!isValidElement(nodes[0]) ||
|
|
251
|
-
nodes[0].type !== ContentTitle
|
|
252
|
-
) {
|
|
253
|
-
throw new Error(
|
|
254
|
-
"ContentHeader de fieldset exige somente um ContentTitle; contador, descrição e ações ficam fora da legenda.",
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
265
|
return (
|
|
258
266
|
<ContentHeaderContext.Provider value={content}>
|
|
259
|
-
<
|
|
260
|
-
|
|
267
|
+
<SurfaceHeader
|
|
268
|
+
as="legend"
|
|
269
|
+
name="ContentHeader"
|
|
270
|
+
slot="content"
|
|
271
|
+
slots={{ media: ContentMedia, title: ContentTitle }}
|
|
272
|
+
mediaPlacement="heading"
|
|
273
|
+
invalidMessage="ContentHeader de fieldset aceita somente ContentMedia e ContentTitle; contador, descrição e ações ficam fora da legenda."
|
|
261
274
|
className={cn(
|
|
262
275
|
content.framed &&
|
|
263
276
|
content.frameVariant === "default" &&
|
|
@@ -270,7 +283,7 @@ export function ContentHeader({
|
|
|
270
283
|
{...props}
|
|
271
284
|
>
|
|
272
285
|
{children}
|
|
273
|
-
</
|
|
286
|
+
</SurfaceHeader>
|
|
274
287
|
</ContentHeaderContext.Provider>
|
|
275
288
|
);
|
|
276
289
|
}
|
|
@@ -280,12 +293,25 @@ export function ContentHeader({
|
|
|
280
293
|
name="ContentHeader"
|
|
281
294
|
slot="content"
|
|
282
295
|
slots={{
|
|
296
|
+
media: ContentMedia,
|
|
283
297
|
title: ContentTitle,
|
|
284
298
|
count: ContentMeta,
|
|
285
299
|
description: ContentDescription,
|
|
286
300
|
actions: ContentActions,
|
|
287
301
|
}}
|
|
302
|
+
mediaPlacement="heading"
|
|
288
303
|
className={cn(
|
|
304
|
+
content.framed &&
|
|
305
|
+
content.frameVariant === "legend" &&
|
|
306
|
+
"flex items-center [&>[data-slot=content-actions]]:-mr-2 [&>[data-slot=content-actions]]:bg-[var(--surface-background,var(--background))] [&>[data-slot=content-actions]]:px-2 [&>[data-slot=content-heading]]:-ml-2 [&>[data-slot=content-heading]]:w-fit [&>[data-slot=content-heading]]:max-w-full [&>[data-slot=content-heading]]:bg-[var(--surface-background,var(--background))] [&>[data-slot=content-heading]]:px-2",
|
|
307
|
+
content.framed &&
|
|
308
|
+
content.frameVariant === "legend" &&
|
|
309
|
+
content.variant === "section" &&
|
|
310
|
+
"-mt-4 min-h-8",
|
|
311
|
+
content.framed &&
|
|
312
|
+
content.frameVariant === "legend" &&
|
|
313
|
+
content.variant === "page" &&
|
|
314
|
+
"-mt-5 min-h-10",
|
|
289
315
|
content.variant === "page" &&
|
|
290
316
|
"flex-row items-center justify-start gap-3",
|
|
291
317
|
className,
|
|
@@ -298,6 +324,59 @@ export function ContentHeader({
|
|
|
298
324
|
);
|
|
299
325
|
}
|
|
300
326
|
|
|
327
|
+
/** `null`, `false` e `undefined` não desenham mídia: uma moldura vazia não identifica nada. */
|
|
328
|
+
function hasContentMedia(media: ReactNode): boolean {
|
|
329
|
+
return media !== undefined && media !== null && media !== false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Um `IconTile` recebido como filho não vira uma segunda moldura: o slot assume o lugar dele e
|
|
334
|
+
* herda o que ele declara (`size`, `context`, `variant`, demais props), somando as classes.
|
|
335
|
+
*/
|
|
336
|
+
function adoptIconTile(props: SurfaceMediaProps): SurfaceMediaProps {
|
|
337
|
+
const { children, asChild, className } = props;
|
|
338
|
+
if (asChild) return props;
|
|
339
|
+
// `toArray`, não `only`: um array com um único item (de um `map`/`filter`) é válido aqui.
|
|
340
|
+
const nodes = Children.toArray(children);
|
|
341
|
+
if (nodes.length !== 1) return props;
|
|
342
|
+
const child = nodes[0];
|
|
343
|
+
if (!isValidElement<IconTileProps>(child) || child.type !== IconTile) {
|
|
344
|
+
return props;
|
|
345
|
+
}
|
|
346
|
+
// `null` em size/context/variant significa "use o padrão" no IconTile: fica o do slot.
|
|
347
|
+
const declared = Object.fromEntries(
|
|
348
|
+
Object.entries(child.props).filter(([, value]) => value != null),
|
|
349
|
+
) as SurfaceMediaProps;
|
|
350
|
+
return {
|
|
351
|
+
...props,
|
|
352
|
+
...declared,
|
|
353
|
+
className: cn(className, declared.className),
|
|
354
|
+
children: declared.children,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Moldura de ícone ou imagem que identifica a região nomeada. */
|
|
359
|
+
export function ContentMedia({
|
|
360
|
+
size = "xs",
|
|
361
|
+
...rest
|
|
362
|
+
}: SurfaceMediaProps): ReactElement {
|
|
363
|
+
const context = requiredContext(
|
|
364
|
+
useContext(ContentHeaderContext),
|
|
365
|
+
"ContentMedia",
|
|
366
|
+
"ContentHeader",
|
|
367
|
+
);
|
|
368
|
+
const props = adoptIconTile({ size, ...rest });
|
|
369
|
+
return (
|
|
370
|
+
<SurfaceMedia
|
|
371
|
+
slot="content-media"
|
|
372
|
+
{...props}
|
|
373
|
+
aria-hidden={
|
|
374
|
+
context.element === "fieldset" ? true : props["aria-hidden"]
|
|
375
|
+
}
|
|
376
|
+
/>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
301
380
|
export function ContentTitle({
|
|
302
381
|
className,
|
|
303
382
|
id,
|
|
@@ -1218,20 +1218,8 @@ function AdvancedFiltersDialog({
|
|
|
1218
1218
|
frameVariant={
|
|
1219
1219
|
sectionVariant === "framed" ? "legend" : "default"
|
|
1220
1220
|
}
|
|
1221
|
-
title={
|
|
1222
|
-
|
|
1223
|
-
{sectionIcons?.[section] !== undefined && (
|
|
1224
|
-
<span
|
|
1225
|
-
data-slot="content-title-icon"
|
|
1226
|
-
aria-hidden="true"
|
|
1227
|
-
className="inline-flex shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4"
|
|
1228
|
-
>
|
|
1229
|
-
{sectionIcons[section]}
|
|
1230
|
-
</span>
|
|
1231
|
-
)}
|
|
1232
|
-
{section}
|
|
1233
|
-
</span>
|
|
1234
|
-
}
|
|
1221
|
+
title={section}
|
|
1222
|
+
media={sectionIcons?.[section]}
|
|
1235
1223
|
>
|
|
1236
1224
|
<FieldGroup className={cn("grid gap-4", gridColumns)}>
|
|
1237
1225
|
{entries.map(field)}
|
|
@@ -45,10 +45,12 @@ interface SurfaceHeaderSlots {
|
|
|
45
45
|
title: ElementType;
|
|
46
46
|
count?: ElementType;
|
|
47
47
|
description?: ElementType;
|
|
48
|
-
actions
|
|
48
|
+
actions?: ElementType;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
52
|
+
/** Elemento estrutural; `legend` preserva a mesma anatomia com wrappers phrasing. */
|
|
53
|
+
as?: "div" | "legend";
|
|
52
54
|
/** Nome do componente público, para as mensagens de erro (`PageHeader`, `ContentHeader`). */
|
|
53
55
|
name: string;
|
|
54
56
|
/** Prefixo dos `data-slot` (`page` → `page-header`, `page-heading`). */
|
|
@@ -57,6 +59,8 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
57
59
|
slots: SurfaceHeaderSlots;
|
|
58
60
|
/** Posição de um slot introdutório opcional, antes da linha ou dentro dela. */
|
|
59
61
|
leadingPlacement?: "above" | "inline";
|
|
62
|
+
/** Mantém a mídia na linha geral ou a agrupa com o heading. */
|
|
63
|
+
mediaPlacement?: "row" | "heading";
|
|
60
64
|
/** Classes de um container interno quando a moldura precisa ocupar toda a largura. */
|
|
61
65
|
contentClassName?: string;
|
|
62
66
|
/** Permite uma superfície composta apenas pelas regiões leading/actions. */
|
|
@@ -71,6 +75,11 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
71
75
|
* NOMEIAM algo (Card, Dialog, Drawer, Page, Content) continuam estritas.
|
|
72
76
|
*/
|
|
73
77
|
allowFreeContent?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Mensagem própria para uma composição inválida, quando a regra do header não cabe na
|
|
80
|
+
* mensagem genérica (a legenda de um fieldset aceita menos slots que o header comum).
|
|
81
|
+
*/
|
|
82
|
+
invalidMessage?: string;
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
/** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
|
|
@@ -83,19 +92,23 @@ function flatten(children: ReactNode): ReactNode[] {
|
|
|
83
92
|
}
|
|
84
93
|
|
|
85
94
|
export function SurfaceHeader({
|
|
95
|
+
as: Root = "div",
|
|
86
96
|
name,
|
|
87
97
|
slot,
|
|
88
98
|
slots,
|
|
89
99
|
leadingPlacement = "above",
|
|
100
|
+
mediaPlacement = "row",
|
|
90
101
|
contentClassName,
|
|
91
102
|
titleRequired = true,
|
|
92
103
|
rowClassName,
|
|
93
104
|
trailing,
|
|
94
105
|
allowFreeContent = false,
|
|
106
|
+
invalidMessage,
|
|
95
107
|
className,
|
|
96
108
|
children,
|
|
97
109
|
...props
|
|
98
110
|
}: SurfaceHeaderProps): ReactElement {
|
|
111
|
+
const Wrapper = Root === "legend" ? "span" : "div";
|
|
99
112
|
const nodes = flatten(children);
|
|
100
113
|
const of = (type: ElementType) =>
|
|
101
114
|
nodes.filter((node) => isValidElement(node) && node.type === type);
|
|
@@ -111,7 +124,8 @@ export function SurfaceHeader({
|
|
|
111
124
|
const counts = slots.count === undefined ? [] : of(slots.count);
|
|
112
125
|
const descriptions =
|
|
113
126
|
slots.description === undefined ? [] : of(slots.description);
|
|
114
|
-
const actionSlots =
|
|
127
|
+
const actionSlots =
|
|
128
|
+
slots.actions === undefined ? [] : of(slots.actions);
|
|
115
129
|
const slotTypes = [
|
|
116
130
|
...leadingTypes,
|
|
117
131
|
slots.media,
|
|
@@ -167,6 +181,7 @@ export function SurfaceHeader({
|
|
|
167
181
|
isValidElement(node) ? [node.type as ElementType] : [],
|
|
168
182
|
),
|
|
169
183
|
);
|
|
184
|
+
if (invalidMessage !== undefined) throw new Error(invalidMessage);
|
|
170
185
|
throw new Error(
|
|
171
186
|
distinctLeading.size > 1
|
|
172
187
|
? `${name} aceita ${[...distinctLeading].map(label).join(" ou ")} na região introdutória, nunca os dois na mesma superfície.`
|
|
@@ -174,24 +189,42 @@ export function SurfaceHeader({
|
|
|
174
189
|
);
|
|
175
190
|
}
|
|
176
191
|
|
|
192
|
+
const titleLine = titles.length + counts.length > 0 && (
|
|
193
|
+
<Wrapper className="flex items-baseline gap-2">
|
|
194
|
+
{titles}
|
|
195
|
+
{counts}
|
|
196
|
+
</Wrapper>
|
|
197
|
+
);
|
|
177
198
|
const heading = titles.length +
|
|
178
199
|
counts.length +
|
|
179
200
|
descriptions.length +
|
|
180
|
-
free.length
|
|
201
|
+
free.length +
|
|
202
|
+
(mediaPlacement === "heading" ? medias.length : 0) >
|
|
181
203
|
0 && (
|
|
182
|
-
<
|
|
204
|
+
<Wrapper
|
|
183
205
|
data-slot={`${slot}-heading`}
|
|
184
206
|
className={cn("min-w-0", leadingPlacement === "inline" && "flex-1")}
|
|
185
207
|
>
|
|
186
|
-
{
|
|
187
|
-
<
|
|
188
|
-
{
|
|
189
|
-
|
|
190
|
-
|
|
208
|
+
{mediaPlacement === "heading" && medias.length > 0 ? (
|
|
209
|
+
<Wrapper
|
|
210
|
+
data-slot={`${slot}-heading-row`}
|
|
211
|
+
className="flex items-center gap-2"
|
|
212
|
+
>
|
|
213
|
+
{medias}
|
|
214
|
+
<Wrapper data-slot={`${slot}-heading-content`} className="min-w-0">
|
|
215
|
+
{titleLine}
|
|
216
|
+
{descriptions}
|
|
217
|
+
{free}
|
|
218
|
+
</Wrapper>
|
|
219
|
+
</Wrapper>
|
|
220
|
+
) : (
|
|
221
|
+
<>
|
|
222
|
+
{titleLine}
|
|
223
|
+
{descriptions}
|
|
224
|
+
{free}
|
|
225
|
+
</>
|
|
191
226
|
)}
|
|
192
|
-
|
|
193
|
-
{free}
|
|
194
|
-
</div>
|
|
227
|
+
</Wrapper>
|
|
195
228
|
);
|
|
196
229
|
// A região introdutória carrega o mesmo `data-slot` nas duas apresentações: seletor de teste
|
|
197
230
|
// ou de CSS que funcione no header padrão precisa funcionar na barra.
|
|
@@ -200,14 +233,14 @@ export function SurfaceHeader({
|
|
|
200
233
|
{leadingPlacement === "inline" && leading.length > 0 && (
|
|
201
234
|
// `min-w-0` aqui é o que deixa a trilha do PageNavigation ceder e truncar; sem ele o
|
|
202
235
|
// wrapper assume o min-content do breadcrumb e a compressão toda cai sobre o título.
|
|
203
|
-
<
|
|
236
|
+
<Wrapper
|
|
204
237
|
data-slot={`${slot}-navigation`}
|
|
205
238
|
className={cn("flex min-w-0 items-center", !heading && "flex-1")}
|
|
206
239
|
>
|
|
207
240
|
{leading}
|
|
208
|
-
</
|
|
241
|
+
</Wrapper>
|
|
209
242
|
)}
|
|
210
|
-
{medias}
|
|
243
|
+
{mediaPlacement === "row" && medias}
|
|
211
244
|
{heading}
|
|
212
245
|
{actionSlots}
|
|
213
246
|
{trailing}
|
|
@@ -215,15 +248,15 @@ export function SurfaceHeader({
|
|
|
215
248
|
);
|
|
216
249
|
const layout =
|
|
217
250
|
leadingPlacement === "inline" ? (
|
|
218
|
-
<
|
|
251
|
+
<Wrapper className={cn("flex min-w-0 items-center gap-3", rowClassName)}>
|
|
219
252
|
{row}
|
|
220
|
-
</
|
|
253
|
+
</Wrapper>
|
|
221
254
|
) : leading.length > 0 ? (
|
|
222
255
|
<>
|
|
223
|
-
<
|
|
256
|
+
<Wrapper data-slot={`${slot}-navigation`} className="mb-4">
|
|
224
257
|
{leading}
|
|
225
|
-
</
|
|
226
|
-
<
|
|
258
|
+
</Wrapper>
|
|
259
|
+
<Wrapper
|
|
227
260
|
data-slot={`${slot}-header-row`}
|
|
228
261
|
className={cn(
|
|
229
262
|
actionSlots.length > 0 &&
|
|
@@ -232,7 +265,7 @@ export function SurfaceHeader({
|
|
|
232
265
|
>
|
|
233
266
|
{heading}
|
|
234
267
|
{actionSlots}
|
|
235
|
-
</
|
|
268
|
+
</Wrapper>
|
|
236
269
|
</>
|
|
237
270
|
) : (
|
|
238
271
|
<>
|
|
@@ -242,7 +275,7 @@ export function SurfaceHeader({
|
|
|
242
275
|
);
|
|
243
276
|
|
|
244
277
|
return (
|
|
245
|
-
<
|
|
278
|
+
<Root
|
|
246
279
|
data-slot={`${slot}-header`}
|
|
247
280
|
className={cn(
|
|
248
281
|
contentClassName === undefined &&
|
|
@@ -257,10 +290,10 @@ export function SurfaceHeader({
|
|
|
257
290
|
{contentClassName === undefined ? (
|
|
258
291
|
layout
|
|
259
292
|
) : (
|
|
260
|
-
<
|
|
293
|
+
<Wrapper data-slot={`${slot}-header-content`} className={contentClassName}>
|
|
261
294
|
{layout}
|
|
262
|
-
</
|
|
295
|
+
</Wrapper>
|
|
263
296
|
)}
|
|
264
|
-
</
|
|
297
|
+
</Root>
|
|
265
298
|
);
|
|
266
299
|
}
|
|
@@ -52,7 +52,7 @@ export function Card({
|
|
|
52
52
|
data-slot="card"
|
|
53
53
|
data-layout={layout}
|
|
54
54
|
className={cn(
|
|
55
|
-
"rounded-xl border border-border bg-card text-card-foreground",
|
|
55
|
+
"rounded-xl border border-border bg-card text-card-foreground [--surface-background:var(--card)]",
|
|
56
56
|
// Seccionado: o divisor encosta na borda, então o raio precisa recortar.
|
|
57
57
|
layout === "sectioned" && "overflow-hidden",
|
|
58
58
|
className,
|
|
@@ -230,7 +230,7 @@ export function DialogContent({
|
|
|
230
230
|
const { mode } = useDialogContext();
|
|
231
231
|
const showClose = showCloseButton ?? mode !== "alert";
|
|
232
232
|
const contentClasses =
|
|
233
|
-
"fixed left-[50%] top-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-clip rounded-xl border border-transparent bg-background text-foreground shadow-xl duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/70";
|
|
233
|
+
"fixed left-[50%] top-[50%] z-50 w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] overflow-clip rounded-xl border border-transparent bg-background text-foreground shadow-xl duration-200 outline-none [--surface-background:var(--background)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/70";
|
|
234
234
|
|
|
235
235
|
if (mode === "alert") {
|
|
236
236
|
return (
|