@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
@@ -19,8 +19,8 @@
19
19
  * de cada tipo (void, `false`, `null`).
20
20
  */
21
21
 
22
- import * as React from 'react'
23
- import type { UiContext } from '../../../core/ui-context.ts'
22
+ import * as React from "react";
23
+ import type { UiContext } from "../../../core/ui-context.ts";
24
24
  import {
25
25
  Dialog,
26
26
  DialogBody,
@@ -30,19 +30,24 @@ import {
30
30
  DialogHeader,
31
31
  DialogMedia,
32
32
  DialogTitle,
33
- } from '../primitives/dialog.tsx'
34
- import { Button, type ButtonContext, type ButtonVariant } from '../primitives/button.tsx'
35
- import { Input } from '../primitives/input.tsx'
33
+ } from "../primitives/dialog.tsx";
34
+ import {
35
+ Button,
36
+ type ButtonContext,
37
+ type ButtonVariant,
38
+ } from "../primitives/button.tsx";
39
+ import { Input } from "../primitives/input.tsx";
40
+ import { ButtonGroup } from "../primitives/button-group.tsx";
36
41
 
37
42
  /** Campos comuns aos quatro modos imperativos. */
38
43
  interface DialogBase {
39
- title: React.ReactNode
44
+ title: React.ReactNode;
40
45
  /** Ícone grande acima do título (o quadro do DialogMedia). */
41
- media?: React.ReactNode
46
+ media?: React.ReactNode;
42
47
  /** Conteúdo próprio entre a descrição e os botões (lista, detalhe destacado). */
43
- body?: React.ReactNode
48
+ body?: React.ReactNode;
44
49
  /** Rótulo do botão que confirma. */
45
- action?: string
50
+ action?: string;
46
51
  }
47
52
 
48
53
  /** `alert` — reconhecimento obrigatório, um botão só. */
@@ -50,276 +55,308 @@ export interface AlertOptions extends DialogBase {}
50
55
 
51
56
  /** `confirm` — pergunta de sim/não. */
52
57
  export interface ConfirmOptions extends DialogBase {
53
- cancel?: string
58
+ cancel?: string;
54
59
  /** Contexto do botão de confirmação. */
55
- context?: Extract<UiContext, 'primary' | 'danger'>
60
+ context?: Extract<UiContext, "primary" | "danger">;
56
61
  }
57
62
 
58
63
  /** `prompt` — uma string livre. Form de verdade (campos, contrato) → ActionFormDialog. */
59
64
  export interface PromptOptions extends DialogBase {
60
- cancel?: string
61
- context?: Extract<UiContext, 'primary' | 'danger'>
62
- placeholder?: string
63
- defaultValue?: string
65
+ cancel?: string;
66
+ context?: Extract<UiContext, "primary" | "danger">;
67
+ placeholder?: string;
68
+ defaultValue?: string;
64
69
  }
65
70
 
66
71
  export interface DialogChoice<TResult extends string = string> {
67
72
  /** Valor devolvido quando esta ação for escolhida. Deve ser único na coleção. */
68
- result: TResult
69
- label: React.ReactNode
70
- context?: ButtonContext
71
- variant?: ButtonVariant
72
- initialFocus?: boolean
73
- disabled?: boolean
73
+ result: TResult;
74
+ label: React.ReactNode;
75
+ context?: ButtonContext;
76
+ variant?: ButtonVariant;
77
+ initialFocus?: boolean;
78
+ disabled?: boolean;
74
79
  }
75
80
 
76
81
  /** `choose` — uma escolha simples entre resultados explícitos. */
77
- export interface ChooseOptions<TResult extends string = string> extends Omit<DialogBase, 'action'> {
78
- actions: readonly DialogChoice<TResult>[]
82
+ export interface ChooseOptions<TResult extends string = string> extends Omit<
83
+ DialogBase,
84
+ "action"
85
+ > {
86
+ actions: readonly DialogChoice<TResult>[];
79
87
  }
80
88
 
81
89
  type Pending =
82
- | ({ kind: 'alert'; id: number; resolve: () => void } & AlertOptions)
90
+ | ({ kind: "alert"; id: number; resolve: () => void } & AlertOptions)
83
91
  | ({
84
- kind: 'confirm'
85
- id: number
86
- resolve: (ok: boolean) => void
92
+ kind: "confirm";
93
+ id: number;
94
+ resolve: (ok: boolean) => void;
87
95
  } & ConfirmOptions)
88
96
  | ({
89
- kind: 'prompt'
90
- id: number
91
- resolve: (value: string | null) => void
97
+ kind: "prompt";
98
+ id: number;
99
+ resolve: (value: string | null) => void;
92
100
  } & PromptOptions)
93
101
  | ({
94
- kind: 'choose'
95
- id: number
96
- resolve: (result: string | null) => void
97
- } & ChooseOptions)
102
+ kind: "choose";
103
+ id: number;
104
+ resolve: (result: string | null) => void;
105
+ } & ChooseOptions);
98
106
 
99
- let nextId = 0
100
- let listener: ((p: Pending) => void) | null = null
107
+ let nextId = 0;
108
+ let listener: ((p: Pending) => void) | null = null;
101
109
 
102
110
  /** Sem `<DialogHost />` montado, LANÇA em vez de deixar a promise pendurada (clique sem
103
111
  * efeito é o pior desfecho de uma interrupção que exige resposta). */
104
112
  function requireHost(fn: string): Error | null {
105
- if (listener !== null) return null
113
+ if (listener !== null) return null;
106
114
  return new Error(
107
115
  `${fn} foi chamado sem <DialogHost /> montado — ponha o host no shell do app (ao lado do <Toaster />).`,
108
- )
116
+ );
109
117
  }
110
118
 
111
- function validateChoose<TResult extends string>(options: ChooseOptions<TResult>): Error | null {
119
+ function validateChoose<TResult extends string>(
120
+ options: ChooseOptions<TResult>,
121
+ ): Error | null {
112
122
  if (options.actions.length === 0) {
113
- return new Error('dialog.choose() exige pelo menos uma ação.')
123
+ return new Error("dialog.choose() exige pelo menos uma ação.");
114
124
  }
115
- const focused = options.actions.filter((action) => action.initialFocus === true)
125
+ const focused = options.actions.filter(
126
+ (action) => action.initialFocus === true,
127
+ );
116
128
  if (focused.length !== 1) {
117
- return new Error('dialog.choose() exige uma única ação com initialFocus.')
129
+ return new Error("dialog.choose() exige uma única ação com initialFocus.");
118
130
  }
119
131
  if (focused[0]?.disabled === true) {
120
- return new Error('dialog.choose() não permite initialFocus em uma ação desabilitada.')
132
+ return new Error(
133
+ "dialog.choose() não permite initialFocus em uma ação desabilitada.",
134
+ );
121
135
  }
122
- if (new Set(options.actions.map((action) => action.result)).size !== options.actions.length) {
123
- return new Error('dialog.choose() exige resultados únicos.')
136
+ if (
137
+ new Set(options.actions.map((action) => action.result)).size !==
138
+ options.actions.length
139
+ ) {
140
+ return new Error("dialog.choose() exige resultados únicos.");
124
141
  }
125
- return null
142
+ return null;
126
143
  }
127
144
 
128
145
  export const dialog = {
129
146
  /** Avisa e espera o "OK". `Promise<void>` — só reconhecimento, sem decisão. */
130
147
  alert(options: AlertOptions): Promise<void> {
131
- const err = requireHost('dialog.alert()')
132
- if (err !== null) return Promise.reject(err)
148
+ const err = requireHost("dialog.alert()");
149
+ if (err !== null) return Promise.reject(err);
133
150
  return new Promise<void>((resolve) => {
134
- listener?.({ ...options, kind: 'alert', id: nextId++, resolve })
135
- })
151
+ listener?.({ ...options, kind: "alert", id: nextId++, resolve });
152
+ });
136
153
  },
137
154
  /** Pergunta sim/não. `Promise<boolean>` — fechar/ESC resolve `false`. */
138
155
  confirm(options: ConfirmOptions): Promise<boolean> {
139
- const err = requireHost('dialog.confirm()')
140
- if (err !== null) return Promise.reject(err)
156
+ const err = requireHost("dialog.confirm()");
157
+ if (err !== null) return Promise.reject(err);
141
158
  return new Promise<boolean>((resolve) => {
142
- listener?.({ ...options, kind: 'confirm', id: nextId++, resolve })
143
- })
159
+ listener?.({ ...options, kind: "confirm", id: nextId++, resolve });
160
+ });
144
161
  },
145
162
  /** Pede uma string. `Promise<string | null>` — fechar/ESC/Cancelar resolve `null`. */
146
163
  prompt(options: PromptOptions): Promise<string | null> {
147
- const err = requireHost('dialog.prompt()')
148
- if (err !== null) return Promise.reject(err)
164
+ const err = requireHost("dialog.prompt()");
165
+ if (err !== null) return Promise.reject(err);
149
166
  return new Promise<string | null>((resolve) => {
150
- listener?.({ ...options, kind: 'prompt', id: nextId++, resolve })
151
- })
167
+ listener?.({ ...options, kind: "prompt", id: nextId++, resolve });
168
+ });
152
169
  },
153
170
  /** Oferece resultados explícitos. Fechar/ESC sem escolher resolve `null`. */
154
- choose<const TResult extends string>(options: ChooseOptions<TResult>): Promise<TResult | null> {
155
- const invalid = validateChoose(options)
156
- if (invalid !== null) return Promise.reject(invalid)
157
- const err = requireHost('dialog.choose()')
158
- if (err !== null) return Promise.reject(err)
171
+ choose<const TResult extends string>(
172
+ options: ChooseOptions<TResult>,
173
+ ): Promise<TResult | null> {
174
+ const invalid = validateChoose(options);
175
+ if (invalid !== null) return Promise.reject(invalid);
176
+ const err = requireHost("dialog.choose()");
177
+ if (err !== null) return Promise.reject(err);
159
178
  return new Promise<TResult | null>((resolve) => {
160
179
  listener?.({
161
180
  ...options,
162
- kind: 'choose',
181
+ kind: "choose",
163
182
  id: nextId++,
164
183
  resolve: (result) => resolve(result as TResult | null),
165
- })
166
- })
184
+ });
185
+ });
167
186
  },
168
- }
187
+ };
169
188
 
170
189
  /**
171
190
  * O host — um por app, no shell. Enfileira: uma por vez (duas caixas empilhadas seriam
172
191
  * ambíguas sobre qual clique respondeu o quê).
173
192
  */
174
193
  export function DialogHost(): React.ReactElement {
175
- const [queue, setQueue] = React.useState<Pending[]>([])
176
- const current = queue[0]
177
- const [text, setText] = React.useState('')
194
+ const [queue, setQueue] = React.useState<Pending[]>([]);
195
+ const current = queue[0];
196
+ const [text, setText] = React.useState("");
178
197
 
179
198
  React.useEffect(() => {
180
- listener = (p) => setQueue((q) => [...q, p])
199
+ listener = (p) => setQueue((q) => [...q, p]);
181
200
  return () => {
182
- listener = null
183
- }
184
- }, [])
201
+ listener = null;
202
+ };
203
+ }, []);
185
204
 
186
205
  // Reseta o input quando a pergunta corrente troca (defaultValue do prompt, ou vazio).
187
206
  React.useEffect(() => {
188
- setText(current !== undefined && current.kind === 'prompt' ? (current.defaultValue ?? '') : '')
189
- }, [current?.id]) // eslint-disable-line react-hooks/exhaustive-deps
207
+ setText(
208
+ current !== undefined && current.kind === "prompt"
209
+ ? (current.defaultValue ?? "")
210
+ : "",
211
+ );
212
+ }, [current?.id]); // eslint-disable-line react-hooks/exhaustive-deps
190
213
 
191
214
  // Resolve a pergunta `id` — e SÓ ela. A 2ª chamada pro mesmo id (clique + o fechamento
192
215
  // que o Radix dispara em seguida) é inócua: a promise já resolveu e a fila já andou.
193
216
  const drop = (id: number, apply: (p: Pending) => void): void => {
194
217
  setQueue((q) => {
195
- if (q[0]?.id !== id) return q
196
- apply(q[0])
197
- return q.slice(1)
198
- })
199
- }
218
+ if (q[0]?.id !== id) return q;
219
+ apply(q[0]);
220
+ return q.slice(1);
221
+ });
222
+ };
200
223
  // Fechar É responder "não" — o "não" de cada tipo.
201
224
  const cancel = (p: Pending): void => {
202
- if (p.kind === 'alert') p.resolve()
203
- else if (p.kind === 'confirm') p.resolve(false)
204
- else if (p.kind === 'prompt') p.resolve(null)
205
- else p.resolve(null)
206
- }
225
+ if (p.kind === "alert") p.resolve();
226
+ else if (p.kind === "confirm") p.resolve(false);
227
+ else if (p.kind === "prompt") p.resolve(null);
228
+ else p.resolve(null);
229
+ };
207
230
  const accept = (p: Pending, value: string): void => {
208
- if (p.kind === 'alert') p.resolve()
209
- else if (p.kind === 'confirm') p.resolve(true)
210
- else p.resolve(value)
211
- }
231
+ if (p.kind === "alert") p.resolve();
232
+ else if (p.kind === "confirm") p.resolve(true);
233
+ else p.resolve(value);
234
+ };
212
235
 
213
236
  const context =
214
- current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
215
- ? (current.context ?? 'primary')
216
- : 'primary'
217
- const defaultAction = current?.kind === 'alert' ? 'OK' : 'Confirmar'
218
- const bodyId = current === undefined ? undefined : `dialog-body-${current.id}`
237
+ current !== undefined &&
238
+ (current.kind === "confirm" || current.kind === "prompt")
239
+ ? (current.context ?? "primary")
240
+ : "primary";
241
+ const defaultAction = current?.kind === "alert" ? "OK" : "Confirmar";
242
+ const bodyId =
243
+ current === undefined ? undefined : `dialog-body-${current.id}`;
219
244
  const bodyDescribesDialog =
220
- current !== undefined && (typeof current.body === 'string' || typeof current.body === 'number')
245
+ current !== undefined &&
246
+ (typeof current.body === "string" || typeof current.body === "number");
221
247
 
222
248
  return (
223
249
  <Dialog
224
250
  mode="alert"
225
251
  open={current !== undefined}
226
252
  onOpenChange={(open) => {
227
- if (!open && current !== undefined) drop(current.id, cancel)
253
+ if (!open && current !== undefined) drop(current.id, cancel);
228
254
  }}
229
255
  >
230
256
  {current !== undefined && (
231
257
  <DialogContent
232
258
  data-slot="dialog"
233
259
  data-kind={current.kind}
234
- className={current.kind === 'choose' ? 'max-w-lg' : undefined}
260
+ className={current.kind === "choose" ? "max-w-lg" : undefined}
235
261
  aria-describedby={bodyDescribesDialog ? bodyId : undefined}
236
262
  // Prompt: o foco vai pro input, não pro Cancelar (default do Dialog). Via
237
263
  // querySelector (não ref) — o Input é function component sem forwardRef, e isto
238
264
  // funciona igual em React 18 e 19.
239
265
  onOpenAutoFocus={
240
- current.kind === 'prompt'
266
+ current.kind === "prompt"
241
267
  ? (e) => {
242
- e.preventDefault()
243
- const root = (e.currentTarget as HTMLElement | null) ?? document
244
- const el = root.querySelector<HTMLInputElement>('[data-slot="input"]')
245
- el?.focus()
246
- el?.select()
268
+ e.preventDefault();
269
+ const root =
270
+ (e.currentTarget as HTMLElement | null) ?? document;
271
+ const el = root.querySelector<HTMLInputElement>(
272
+ '[data-slot="input"]',
273
+ );
274
+ el?.focus();
275
+ el?.select();
247
276
  }
248
277
  : undefined
249
278
  }
250
279
  >
251
280
  <DialogHeader>
252
- {current.media !== undefined && <DialogMedia>{current.media}</DialogMedia>}
281
+ {current.media !== undefined && (
282
+ <DialogMedia>{current.media}</DialogMedia>
283
+ )}
253
284
  <DialogTitle>{current.title}</DialogTitle>
254
285
  </DialogHeader>
255
- {current.body !== undefined && <DialogBody id={bodyId}>{current.body}</DialogBody>}
256
- {current.kind === 'prompt' && (
286
+ {current.body !== undefined && (
287
+ <DialogBody id={bodyId}>{current.body}</DialogBody>
288
+ )}
289
+ {current.kind === "prompt" && (
257
290
  <Input
258
291
  value={text}
259
292
  placeholder={current.placeholder}
260
293
  onChange={(e) => setText(e.target.value)}
261
294
  onKeyDown={(e) => {
262
- if (e.key === 'Enter') {
263
- e.preventDefault()
264
- drop(current.id, (p) => accept(p, text))
295
+ if (e.key === "Enter") {
296
+ e.preventDefault();
297
+ drop(current.id, (p) => accept(p, text));
265
298
  }
266
299
  }}
267
300
  />
268
301
  )}
269
- <DialogFooter
270
- className={
271
- current.kind === 'choose'
272
- ? 'grid-cols-[repeat(auto-fit,minmax(7rem,1fr))]'
273
- : undefined
274
- }
275
- >
276
- {current.kind === 'choose' ? (
277
- current.actions.map((action, index) => {
278
- const primary = index === current.actions.length - 1
279
- return (
280
- <DialogClose key={action.result} initialFocus={action.initialFocus} asChild>
281
- <Button
282
- context={action.context ?? (primary ? 'primary' : 'neutral')}
283
- variant={action.variant ?? (primary ? 'solid' : 'ghost')}
284
- disabled={action.disabled}
285
- onClick={() =>
286
- drop(current.id, (p) => {
287
- if (p.kind === 'choose') p.resolve(action.result)
288
- })
289
- }
302
+ <DialogFooter>
303
+ <ButtonGroup mode="spaced" distribution="equal">
304
+ {current.kind === "choose" ? (
305
+ current.actions.map((action, index) => {
306
+ const primary = index === current.actions.length - 1;
307
+ return (
308
+ <DialogClose
309
+ key={action.result}
310
+ initialFocus={action.initialFocus}
311
+ asChild
290
312
  >
291
- {action.label}
292
- </Button>
293
- </DialogClose>
294
- )
295
- })
296
- ) : (
297
- <>
298
- {current.kind !== 'alert' && (
299
- <DialogClose initialFocus asChild>
313
+ <Button
314
+ context={
315
+ action.context ?? (primary ? "primary" : "neutral")
316
+ }
317
+ variant={
318
+ action.variant ?? (primary ? "solid" : "outline")
319
+ }
320
+ disabled={action.disabled}
321
+ onClick={() =>
322
+ drop(current.id, (p) => {
323
+ if (p.kind === "choose") p.resolve(action.result);
324
+ })
325
+ }
326
+ >
327
+ {action.label}
328
+ </Button>
329
+ </DialogClose>
330
+ );
331
+ })
332
+ ) : (
333
+ <>
334
+ {current.kind !== "alert" && (
335
+ <DialogClose initialFocus asChild>
336
+ <Button
337
+ context="neutral"
338
+ variant="outline"
339
+ onClick={() => drop(current.id, cancel)}
340
+ >
341
+ {current.cancel ?? "Cancelar"}
342
+ </Button>
343
+ </DialogClose>
344
+ )}
345
+ <DialogClose initialFocus={current.kind === "alert"} asChild>
300
346
  <Button
301
- context="neutral"
302
- variant="ghost"
303
- onClick={() => drop(current.id, cancel)}
347
+ context={context}
348
+ variant="solid"
349
+ onClick={() => drop(current.id, (p) => accept(p, text))}
304
350
  >
305
- {current.cancel ?? 'Cancelar'}
351
+ {current.action ?? defaultAction}
306
352
  </Button>
307
353
  </DialogClose>
308
- )}
309
- <DialogClose initialFocus={current.kind === 'alert'} asChild>
310
- <Button
311
- context={context}
312
- variant="solid"
313
- onClick={() => drop(current.id, (p) => accept(p, text))}
314
- >
315
- {current.action ?? defaultAction}
316
- </Button>
317
- </DialogClose>
318
- </>
319
- )}
354
+ </>
355
+ )}
356
+ </ButtonGroup>
320
357
  </DialogFooter>
321
358
  </DialogContent>
322
359
  )}
323
360
  </Dialog>
324
- )
361
+ );
325
362
  }
@@ -9,6 +9,7 @@ import {
9
9
  type ReactNode,
10
10
  } from "react";
11
11
  import { cn } from "../../lib/cn.ts";
12
+ import { usePageHeadingPresence } from "./page-heading-context.tsx";
12
13
  import {
13
14
  SurfaceHeader,
14
15
  surfaceHeaderClasses,
@@ -148,6 +149,7 @@ export type ContentHeaderProps = Omit<HTMLAttributes<HTMLDivElement>, "title">;
148
149
  * `SurfaceHeader`, a mesma de `PageHeader`: só os nomes dos slots e a hierarquia mudam.
149
150
  */
150
151
  export function ContentHeader({
152
+ className,
151
153
  children,
152
154
  ...props
153
155
  }: ContentHeaderProps): ReactElement {
@@ -167,6 +169,11 @@ export function ContentHeader({
167
169
  description: ContentDescription,
168
170
  actions: ContentActions,
169
171
  }}
172
+ className={cn(
173
+ content.variant === "page" &&
174
+ "flex-row items-center justify-start gap-3 sm:flex-row sm:items-center sm:justify-start",
175
+ className,
176
+ )}
170
177
  {...props}
171
178
  >
172
179
  {children}
@@ -195,6 +202,7 @@ export function ContentTitle({
195
202
  "ContentHeader",
196
203
  );
197
204
  const Heading = headingTags[context.level];
205
+ usePageHeadingPresence(context.level === 1);
198
206
  return (
199
207
  <Heading
200
208
  id={id ?? context.titleId}
@@ -217,7 +225,10 @@ export function ContentDescription({
217
225
  return (
218
226
  <p
219
227
  data-slot="content-description"
220
- className={cn(surfaceHeaderClasses[context.variant].description, className)}
228
+ className={cn(
229
+ surfaceHeaderClasses[context.variant].description,
230
+ className,
231
+ )}
221
232
  {...props}
222
233
  />
223
234
  );
@@ -254,7 +265,11 @@ export function ContentActions({
254
265
  return (
255
266
  <div
256
267
  data-slot="content-actions"
257
- className={cn(surfaceHeaderClasses[context.variant].actions, className)}
268
+ className={cn(
269
+ surfaceHeaderClasses[context.variant].actions,
270
+ context.variant === "page" && "ml-auto w-auto",
271
+ className,
272
+ )}
258
273
  {...props}
259
274
  />
260
275
  );
@@ -8,7 +8,7 @@
8
8
  * pelo próprio form (fonte única do scroll e da faixa, sem string duplicada).
9
9
  */
10
10
 
11
- import { useId, type ReactNode } from 'react'
11
+ import { useId, type ReactNode } from "react";
12
12
  import {
13
13
  Dialog,
14
14
  DialogBody,
@@ -16,35 +16,42 @@ import {
16
16
  DialogFooter,
17
17
  DialogHeader,
18
18
  DialogTitle,
19
- } from '../primitives/dialog.tsx'
20
- import { ActionForm, type ActionFormProps } from './form.tsx'
19
+ } from "../primitives/dialog.tsx";
20
+ import { ActionForm, type ActionFormProps } from "./form.tsx";
21
+ import { ButtonGroup } from "../primitives/button-group.tsx";
21
22
 
22
23
  export interface ActionFormDialogProps<
23
24
  TInput extends Record<string, unknown>,
24
25
  TData,
25
26
  > extends ActionFormProps<TInput, TData> {
26
- open: boolean
27
- onOpenChange: (open: boolean) => void
28
- title: string
27
+ open: boolean;
28
+ onOpenChange: (open: boolean) => void;
29
+ title: string;
29
30
  /** Conteúdo relevante apresentado antes dos campos, como texto ou Alert. */
30
- intro?: ReactNode
31
+ intro?: ReactNode;
31
32
  }
32
33
 
33
- export function ActionFormDialog<TInput extends Record<string, unknown>, TData>({
34
+ export function ActionFormDialog<
35
+ TInput extends Record<string, unknown>,
36
+ TData,
37
+ >({
34
38
  open,
35
39
  onOpenChange,
36
40
  title,
37
41
  intro,
38
42
  onSuccess,
39
43
  onCancel,
44
+ cancelVariant,
40
45
  ...rest
41
46
  }: ActionFormDialogProps<TInput, TData>) {
42
- const introId = useId()
47
+ const introId = useId();
43
48
  return (
44
49
  <Dialog open={open} onOpenChange={onOpenChange}>
45
50
  {/* Header e rodapé FIXOS, só o CORPO (campos) scrolla — o "X" não rola junto e
46
51
  forms longos não estouram a viewport. O content já é coluna flex com teto. */}
47
- <DialogContent aria-describedby={intro === undefined ? undefined : introId}>
52
+ <DialogContent
53
+ aria-describedby={intro === undefined ? undefined : introId}
54
+ >
48
55
  <DialogHeader>
49
56
  <DialogTitle>{title}</DialogTitle>
50
57
  </DialogHeader>
@@ -63,14 +70,21 @@ export function ActionFormDialog<TInput extends Record<string, unknown>, TData>(
63
70
  {fields}
64
71
  </DialogBody>
65
72
  )}
66
- footer={(actions) => <DialogFooter>{actions}</DialogFooter>}
73
+ footer={(actions) => (
74
+ <DialogFooter>
75
+ <ButtonGroup mode="spaced" distribution="equal">
76
+ {actions}
77
+ </ButtonGroup>
78
+ </DialogFooter>
79
+ )}
67
80
  onSuccess={(d) => {
68
- onSuccess?.(d)
69
- onOpenChange(false)
81
+ onSuccess?.(d);
82
+ onOpenChange(false);
70
83
  }}
71
84
  onCancel={onCancel ?? (() => onOpenChange(false))}
85
+ cancelVariant={cancelVariant ?? "outline"}
72
86
  />
73
87
  </DialogContent>
74
88
  </Dialog>
75
- )
89
+ );
76
90
  }