@softize/opus 12.5.5 → 12.6.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 CHANGED
@@ -7,6 +7,21 @@ 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.6.0 — 2026-08-25
11
+
12
+ `ContentHeader` oferece a composição compartilhada de título, descrição, metadados e ações para
13
+ páginas e seções. `Page` usa a mesma estrutura, enquanto `level` mantém a hierarquia semântica
14
+ independente do destaque visual.
15
+
16
+ `ActionFilterBar` expõe os filtros declarativos de uma list action para telas que precisam da
17
+ toolbar sem delegar a renderização dos resultados ao `ActionList`. `ItemGroup` também passa a
18
+ oferecer moldura explícita, e seus itens carregam semântica de lista por padrão.
19
+
20
+ Os patterns existentes passam a reutilizar os primitives canônicos: `ActionForm` usa `Field`,
21
+ `FieldGroup`, descrições e erros associados ao controle; `ActionList` usa a moldura de `Table` e
22
+ os estados seguros de `DataState`, preservando a tentativa de recuperação; e a confirmação de uma
23
+ `ActionTrigger` destrutiva mantém o tratamento destrutivo mesmo quando o gatilho visual é discreto.
24
+
10
25
  ## 12.5.5 — 2026-08-25
11
26
 
12
27
  Só documentação: leva ao pacote a correção de nota que a 12.5.4 recebeu no repositório depois de
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "12.5.5",
3
+ "version": "12.6.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",
@@ -0,0 +1,88 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from '../../lib/cn.ts'
3
+
4
+ export type ContentHeaderLevel = 1 | 2 | 3 | 4 | 5 | 6
5
+ export type ContentHeaderVariant = 'page' | 'section'
6
+
7
+ export interface ContentHeaderProps {
8
+ title: ReactNode
9
+ /** Conteúdo complementar exibido ao lado do título, como uma contagem. */
10
+ meta?: ReactNode
11
+ description?: ReactNode
12
+ actions?: ReactNode
13
+ /** Nível semântico do heading, independente de seu destaque visual. */
14
+ level?: ContentHeaderLevel
15
+ /** Hierarquia visual: destaque principal ou cabeçalho interno de seção. */
16
+ variant?: ContentHeaderVariant
17
+ className?: string
18
+ }
19
+
20
+ interface ContentHeaderSlots {
21
+ root?: string
22
+ heading: string
23
+ actions: string
24
+ }
25
+
26
+ interface ContentHeaderFrameProps extends ContentHeaderProps {
27
+ slots: ContentHeaderSlots
28
+ }
29
+
30
+ const headingTags = {
31
+ 1: 'h1',
32
+ 2: 'h2',
33
+ 3: 'h3',
34
+ 4: 'h4',
35
+ 5: 'h5',
36
+ 6: 'h6',
37
+ } as const
38
+
39
+ /** Layout compartilhado com Page; mantém os slots históricos sem expô-los na API pública. */
40
+ export function ContentHeaderFrame({
41
+ title,
42
+ meta,
43
+ description,
44
+ actions,
45
+ level = 2,
46
+ variant = 'section',
47
+ className,
48
+ slots,
49
+ }: ContentHeaderFrameProps): React.ReactElement {
50
+ const Heading = headingTags[level]
51
+ const page = variant === 'page'
52
+
53
+ return (
54
+ <div
55
+ {...(slots.root === undefined ? {} : { 'data-slot': slots.root })}
56
+ className={cn(actions !== undefined && 'flex items-end justify-between gap-4', className)}
57
+ >
58
+ <div data-slot={slots.heading} className="min-w-0">
59
+ <Heading
60
+ className={cn(
61
+ 'flex items-baseline gap-2 font-semibold',
62
+ page ? 'text-2xl tracking-tight' : 'text-sm',
63
+ )}
64
+ >
65
+ {title}
66
+ {meta}
67
+ </Heading>
68
+ {description !== undefined && (
69
+ <p className={cn('truncate text-muted-foreground', page ? 'mt-1 text-sm' : 'mt-0.5 text-xs')}>{description}</p>
70
+ )}
71
+ </div>
72
+ {actions !== undefined && (
73
+ <div data-slot={slots.actions} className={cn('flex shrink-0 items-center', page ? 'gap-4' : 'gap-2')}>
74
+ {actions}
75
+ </div>
76
+ )}
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export function ContentHeader(props: ContentHeaderProps): React.ReactElement {
82
+ return (
83
+ <ContentHeaderFrame
84
+ {...props}
85
+ slots={{ root: 'content-header', heading: 'content-header-heading', actions: 'content-header-actions' }}
86
+ />
87
+ )
88
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import * as React from 'react'
11
11
  import { cn } from '../../lib/cn.ts'
12
+ import { Button } from '../primitives/button.tsx'
12
13
  import { Spinner } from '../primitives/spinner.tsx'
13
14
 
14
15
  export interface DataStateProps {
@@ -22,6 +23,9 @@ export interface DataStateProps {
22
23
  emptyText?: string
23
24
  /** Aviso de erro (calmo, orientado ao usuário). Default: "Não foi possível carregar.". */
24
25
  errorText?: string
26
+ /** Recuperação opcional exibida somente no estado de erro. */
27
+ onRetry?: () => Promise<void> | void
28
+ retryText?: string
25
29
  /** Em TABELA: renderiza o estado como UMA linha (<tr><td colSpan>), não um bloco — pra
26
30
  * caber direto no <tbody>. Passe o nº de colunas da tabela. */
27
31
  colSpan?: number
@@ -36,6 +40,8 @@ export function DataState({
36
40
  empty = false,
37
41
  emptyText = 'Nada por aqui.',
38
42
  errorText,
43
+ onRetry,
44
+ retryText = 'Tentar de novo',
39
45
  colSpan,
40
46
  children,
41
47
  }: DataStateProps): React.ReactElement {
@@ -50,7 +56,20 @@ export function DataState({
50
56
  colSpan={colSpan}
51
57
  className={cn('px-4 py-10 text-center text-sm', error ? 'text-destructive' : 'text-muted-foreground/60')}
52
58
  >
53
- {error ? (errorText ?? 'Não foi possível carregar.') : loading ? <Spinner className="mx-auto" /> : emptyText}
59
+ {error ? (
60
+ <div className="flex flex-col items-center gap-3">
61
+ <span>{errorText ?? 'Não foi possível carregar.'}</span>
62
+ {onRetry !== undefined && (
63
+ <Button variant="outline" size="sm" onClick={() => void onRetry()}>
64
+ {retryText}
65
+ </Button>
66
+ )}
67
+ </div>
68
+ ) : loading ? (
69
+ <Spinner className="mx-auto" />
70
+ ) : (
71
+ emptyText
72
+ )}
54
73
  </td>
55
74
  </tr>
56
75
  )
@@ -59,8 +78,13 @@ export function DataState({
59
78
  // Modo BLOCO (default).
60
79
  if (error) {
61
80
  return (
62
- <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-center text-sm text-destructive">
63
- {errorText ?? 'Não foi possível carregar.'}
81
+ <div className="flex flex-col items-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-center text-sm text-destructive">
82
+ <span>{errorText ?? 'Não foi possível carregar.'}</span>
83
+ {onRetry !== undefined && (
84
+ <Button variant="outline" size="sm" onClick={() => void onRetry()}>
85
+ {retryText}
86
+ </Button>
87
+ )}
64
88
  </div>
65
89
  )
66
90
  }
@@ -2,12 +2,11 @@
2
2
  * <ActionForm action /> — renderiza um FormAction do Opus em UI do Opus.
3
3
  *
4
4
  * Dois modos, um motor:
5
- * - AUTO (sem children): monta TODOS os campos na ordem do contrato — zero JSX de campo;
6
- * o espaçamento entre campos (space-y-6) é deste modo, que é quem diagrama.
5
+ * - AUTO (sem children): monta TODOS os campos na ordem do contrato — zero JSX de campo.
7
6
  * - COMPOSIÇÃO (com children): você diagrama; <ActionFormField name /> coloca cada campo
8
7
  * (label/hint/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
9
8
  * JSX ({cond && <ActionFormField/>}); opções de runtime entram por prop no campo. O
10
- * espaçamento é 100% seu nenhuma margem imposta (o campo não sabe onde será colocado).
9
+ * FieldGroup canônico permanece nos dois modos; grids e seções entram como filhos explícitos.
11
10
  *
12
11
  * Auto-detect do Zod (nos dois modos):
13
12
  * - z.enum(...) → <Select> com opções inferidas
@@ -28,12 +27,12 @@ import { toast } from '../primitives/sonner.tsx'
28
27
  import { Button } from '../primitives/button.tsx'
29
28
  import { Input } from '../primitives/input.tsx'
30
29
  import { Textarea } from '../primitives/textarea.tsx'
31
- import { Label } from '../primitives/label.tsx'
32
30
  import { Checkbox } from '../primitives/checkbox.tsx'
33
31
  import { IconPicker } from '../primitives/icon-picker.tsx'
34
32
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip.tsx'
35
33
  import { Info } from 'lucide-react'
36
34
  import { Select, type SelectOption } from '../primitives/select.tsx'
35
+ import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
37
36
 
38
37
  // =============================================================================
39
38
  // Inferência de tipo de field a partir do Zod schema
@@ -265,12 +264,6 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
265
264
  // Assina TODOS os valores: alimenta o showWhen e os widgets controlados.
266
265
  const formValues = form.watch()
267
266
 
268
- // Clique na label foca o controle — todo controle da casa honra o id (o Select
269
- // reaplica o dele, que o cmdk sobrescreve).
270
- const focusControl = (): void => {
271
- document.getElementById(name)?.focus()
272
- }
273
-
274
267
  const spec: FieldSpec = fields[name] ?? {}
275
268
  const fieldSchema = shape[name]
276
269
  if (fieldSchema === undefined) return null
@@ -290,6 +283,9 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
290
283
  : inferred
291
284
  const fieldError = form.formState.errors[name]
292
285
  const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
286
+ const descriptionId = spec.hint !== undefined ? `${name}-description` : undefined
287
+ const errorId = errorMessage !== undefined ? `${name}-error` : undefined
288
+ const describedBy = [descriptionId, errorId].filter(Boolean).join(' ') || undefined
293
289
  // Precedência das opções: prop do campo > fieldOptions do form > spec.options
294
290
  // (dictionary via provider, static direto) > meta do t.dict no schema
295
291
  // (zero-config) > chaves cruas do z.enum.
@@ -314,34 +310,46 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
314
310
  // Checkbox tem layout próprio (controle + label na mesma linha).
315
311
  if (fieldKind.kind === 'checkbox') {
316
312
  return (
317
- <div className={cn('space-y-2', className)}>
318
- <label className="flex items-center gap-2">
313
+ <Field
314
+ className={className}
315
+ data-invalid={errorMessage !== undefined}
316
+ aria-invalid={errorMessage !== undefined}
317
+ aria-describedby={describedBy}
318
+ >
319
+ <FieldLabel htmlFor={name} className="items-center">
319
320
  <Checkbox
320
321
  id={name}
321
322
  checked={(formValues[name] as boolean | undefined) ?? false}
322
323
  onCheckedChange={(v) => setValue(v === true)}
324
+ aria-invalid={errorMessage !== undefined}
325
+ aria-describedby={describedBy}
323
326
  />
324
- <span className="text-sm font-medium">
327
+ <span>
325
328
  {spec.label ?? name}
326
329
  <RequiredMark required={fieldKind.required} />
327
330
  </span>
328
331
  <LabelHelp help={spec.help} />
329
- </label>
330
- {spec.hint !== undefined && <p className="text-xs text-muted-foreground">{spec.hint}</p>}
331
- {errorMessage !== undefined && <p className="text-xs text-destructive">{errorMessage}</p>}
332
- </div>
332
+ </FieldLabel>
333
+ {spec.hint !== undefined && <FieldDescription id={descriptionId}>{spec.hint}</FieldDescription>}
334
+ {errorMessage !== undefined && <FieldError id={errorId}>{errorMessage}</FieldError>}
335
+ </Field>
333
336
  )
334
337
  }
335
338
 
336
339
  return (
337
- <div className={cn('space-y-2', className)}>
338
- <Label htmlFor={name} onClick={focusControl} className="flex items-center gap-1.5">
340
+ <Field
341
+ className={className}
342
+ data-invalid={errorMessage !== undefined}
343
+ aria-invalid={errorMessage !== undefined}
344
+ aria-describedby={describedBy}
345
+ >
346
+ <FieldLabel htmlFor={name} className="items-center gap-1.5">
339
347
  <span>
340
348
  {spec.label ?? name}
341
349
  <RequiredMark required={fieldKind.required} />
342
350
  </span>
343
351
  <LabelHelp help={spec.help} />
344
- </Label>
352
+ </FieldLabel>
345
353
 
346
354
  {effectiveKind.kind === 'multiselect' ? (
347
355
  <Select
@@ -353,6 +361,8 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
353
361
  options={options}
354
362
  placeholder={spec.placeholder ?? 'Selecione…'}
355
363
  className="w-full"
364
+ aria-invalid={errorMessage !== undefined}
365
+ aria-describedby={describedBy}
356
366
  />
357
367
  ) : effectiveKind.kind === 'select' ? (
358
368
  // w-full: campo de FORM alinha com os inputs (todos cheios) — coluna uniforme.
@@ -369,6 +379,8 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
369
379
  searchable={options.length > 6}
370
380
  placeholder={spec.placeholder ?? 'Selecione…'}
371
381
  className="w-full"
382
+ aria-invalid={errorMessage !== undefined}
383
+ aria-describedby={describedBy}
372
384
  />
373
385
  ) : effectiveKind.kind === 'lines' ? (
374
386
  // Lista de strings num textarea: um item por linha. split/join round-trip
@@ -380,6 +392,8 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
380
392
  className="max-h-80"
381
393
  value={((formValues[name] as string[] | undefined) ?? []).join('\n')}
382
394
  onChange={(e) => setValue(e.target.value.split('\n'))}
395
+ aria-invalid={errorMessage !== undefined}
396
+ aria-describedby={describedBy}
383
397
  />
384
398
  ) : effectiveKind.kind === 'refItems' ? (
385
399
  // Lista composta {ref, text}: cada linha = select de referência (opções de
@@ -392,16 +406,22 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
392
406
  {items.map((item, i) => (
393
407
  <div key={i} className="flex items-start gap-2">
394
408
  <Select
409
+ id={i === 0 ? name : `${name}-${i}-ref`}
395
410
  value={item.ref}
396
411
  onChange={(v) => setItems(items.map((it, j) => (j === i ? { ...it, ref: v } : it)))}
397
412
  options={options}
398
413
  placeholder="Papel…"
399
414
  className="w-44 shrink-0"
415
+ aria-invalid={errorMessage !== undefined}
416
+ aria-describedby={describedBy}
400
417
  />
401
418
  <Input
419
+ id={`${name}-${i}-text`}
402
420
  value={item.text}
403
421
  placeholder={spec.placeholder ?? 'O quê (com critério)…'}
404
422
  onChange={(e) => setItems(items.map((it, j) => (j === i ? { ...it, text: e.target.value } : it)))}
423
+ aria-invalid={errorMessage !== undefined}
424
+ aria-describedby={describedBy}
405
425
  />
406
426
  <Button
407
427
  type="button"
@@ -419,7 +439,10 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
419
439
  type="button"
420
440
  variant="outline"
421
441
  size="sm"
442
+ id={items.length === 0 ? name : undefined}
422
443
  onClick={() => setItems([...items, { ref: '', text: '' }])}
444
+ aria-invalid={items.length === 0 && errorMessage !== undefined ? true : undefined}
445
+ aria-describedby={items.length === 0 ? describedBy : undefined}
423
446
  >
424
447
  Adicionar item
425
448
  </Button>
@@ -433,6 +456,8 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
433
456
  value={(formValues[name] as string | undefined) ?? ''}
434
457
  onChange={(v) => setValue(v)}
435
458
  placeholder={spec.placeholder ?? 'Selecione um ícone…'}
459
+ aria-invalid={errorMessage !== undefined}
460
+ aria-describedby={describedBy}
436
461
  />
437
462
  ) : effectiveKind.kind === 'textarea' ? (
438
463
  // max-h: o Textarea v4 auto-cresce (field-sizing-content) — capa e scrolla
@@ -442,23 +467,27 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
442
467
  placeholder={spec.placeholder ?? ''}
443
468
  rows={5}
444
469
  className={spec.widget === 'code' ? 'max-h-80 font-mono text-xs' : 'max-h-80'}
470
+ aria-invalid={errorMessage !== undefined}
471
+ aria-describedby={describedBy}
445
472
  {...form.register(name)}
446
473
  />
447
474
  ) : (
448
475
  <Input
449
476
  id={name}
450
477
  placeholder={spec.placeholder ?? ''}
478
+ aria-invalid={errorMessage !== undefined}
479
+ aria-describedby={describedBy}
451
480
  {...form.register(name)}
452
481
  />
453
482
  )}
454
483
 
455
484
  {spec.hint !== undefined && (
456
- <p className="text-xs text-muted-foreground">{spec.hint}</p>
485
+ <FieldDescription id={descriptionId}>{spec.hint}</FieldDescription>
457
486
  )}
458
487
  {errorMessage !== undefined && (
459
- <p className="text-xs text-destructive">{errorMessage}</p>
488
+ <FieldError id={errorId}>{errorMessage}</FieldError>
460
489
  )}
461
- </div>
490
+ </Field>
462
491
  )
463
492
  }
464
493
 
@@ -527,15 +556,12 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
527
556
  <div className="mt-6 flex shrink-0 justify-end gap-2">{actions}</div>
528
557
  ))
529
558
 
530
- // Banner de erro do servidor (chrome do próprio form, nos dois modos). No AUTO o
531
- // space-y-6 do wrapper espaça; na COMPOSIÇÃO ele carrega a própria separação (mt-6).
559
+ // Banner de erro do servidor (chrome do próprio form, nos dois modos). O FieldGroup
560
+ // mantém o mesmo ritmo entre campos, blocos compostos e este estado.
532
561
  const errorBanner =
533
562
  error !== undefined && !isSuccess ? (
534
563
  <div
535
- className={cn(
536
- 'rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive',
537
- children !== undefined && 'mt-6',
538
- )}
564
+ className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
539
565
  >
540
566
  <strong>{error.code}</strong>: {error.message}
541
567
  </div>
@@ -552,22 +578,12 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
552
578
  >
553
579
  <form onSubmit={submit} className={cn('flex flex-col', className)} data-action={action.name}>
554
580
  {wrapBody(
555
- children !== undefined ? (
556
- // COMPOSIÇÃO: nenhum espaçamento imposto — o componente não sabe onde será
557
- // colocado; a diagramação (grid/gap/space-y) é 100% de quem compõe.
558
- <>
559
- {children}
560
- {errorBanner}
561
- </>
562
- ) : (
563
- // AUTO: aqui o ActionForm É o pai que diagrama — o space-y-6 é dele.
564
- <div className="space-y-6">
565
- {Object.keys(fields).map((name) => (
566
- <ActionFormField key={name} name={name} />
567
- ))}
568
- {errorBanner}
569
- </div>
570
- ),
581
+ <FieldGroup>
582
+ {children !== undefined
583
+ ? children
584
+ : Object.keys(fields).map((name) => <ActionFormField key={name} name={name} />)}
585
+ {errorBanner}
586
+ </FieldGroup>,
571
587
  )}
572
588
 
573
589
  {wrapFooter(