@softize/opus 17.2.0 → 18.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/bin/lib/check.mjs +212 -45
  3. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +4 -0
  4. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +14 -2
  5. package/docs/adr/0016-list-collection-header-belongs-to-content.md +80 -0
  6. package/package.json +1 -1
  7. package/registry/skills/build-opus-ui/SKILL.md +30 -20
  8. package/registry/skills/build-opus-ui/references/evaluations.md +9 -3
  9. package/registry/skills/build-opus-ui/references/ui-patterns.md +29 -17
  10. package/src/core/presentation.ts +223 -24
  11. package/src/core/runtime.ts +3 -0
  12. package/src/core/types.ts +2 -0
  13. package/src/mcp/index.ts +1 -0
  14. package/src/ui/components/patterns/action-form-card.tsx +8 -1
  15. package/src/ui/components/patterns/confirm.tsx +194 -157
  16. package/src/ui/components/patterns/content-header.tsx +17 -2
  17. package/src/ui/components/patterns/form-dialog.tsx +28 -14
  18. package/src/ui/components/patterns/form.tsx +340 -222
  19. package/src/ui/components/patterns/list.tsx +43 -44
  20. package/src/ui/components/patterns/page-heading-context.tsx +34 -0
  21. package/src/ui/components/patterns/page-state.tsx +2 -0
  22. package/src/ui/components/patterns/page.tsx +164 -50
  23. package/src/ui/components/patterns/presentation.tsx +140 -84
  24. package/src/ui/components/patterns/surface-header.tsx +5 -6
  25. package/src/ui/components/patterns/trigger.tsx +112 -82
  26. package/src/ui/components/primitives/button.tsx +2 -2
  27. package/src/ui/components/primitives/chat.tsx +19 -5
  28. package/src/ui/components/primitives/control.ts +9 -3
  29. package/src/ui/components/primitives/dialog.tsx +16 -9
  30. package/src/ui/components/primitives/drawer.tsx +9 -6
  31. package/src/ui/docs/content/action-form-card.md +9 -8
  32. package/src/ui/docs/content/action-form-dialog.md +11 -12
  33. package/src/ui/docs/content/action-form.md +25 -25
  34. package/src/ui/docs/content/action-list.md +101 -70
  35. package/src/ui/docs/content/chat.md +4 -4
  36. package/src/ui/docs/content/content.md +29 -13
  37. package/src/ui/docs/content/dialog.md +27 -21
  38. package/src/ui/docs/content/drawer.md +8 -6
  39. package/src/ui/docs/content/page.md +43 -50
  40. package/src/ui/docs/content/presentation.md +39 -28
  41. package/src/ui/docs/doc-client.tsx +1 -1
  42. package/src/ui/meta.ts +4 -4
@@ -12,15 +12,19 @@
12
12
  * Emite `data-action="<action.name>"` na raiz (selector E2E).
13
13
  */
14
14
 
15
- import type { ReactNode } from 'react'
16
- import { useEffect, useId, useState } from 'react'
17
- import type { SimpleContract } from '../../../core/index.ts'
18
- import { useTriggerAction } from '../../drivers/react.tsx'
19
- import { toast } from '../primitives/sonner.tsx'
20
- import { cn } from '../../lib/cn.ts'
21
- import { humanizeActionError } from '../../lib/action-errors.ts'
22
- import { Button, type ButtonProps } from '../primitives/button.tsx'
23
- import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip.tsx'
15
+ import type { ReactNode } from "react";
16
+ import { useEffect, useId, useState } from "react";
17
+ import type { SimpleContract } from "../../../core/index.ts";
18
+ import { useTriggerAction } from "../../drivers/react.tsx";
19
+ import { toast } from "../primitives/sonner.tsx";
20
+ import { cn } from "../../lib/cn.ts";
21
+ import { humanizeActionError } from "../../lib/action-errors.ts";
22
+ import { Button, type ButtonProps } from "../primitives/button.tsx";
23
+ import {
24
+ Tooltip,
25
+ TooltipContent,
26
+ TooltipTrigger,
27
+ } from "../primitives/tooltip.tsx";
24
28
  import {
25
29
  Dialog,
26
30
  DialogBody,
@@ -29,52 +33,53 @@ import {
29
33
  DialogFooter,
30
34
  DialogHeader,
31
35
  DialogTitle,
32
- } from '../primitives/dialog.tsx'
36
+ } from "../primitives/dialog.tsx";
37
+ import { ButtonGroup } from "../primitives/button-group.tsx";
33
38
 
34
39
  /** action.messages.* pode ser string ou I18nRef ({ key, default }). Resolve pra texto. */
35
40
  function msgText(m: unknown, fallback: string): string {
36
- if (typeof m === 'string') return m
37
- if (m !== null && typeof m === 'object' && 'default' in m) {
38
- const d = (m as { default: unknown }).default
39
- if (typeof d === 'string') return d
41
+ if (typeof m === "string") return m;
42
+ if (m !== null && typeof m === "object" && "default" in m) {
43
+ const d = (m as { default: unknown }).default;
44
+ if (typeof d === "string") return d;
40
45
  }
41
- return fallback
46
+ return fallback;
42
47
  }
43
48
 
44
49
  export interface ActionTriggerProps<TInput, TData> {
45
- action: SimpleContract<TInput, TData>
50
+ action: SimpleContract<TInput, TData>;
46
51
  /** Input enviado pra action. Geralmente { id } ou similar. */
47
- input: TInput
52
+ input: TInput;
48
53
  /** Texto do botão. Default usa action.label. */
49
- label?: string
54
+ label?: string;
50
55
  /** Contexto visual. Por padrão, ações destrutivas usam `danger`. */
51
- context?: ButtonProps['context']
56
+ context?: ButtonProps["context"];
52
57
  /** Tratamento visual do Button. */
53
- variant?: ButtonProps['variant']
58
+ variant?: ButtonProps["variant"];
54
59
  /** Tamanho do botão (escala de control.ts). Com `icon`, o default é `icon-xs`. */
55
- size?: ButtonProps['size']
60
+ size?: ButtonProps["size"];
56
61
  /** Desabilita o botão independente de loading. */
57
- disabled?: boolean
62
+ disabled?: boolean;
58
63
  /** Texto do confirm dialog. Default vem do `action.confirm` (ConfirmSpec do
59
64
  * contrato — declarativo). Sem nenhum dos dois, dispara direto. */
60
65
  confirm?: {
61
- title: string
66
+ title: string;
62
67
  /** Conteúdo relevante apresentado antes das ações. */
63
- body?: ReactNode
64
- actionLabel?: string
65
- cancelLabel?: string
66
- }
68
+ body?: ReactNode;
69
+ actionLabel?: string;
70
+ cancelLabel?: string;
71
+ };
67
72
  /** Callback adicional pós-sucesso (cache já foi invalidado). */
68
- onSuccess?: (data: TData) => void
73
+ onSuccess?: (data: TData) => void;
69
74
  /** Notifica o renderer que coordena bloqueio entre várias actions. */
70
- onLoadingChange?: (loading: boolean) => void
75
+ onLoadingChange?: (loading: boolean) => void;
71
76
  /** Ícone: o botão vira icon-only, com o `label` no tooltip e no aria-label. É a forma
72
77
  * da ação que mora DENTRO de um item (linha, card) — o clique não vaza pro item. */
73
- icon?: ReactNode
78
+ icon?: ReactNode;
74
79
  /** Nome do item na pergunta (ex.: o slug, o nome da unidade) — sai entre aspas, em
75
80
  * destaque, antes da mensagem do contrato. */
76
- itemLabel?: string
77
- className?: string
81
+ itemLabel?: string;
82
+ className?: string;
78
83
  }
79
84
 
80
85
  export function ActionTrigger<TInput, TData>({
@@ -92,46 +97,52 @@ export function ActionTrigger<TInput, TData>({
92
97
  itemLabel,
93
98
  className,
94
99
  }: ActionTriggerProps<TInput, TData>) {
95
- const confirmBodyId = useId()
96
- const [open, setOpen] = useState(false)
100
+ const confirmBodyId = useId();
101
+ const [open, setOpen] = useState(false);
97
102
  const { trigger, isLoading } = useTriggerAction(action, {
98
103
  onSuccess: (data) => {
99
- toast.success(msgText(action.messages?.success, 'Concluído'))
100
- onSuccess?.(data)
101
- setOpen(false)
104
+ toast.success(msgText(action.messages?.success, "Concluído"));
105
+ onSuccess?.(data);
106
+ setOpen(false);
102
107
  },
103
108
  onError: (err) => {
104
109
  // A frase de negócio do servidor vence o rótulo do contrato (ver lib/action-errors.ts);
105
110
  // fora da allowlist, vale o rótulo — nunca o texto técnico cru.
106
111
  toast.error(
107
- humanizeActionError(err, msgText(action.messages?.error, 'Não foi possível concluir.')),
108
- )
112
+ humanizeActionError(
113
+ err,
114
+ msgText(action.messages?.error, "Não foi possível concluir."),
115
+ ),
116
+ );
109
117
  },
110
- })
118
+ });
111
119
  useEffect(() => {
112
- onLoadingChange?.(isLoading)
113
- return () => onLoadingChange?.(false)
114
- }, [isLoading, onLoadingChange])
120
+ onLoadingChange?.(isLoading);
121
+ return () => onLoadingChange?.(false);
122
+ }, [isLoading, onLoadingChange]);
115
123
 
116
- const buttonLabel = label ?? (typeof action.label === 'string' ? action.label : action.name)
124
+ const buttonLabel =
125
+ label ?? (typeof action.label === "string" ? action.label : action.name);
117
126
 
118
127
  // Prop sobrepõe; sem prop, o ConfirmSpec DECLARADO no contrato (action.confirm) vale.
119
- const spec = action.confirm
128
+ const spec = action.confirm;
120
129
  const confirm =
121
130
  confirmProp ??
122
131
  (spec !== undefined
123
132
  ? {
124
- title: msgText(spec.title, 'Confirmar?'),
125
- ...(spec.message !== undefined ? { body: msgText(spec.message, '') } : {}),
133
+ title: msgText(spec.title, "Confirmar?"),
134
+ ...(spec.message !== undefined
135
+ ? { body: msgText(spec.message, "") }
136
+ : {}),
126
137
  ...(spec.confirmLabel !== undefined
127
138
  ? { actionLabel: msgText(spec.confirmLabel, buttonLabel) }
128
139
  : {}),
129
140
  ...(spec.cancelLabel !== undefined
130
- ? { cancelLabel: msgText(spec.cancelLabel, 'Cancelar') }
141
+ ? { cancelLabel: msgText(spec.cancelLabel, "Cancelar") }
131
142
  : {}),
132
143
  }
133
- : undefined)
134
- const destructive = spec?.destructive === true
144
+ : undefined);
145
+ const destructive = spec?.destructive === true;
135
146
  /**
136
147
  * Precedência de aparência:
137
148
  * - Gatilho: `context` explícito vence `destructive`; sem ele, destrutivo é `danger`. Com
@@ -142,14 +153,15 @@ export function ActionTrigger<TInput, TData>({
142
153
  * decisão.
143
154
  */
144
155
  const triggerContext =
145
- context ?? (destructive ? 'danger' : icon !== undefined ? 'neutral' : 'primary')
146
- const triggerVariant = variant ?? (icon !== undefined ? 'ghost' : 'solid')
147
- const confirmContext = destructive ? 'danger' : (context ?? 'primary')
148
- const confirmVariant = 'solid'
156
+ context ??
157
+ (destructive ? "danger" : icon !== undefined ? "neutral" : "primary");
158
+ const triggerVariant = variant ?? (icon !== undefined ? "ghost" : "solid");
159
+ const confirmContext = destructive ? "danger" : (context ?? "primary");
160
+ const confirmVariant = "solid";
149
161
 
150
162
  const fire = () => {
151
- void trigger(input)
152
- }
163
+ void trigger(input);
164
+ };
153
165
 
154
166
  /** O gatilho. Com `icon`: icon-only, rótulo no tooltip e no aria-label. O
155
167
  * stopPropagation é o que permite viver DENTRO de uma linha clicável — sem ele,
@@ -159,30 +171,33 @@ export function ActionTrigger<TInput, TData>({
159
171
  <Button
160
172
  context={triggerContext}
161
173
  variant={triggerVariant}
162
- size={size ?? (icon !== undefined ? 'icon-xs' : 'default')}
174
+ size={size ?? (icon !== undefined ? "icon-xs" : "default")}
163
175
  busy={isLoading}
164
176
  disabled={disabled}
165
177
  aria-label={icon !== undefined ? buttonLabel : undefined}
166
178
  onClick={(e) => {
167
- e.stopPropagation()
168
- onClick()
179
+ e.stopPropagation();
180
+ onClick();
169
181
  }}
170
- className={cn(icon !== undefined && 'shrink-0 text-muted-foreground/60', className)}
182
+ className={cn(
183
+ icon !== undefined && "shrink-0 text-muted-foreground/60",
184
+ className,
185
+ )}
171
186
  data-action={action.name}
172
187
  >
173
188
  {icon ?? buttonLabel}
174
189
  </Button>
175
- )
176
- if (icon === undefined) return button
190
+ );
191
+ if (icon === undefined) return button;
177
192
  return (
178
193
  <Tooltip>
179
194
  <TooltipTrigger asChild>{button}</TooltipTrigger>
180
195
  <TooltipContent>{buttonLabel}</TooltipContent>
181
196
  </Tooltip>
182
- )
183
- }
197
+ );
198
+ };
184
199
 
185
- if (confirm === undefined) return renderButton(fire)
200
+ if (confirm === undefined) return renderButton(fire);
186
201
 
187
202
  return (
188
203
  <>
@@ -192,38 +207,53 @@ export function ActionTrigger<TInput, TData>({
192
207
  <DialogContent
193
208
  data-action={action.name}
194
209
  aria-describedby={
195
- itemLabel !== undefined || confirm.body !== undefined ? confirmBodyId : undefined
210
+ itemLabel !== undefined || confirm.body !== undefined
211
+ ? confirmBodyId
212
+ : undefined
196
213
  }
197
214
  >
198
215
  <DialogHeader>
199
216
  <DialogTitle>{confirm.title}</DialogTitle>
200
217
  </DialogHeader>
201
218
  {(itemLabel !== undefined || confirm.body !== undefined) && (
202
- <DialogBody id={confirmBodyId} className="text-sm text-muted-foreground">
219
+ <DialogBody
220
+ id={confirmBodyId}
221
+ className="text-sm text-muted-foreground"
222
+ >
203
223
  {itemLabel !== undefined && (
204
- <span className="font-medium text-foreground">“{itemLabel}”</span>
224
+ <span className="font-medium text-foreground">
225
+ “{itemLabel}”
226
+ </span>
205
227
  )}
206
- {itemLabel !== undefined && confirm.body !== undefined ? ' — ' : ''}
228
+ {itemLabel !== undefined && confirm.body !== undefined
229
+ ? " — "
230
+ : ""}
207
231
  {confirm.body}
208
232
  </DialogBody>
209
233
  )}
210
234
  <DialogFooter>
211
- <DialogClose initialFocus asChild>
212
- <Button context="neutral" variant="ghost" disabled={isLoading}>
213
- {confirm.cancelLabel ?? 'Cancelar'}
235
+ <ButtonGroup mode="spaced" distribution="equal">
236
+ <DialogClose initialFocus asChild>
237
+ <Button
238
+ context="neutral"
239
+ variant="outline"
240
+ disabled={isLoading}
241
+ >
242
+ {confirm.cancelLabel ?? "Cancelar"}
243
+ </Button>
244
+ </DialogClose>
245
+ <Button
246
+ context={confirmContext}
247
+ variant={confirmVariant}
248
+ busy={isLoading}
249
+ onClick={fire}
250
+ >
251
+ {confirm.actionLabel ?? buttonLabel}
214
252
  </Button>
215
- </DialogClose>
216
- <Button
217
- context={confirmContext}
218
- variant={confirmVariant}
219
- busy={isLoading}
220
- onClick={fire}
221
- >
222
- {confirm.actionLabel ?? buttonLabel}
223
- </Button>
253
+ </ButtonGroup>
224
254
  </DialogFooter>
225
255
  </DialogContent>
226
256
  </Dialog>
227
257
  </>
228
- )
258
+ );
229
259
  }
@@ -4,8 +4,8 @@ import { Slot } from '@radix-ui/react-slot'
4
4
  import type { UiContext } from '../../../core/ui-context.ts'
5
5
  import { cn } from '../../lib/cn.ts'
6
6
  import {
7
- controlBase,
8
7
  controlGlyphClass,
8
+ controlGlyphSize,
9
9
  controlHeight,
10
10
  controlSquare,
11
11
  focusRing,
@@ -149,7 +149,7 @@ export function Button({
149
149
  children
150
150
  ) : (
151
151
  <>
152
- {busy ? <Spinner size={controlBase(size)} /> : icon}
152
+ {busy ? <Spinner size={controlGlyphSize(size)} /> : icon}
153
153
  {children}
154
154
  </>
155
155
  )}
@@ -151,6 +151,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
151
151
  const scrollRef = React.useRef<HTMLDivElement>(null)
152
152
  const turns = React.useMemo(() => groupTurns(items), [items])
153
153
  const indicatorVisible = indicator !== undefined
154
+ const compactIndicator = indicator === ''
154
155
  // `null`/`false` em `empty` significam "sem nó" (o dado ainda não chegou): o greeting
155
156
  // continua como fallback em vez de um wrapper composto vazio.
156
157
  const composedEmpty = empty !== undefined && empty !== null && empty !== false
@@ -162,7 +163,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
162
163
  <div
163
164
  ref={scrollRef}
164
165
  data-slot="chat-scroll"
165
- className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-4"
166
+ className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-4"
166
167
  >
167
168
  {items.length === 0 && !indicatorVisible && composedEmpty ? (
168
169
  <div data-slot="chat-empty" data-variant="composed" className="m-auto flex justify-center text-center">
@@ -189,9 +190,17 @@ const ChatTranscript = React.memo(function ChatTranscript({
189
190
  data-slot="chat-turn-user"
190
191
  data-message-id={turn.user.id}
191
192
  data-message-created-at={turn.user.createdAt}
192
- className="flex justify-end py-1.5"
193
+ className="group/chat-message mb-1 flex flex-col items-end justify-end py-1"
193
194
  >
194
195
  <UserTurn content={turn.user.content} />
196
+ {renderMessageActions !== undefined && (
197
+ <div
198
+ data-slot="chat-message-actions"
199
+ className="mt-0.5 flex min-h-5 items-center opacity-0 transition-opacity group-hover/chat-message:opacity-100 focus-within:opacity-100"
200
+ >
201
+ {renderMessageActions(turn.user)}
202
+ </div>
203
+ )}
195
204
  </div>
196
205
  )}
197
206
  {turn.rest.map((item, i) => {
@@ -238,7 +247,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
238
247
  {renderMessageActions !== undefined && (
239
248
  <div
240
249
  data-slot="chat-message-actions"
241
- className="mt-1 flex min-h-5 items-center opacity-0 transition-opacity group-hover/chat-message:opacity-100 focus-within:opacity-100"
250
+ className="mt-0.5 flex min-h-5 items-center opacity-0 transition-opacity group-hover/chat-message:opacity-100 focus-within:opacity-100"
242
251
  >
243
252
  {renderMessageActions(item)}
244
253
  </div>
@@ -251,7 +260,12 @@ const ChatTranscript = React.memo(function ChatTranscript({
251
260
  {indicatorVisible && (
252
261
  <div
253
262
  data-slot="chat-activity"
254
- className="flex items-center gap-2 py-1 text-xs text-muted-foreground"
263
+ role="status"
264
+ aria-label={compactIndicator ? 'Pensando…' : undefined}
265
+ className={cn(
266
+ 'flex items-center gap-2 py-1 text-xs text-muted-foreground',
267
+ compactIndicator && 'justify-center',
268
+ )}
255
269
  >
256
270
  <span className="flex gap-1">
257
271
  {[0, 1, 2].map((i) => (
@@ -262,7 +276,7 @@ const ChatTranscript = React.memo(function ChatTranscript({
262
276
  />
263
277
  ))}
264
278
  </span>
265
- <span>{indicator ?? 'Pensando…'}</span>
279
+ {!compactIndicator && <span>{indicator ?? 'Pensando…'}</span>}
266
280
  </div>
267
281
  )}
268
282
  </div>
@@ -10,8 +10,9 @@ export type ControlShape = 'default' | 'pill'
10
10
  * - Quadrado só-ícone (`icon-xs` · `icon-sm` · `icon` · `icon-lg`): 1.5 · 1.75 · 2.25 · 2.5rem.
11
11
  * `icon-xs` é o degrau da ação que mora dentro de uma linha densa. `icon-sm` atende
12
12
  * composições compactas que ainda precisam de mais presença.
13
- * - Glifo (o svg dentro do controle daquele tamanho): 0.875 · 0.875 · 1 · 1.25rem. O Spinner
14
- * usa esta medida, por isso `busy` substitui o ícone sem mexer na largura do botão.
13
+ * - Glifo (o svg dentro do controle daquele tamanho): 0.875 · 0.875 · 1 · 1.25rem. O quadrado
14
+ * `icon` usa o glifo mais discreto de 0.875rem sem reduzir sua área clicável de 2.25rem. O
15
+ * Spinner usa a mesma medida, por isso `busy` substitui o ícone sem mexer na largura do botão.
15
16
  */
16
17
  export type ControlSize = 'xs' | 'sm' | 'default' | 'lg'
17
18
  export type ControlIconSize = 'icon-xs' | 'icon-sm' | 'icon' | 'icon-lg'
@@ -65,9 +66,14 @@ export function controlBase(size: ControlScale | null | undefined): ControlSize
65
66
  }
66
67
  }
67
68
 
69
+ /** O degrau visual do glifo; `icon` preserva a caixa default com um desenho mais discreto. */
70
+ export function controlGlyphSize(size: ControlScale | null | undefined): ControlSize {
71
+ return size === 'icon' ? 'sm' : controlBase(size)
72
+ }
73
+
68
74
  /** Fallback de tamanho do svg descendente, respeitando um `size-*` explícito no próprio ícone. */
69
75
  export function controlGlyphClass(size: ControlScale | null | undefined): string {
70
- return controlGlyphSelector[controlBase(size)]
76
+ return controlGlyphSelector[controlGlyphSize(size)]
71
77
  }
72
78
 
73
79
  /**
@@ -156,7 +156,7 @@ function DialogCloseButton({ disabled = false }: { disabled?: boolean }) {
156
156
  <DialogPrimitive.Close data-slot="dialog-close" asChild>
157
157
  <Button
158
158
  type="button"
159
- size="icon-sm"
159
+ size="icon"
160
160
  variant="ghost"
161
161
  disabled={disabled}
162
162
  aria-label="Fechar"
@@ -196,7 +196,9 @@ export function DialogContent({
196
196
  aria-describedby={ariaDescribedBy}
197
197
  {...props}
198
198
  >
199
- <DialogContentContext.Provider value={{ showCloseButton: false, closeDisabled: true }}>
199
+ <DialogContentContext.Provider
200
+ value={{ showCloseButton: false, closeDisabled: true }}
201
+ >
200
202
  {children}
201
203
  </DialogContentContext.Provider>
202
204
  </AlertDialogPrimitive.Content>
@@ -218,7 +220,9 @@ export function DialogContent({
218
220
  aria-describedby={ariaDescribedBy}
219
221
  {...props}
220
222
  >
221
- <DialogContentContext.Provider value={{ showCloseButton: showClose, closeDisabled }}>
223
+ <DialogContentContext.Provider
224
+ value={{ showCloseButton: showClose, closeDisabled }}
225
+ >
222
226
  {children}
223
227
  </DialogContentContext.Provider>
224
228
  </DialogPrimitive.Content>
@@ -232,20 +236,23 @@ export function DialogHeader({
232
236
  ...props
233
237
  }: React.ComponentProps<"div">) {
234
238
  const { mode } = useDialogContext();
235
- const { showCloseButton, closeDisabled } = React.useContext(DialogContentContext);
239
+ const { showCloseButton, closeDisabled } =
240
+ React.useContext(DialogContentContext);
236
241
  return (
237
242
  <div
238
243
  data-slot="dialog-header"
239
244
  className={cn(
240
245
  mode === "alert"
241
246
  ? "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=dialog-media]:grid-rows-[auto_auto_1fr]"
242
- : "flex shrink-0 items-center gap-4 border-b p-5 text-left [&:has(+[data-slot=dialog-footer])]:border-b-0",
247
+ : "flex shrink-0 items-center gap-4 border-b p-4 text-left [&:has(+[data-slot=dialog-footer])]:border-b-0",
243
248
  className,
244
249
  )}
245
250
  {...props}
246
251
  >
247
252
  {children}
248
- {mode !== "alert" && showCloseButton && <DialogCloseButton disabled={closeDisabled} />}
253
+ {mode !== "alert" && showCloseButton && (
254
+ <DialogCloseButton disabled={closeDisabled} />
255
+ )}
249
256
  </div>
250
257
  );
251
258
  }
@@ -275,7 +282,7 @@ export function DialogBody({
275
282
  <div
276
283
  data-slot="dialog-body"
277
284
  className={cn(
278
- mode === "alert" ? "min-w-0" : "min-h-0 flex-1 overflow-y-auto p-5",
285
+ mode === "alert" ? "min-w-0" : "min-h-0 flex-1 overflow-y-auto p-4",
279
286
  className,
280
287
  )}
281
288
  {...props}
@@ -293,8 +300,8 @@ export function DialogFooter({
293
300
  data-slot="dialog-footer"
294
301
  className={cn(
295
302
  mode === "alert"
296
- ? "grid grid-cols-2 gap-2 [&>*:only-child]:col-span-full"
297
- : "flex shrink-0 flex-col-reverse gap-2 border-t bg-muted/30 px-5 py-4 sm:flex-row sm:justify-end",
303
+ ? "w-full"
304
+ : "flex shrink-0 flex-col-reverse gap-2 border-t p-4 sm:flex-row sm:justify-end",
298
305
  className,
299
306
  )}
300
307
  {...props}
@@ -58,7 +58,7 @@ function DrawerCloseButton({ disabled = false }: { disabled?: boolean }) {
58
58
  <DrawerPrimitive.Close data-slot="drawer-close" asChild>
59
59
  <Button
60
60
  type="button"
61
- size="icon-sm"
61
+ size="icon"
62
62
  variant="ghost"
63
63
  disabled={disabled}
64
64
  aria-label="Fechar"
@@ -102,7 +102,9 @@ function DrawerContent({
102
102
  aria-describedby={ariaDescribedBy}
103
103
  {...props}
104
104
  >
105
- <DrawerContentContext.Provider value={{ showCloseButton, closeDisabled }}>
105
+ <DrawerContentContext.Provider
106
+ value={{ showCloseButton, closeDisabled }}
107
+ >
106
108
  {children}
107
109
  </DrawerContentContext.Provider>
108
110
  </DrawerPrimitive.Content>
@@ -115,11 +117,12 @@ function DrawerHeader({
115
117
  children,
116
118
  ...props
117
119
  }: React.ComponentProps<"div">) {
118
- const { showCloseButton, closeDisabled } = React.useContext(DrawerContentContext);
120
+ const { showCloseButton, closeDisabled } =
121
+ React.useContext(DrawerContentContext);
119
122
  return (
120
123
  <div
121
124
  data-slot="drawer-header"
122
- className={cn("flex shrink-0 items-center gap-4 border-b p-5", className)}
125
+ className={cn("flex shrink-0 items-center gap-4 border-b p-4", className)}
123
126
  {...props}
124
127
  >
125
128
  {children}
@@ -132,7 +135,7 @@ function DrawerBody({ className, ...props }: React.ComponentProps<"div">) {
132
135
  return (
133
136
  <div
134
137
  data-slot="drawer-body"
135
- className={cn("min-h-0 flex-1 overflow-y-auto p-5", className)}
138
+ className={cn("min-h-0 flex-1 overflow-y-auto p-4", className)}
136
139
  {...props}
137
140
  />
138
141
  );
@@ -143,7 +146,7 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
143
146
  <div
144
147
  data-slot="drawer-footer"
145
148
  className={cn(
146
- "mt-auto flex shrink-0 flex-col-reverse gap-2 border-t bg-muted/30 px-5 py-4 sm:flex-row sm:justify-end",
149
+ "mt-auto flex shrink-0 flex-col-reverse gap-2 border-t p-4 sm:flex-row sm:justify-end",
147
150
  className,
148
151
  )}
149
152
  {...props}
@@ -2,23 +2,24 @@
2
2
 
3
3
  Use `ActionFormCard` para apresentar um `ActionForm` como seção delimitada da página. O componente
4
4
  aplica a estrutura e o espaçamento de `Card`, sem a faixa de ações de um modal. O título é opcional.
5
+ Quando `onCancel` acrescenta uma segunda ação, o componente organiza os botões em um
6
+ `ButtonGroup` espaçado sem forçar a divisão 50/50 do modal.
5
7
 
6
8
  ```tsx preview col md
7
9
  <DocBrowserActionProvider>
8
10
  <ActionFormCard
9
11
  title="Editar workspace"
10
12
  action={docWorkspaceCreate}
11
- defaultValues={{ name: 'Empresa X', status: 'active' }}
12
- submitLabel="Salvar alterações"
13
+ defaultValues={{ name: "Empresa X", status: "active" }}
13
14
  />
14
15
  </DocBrowserActionProvider>
15
16
  ```
16
17
 
17
18
  ## Propriedades de ActionFormCard
18
19
 
19
- | Propriedade | Tipo | Padrão | Descrição |
20
- |---|---|---|---|
21
- | `title` | `string` | | Título do cabeçalho. Sem ele, o card começa diretamente pelo corpo. |
22
- | `description` | `string` | | Subtítulo opcional, abaixo do título. |
23
- | `action / defaultValues / fieldOptions / submitLabel / onSuccess / onCancel` | Propriedades de `ActionForm` | | Mantêm o mesmo comportamento do formulário interno. |
24
- | `cardClassName` | `string` | | Classes aplicadas à superfície do card. `className` continua sendo aplicado ao `<form>`. |
20
+ | Propriedade | Tipo | Padrão | Descrição |
21
+ | ---------------------------------------------------------------------------- | ---------------------------- | ------ | ---------------------------------------------------------------------------------------- |
22
+ | `title` | `string` | | Título do cabeçalho. Sem ele, o card começa diretamente pelo corpo. |
23
+ | `description` | `string` | | Subtítulo opcional, abaixo do título. |
24
+ | `action / defaultValues / fieldOptions / submitLabel / onSuccess / onCancel` | Propriedades de `ActionForm` | | Mantêm o mesmo comportamento do formulário interno. |
25
+ | `cardClassName` | `string` | | Classes aplicadas à superfície do card. `className` continua sendo aplicado ao `<form>`. |
@@ -3,21 +3,20 @@
3
3
  Use `ActionFormDialog` quando o formulário precisar interromper o fluxo atual sem levar a pessoa
4
4
  para outra página. O consumidor controla `open`; depois de uma execução bem-sucedida, o componente
5
5
  fecha o modal. O cabeçalho e o rodapé permanecem visíveis enquanto os campos podem rolar.
6
+ Quando há cancelamento, o rodapé usa um `ButtonGroup` dividido: `Cancelar` aparece em `outline` e
7
+ a ação principal permanece `solid`, ambas no tamanho normal de uma decisão modal.
6
8
 
7
9
  ```tsx preview
8
10
  const [open, setOpen] = useState(false);
9
11
 
10
12
  render(
11
13
  <DocBrowserActionProvider>
12
- <Button onClick={() => setOpen(true)}>
13
- <Plus /> Novo workspace
14
- </Button>
14
+ <Button onClick={() => setOpen(true)}>Criar workspace</Button>
15
15
  <ActionFormDialog
16
16
  open={open}
17
17
  onOpenChange={setOpen}
18
- title="Novo workspace"
18
+ title="Criar workspace"
19
19
  action={docWorkspaceCreate}
20
- submitLabel="Criar workspace"
21
20
  />
22
21
  </DocBrowserActionProvider>,
23
22
  );
@@ -25,10 +24,10 @@ render(
25
24
 
26
25
  ## Propriedades de ActionFormDialog
27
26
 
28
- | Propriedade | Tipo | Padrão | Descrição |
29
- | --------------------- | ------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------- |
30
- | `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e ao cancelar. |
31
- | `title` | `string` | | Nome da tarefa exibido no cabeçalho. |
32
- | `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
33
- | `submitLabel` | `string` | `'Salvar'` | Resultado da ação principal. Em criação, informe `Criar {recurso}`; em edição, `Salvar alterações`. |
34
- | `…ActionFormProps` | `action, defaultValues, onSuccess, fieldOptions…` | | Demais propriedades repassadas ao `ActionForm` interno. |
27
+ | Propriedade | Tipo | Padrão | Descrição |
28
+ | --------------------- | ------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
+ | `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e ao cancelar. |
30
+ | `title` | `string` | | Nome da tarefa exibido no cabeçalho. |
31
+ | `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
32
+ | `submitLabel` | `string` | `'Salvar'` | Resultado da ação principal. Preserve o padrão em criação e edição comuns; sobrescreva apenas quando a operação tiver outro efeito, como `Renomear` ou `Criar nova versão`. |
33
+ | `…ActionFormProps` | `action, defaultValues, onSuccess, fieldOptions…` | | Demais propriedades repassadas ao `ActionForm` interno. |