@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 +15 -0
- package/package.json +1 -1
- package/src/ui/components/patterns/content-header.tsx +88 -0
- package/src/ui/components/patterns/data-state.tsx +27 -3
- package/src/ui/components/patterns/form.tsx +61 -45
- package/src/ui/components/patterns/list.tsx +238 -231
- package/src/ui/components/patterns/page.tsx +14 -15
- package/src/ui/components/patterns/trigger.tsx +4 -3
- package/src/ui/components/primitives/field.tsx +5 -5
- package/src/ui/components/primitives/icon-picker.tsx +6 -0
- package/src/ui/components/primitives/item.tsx +46 -8
- package/src/ui/components/primitives/select.tsx +7 -0
- package/src/ui/docs/content/action-list.md +20 -1
- package/src/ui/docs/content/item.md +4 -3
- package/src/ui/meta.ts +7 -1
- package/src/ui/react.tsx +10 -2
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
|
@@ -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 ? (
|
|
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
|
-
*
|
|
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
|
-
<
|
|
318
|
-
|
|
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
|
|
327
|
+
<span>
|
|
325
328
|
{spec.label ?? name}
|
|
326
329
|
<RequiredMark required={fieldKind.required} />
|
|
327
330
|
</span>
|
|
328
331
|
<LabelHelp help={spec.help} />
|
|
329
|
-
</
|
|
330
|
-
{spec.hint !== undefined && <
|
|
331
|
-
{errorMessage !== undefined && <
|
|
332
|
-
</
|
|
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
|
-
<
|
|
338
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
485
|
+
<FieldDescription id={descriptionId}>{spec.hint}</FieldDescription>
|
|
457
486
|
)}
|
|
458
487
|
{errorMessage !== undefined && (
|
|
459
|
-
<
|
|
488
|
+
<FieldError id={errorId}>{errorMessage}</FieldError>
|
|
460
489
|
)}
|
|
461
|
-
</
|
|
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).
|
|
531
|
-
//
|
|
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=
|
|
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
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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(
|