@softize/opus 25.7.0 → 26.1.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 (41) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/bin/lib/check.mjs +9 -32
  3. package/bin/lib/copy.mjs +7 -4
  4. package/docs/adr/0014-structural-headers-do-not-carry-description.md +1 -1
  5. package/docs/adr/0018-modal-header-only-names-the-surface.md +1 -1
  6. package/docs/adr/0023-surfaces-share-one-anatomy-and-two-layouts.md +4 -0
  7. package/docs/adr/0058-mensagem-da-ia-aceita-imagem-e-o-chat-recebe-anexos.md +201 -0
  8. package/docs/adr/0059-subheadings-orient-without-carrying-business-rules.md +51 -0
  9. package/docs/adr/README.md +2 -0
  10. package/package.json +1 -1
  11. package/registry/skills/build-opus-ui/references/evaluations.md +8 -0
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +14 -4
  13. package/src/ai/drivers/anthropic.ts +49 -9
  14. package/src/ai/index.ts +5 -2
  15. package/src/core/ai-message.ts +20 -0
  16. package/src/core/index.ts +5 -0
  17. package/src/core/types.ts +28 -4
  18. package/src/ui/components/patterns/action-list-dialog.tsx +14 -7
  19. package/src/ui/components/patterns/form-dialog.tsx +18 -7
  20. package/src/ui/components/patterns/form.tsx +36 -8
  21. package/src/ui/components/patterns/page.tsx +22 -0
  22. package/src/ui/components/patterns/surface-anatomy.ts +1 -1
  23. package/src/ui/components/primitives/chat.tsx +179 -22
  24. package/src/ui/components/primitives/composer.tsx +397 -7
  25. package/src/ui/components/primitives/dialog.tsx +28 -1
  26. package/src/ui/components/primitives/drawer.tsx +30 -2
  27. package/src/ui/docs/content/action-form-card.md +1 -1
  28. package/src/ui/docs/content/action-form-dialog.md +8 -0
  29. package/src/ui/docs/content/action-form.md +18 -5
  30. package/src/ui/docs/content/action-list-dialog.md +8 -0
  31. package/src/ui/docs/content/ai.md +46 -0
  32. package/src/ui/docs/content/chat.md +97 -2
  33. package/src/ui/docs/content/composer.md +144 -1
  34. package/src/ui/docs/content/dialog.md +24 -2
  35. package/src/ui/docs/content/drawer.md +24 -2
  36. package/src/ui/docs/content/page.md +23 -1
  37. package/src/ui/docs/content/surface.md +8 -2
  38. package/src/ui/lib/modal-layers.ts +27 -3
  39. package/src/ui/lib/prepare-image.ts +386 -0
  40. package/src/ui/meta.ts +2 -2
  41. package/src/ui/react.tsx +21 -1
@@ -1,10 +1,58 @@
1
1
  import * as React from "react";
2
- import { SendHorizontalIcon } from "lucide-react";
2
+ import { ImageIcon, ImagePlusIcon, SendHorizontalIcon, XIcon } from "lucide-react";
3
+ import type { AiImageMediaType } from "../../../core/index.ts";
3
4
  import { cn } from "../../lib/cn.ts";
5
+ import { IMAGE_MEDIA_TYPES, imageTypeList } from "../../lib/prepare-image.ts";
4
6
  import { Button } from "./button.tsx";
5
7
  import { Spinner } from "./spinner.tsx";
6
8
  import { Textarea } from "./textarea.tsx";
7
9
 
10
+ /** Estado de um anexo pendente: enviando, pronto para ir com a mensagem ou com falha. */
11
+ export type ComposerAttachmentStatus = "uploading" | "ready" | "failed";
12
+
13
+ /** Um anexo pendente da mensagem, no estado que o app mantém (ADR 0058). */
14
+ export interface ComposerAttachment {
15
+ /** Identidade estável do anexo, dada pelo app. */
16
+ id: string;
17
+ /** Nome mostrado na faixa; identifica o anexo também para leitores de tela. */
18
+ name: string;
19
+ status: ComposerAttachmentStatus;
20
+ /** Endereço da miniatura (por exemplo, `URL.createObjectURL` da imagem preparada). */
21
+ previewUrl?: string;
22
+ /** Motivo da falha, mostrado no lugar do estado quando `status` é `failed`. */
23
+ error?: string;
24
+ }
25
+
26
+ /** Um arquivo recusado pela triagem: fora dos tipos aceitos ou além do limite da mensagem. */
27
+ export interface ComposerFileRejection {
28
+ file: File;
29
+ reason: "type" | "limit";
30
+ }
31
+
32
+ /**
33
+ * Anexos da mensagem, controlados pelo app (ADR 0058). O composer faz a triagem e mostra a faixa;
34
+ * preparar, enviar, guardar e limpar depois do envio são do app, que é o dono do transporte.
35
+ */
36
+ export interface ComposerAttachments {
37
+ /** Anexos pendentes, na ordem em que aparecem na faixa. */
38
+ items: ComposerAttachment[];
39
+ /** Recebe os arquivos aceitos pela triagem — do botão, do colar ou do soltar. */
40
+ onAddFiles: (files: File[]) => void;
41
+ /** A pessoa removeu um anexo da faixa. */
42
+ onRemove: (id: string) => void;
43
+ /** Limite de anexos por mensagem. Padrão: 4. */
44
+ max?: number;
45
+ /** Formatos aceitos, um subconjunto dos que a mensagem da IA leva. Padrão: PNG, JPEG e WebP.
46
+ * Não há curinga (`image/*`): a triagem compara o tipo exato do arquivo. */
47
+ accept?: readonly AiImageMediaType[];
48
+ /** Recebe os arquivos recusados. Presente, a explicação da recusa é do app; ausente, o
49
+ * composer a mostra acima do campo. */
50
+ onReject?: (rejections: ComposerFileRejection[]) => void;
51
+ }
52
+
53
+ /** Limite padrão de anexos por mensagem. */
54
+ export const COMPOSER_MAX_ATTACHMENTS = 4;
55
+
8
56
  export interface ComposerProps {
9
57
  /** Texto (controlado): o dono do estado é o consumidor — o `<Chat>` por dentro, ou o app
10
58
  * (ex.: o composer de criação de sessão do Maestro, que nem tem histórico). */
@@ -29,15 +77,165 @@ export interface ComposerProps {
29
77
  * embaixo); ausente → linha única (textarea + enviar inline), o composer clássico do Chat.
30
78
  * Estilize os controles como discretos (ghost/sem borda) — a barra não é um formulário. */
31
79
  actions?: React.ReactNode;
80
+ /** Anexos da mensagem (ADR 0058). Presente → duas linhas, faixa de anexos acima do texto,
81
+ * botão **Anexar imagem** na barra, colar e soltar imagens; ausente → só texto. */
82
+ attachments?: ComposerAttachments;
32
83
  className?: string;
33
84
  }
34
85
 
86
+ /**
87
+ * Ponte interna entre o `<Chat>` e o seu composer: o Chat recebe o soltar sobre a conversa
88
+ * inteira e entrega os arquivos à mesma triagem do composer. Não faz parte da API pública.
89
+ */
90
+ export const ComposerDropBridge = React.createContext<React.RefObject<
91
+ ((files: File[]) => void) | null
92
+ > | null>(null);
93
+
94
+ /** O arraste traz arquivos (e não texto ou um link). */
95
+ export function dragHasFiles(transfer: DataTransfer | null): boolean {
96
+ return transfer !== null && Array.from(transfer.types ?? []).includes("Files");
97
+ }
98
+
99
+ const PASTED_EXTENSIONS: Record<string, string> = {
100
+ "image/png": ".png",
101
+ "image/jpeg": ".jpg",
102
+ "image/webp": ".webp",
103
+ };
104
+
105
+ /** A imagem colada chega como "image.png"; ganha um nome que a pessoa reconhece na conversa. O
106
+ * número vem de um contador da instância, que não volta atrás: remover "Imagem colada 1" e colar
107
+ * de novo dá "Imagem colada 3", e não uma segunda "Imagem colada 1" na mesma conversa. */
108
+ function namePasted(file: File, position: number): File {
109
+ const extension = PASTED_EXTENSIONS[file.type];
110
+ if (extension === undefined) return file;
111
+ return new File([file], `Imagem colada ${position}${extension}`, {
112
+ type: file.type,
113
+ lastModified: file.lastModified,
114
+ });
115
+ }
116
+
117
+ function rejectionMessage(
118
+ rejections: ComposerFileRejection[],
119
+ accept: readonly AiImageMediaType[],
120
+ max: number,
121
+ ): string {
122
+ const parts: string[] = [];
123
+ if (rejections.some((r) => r.reason === "type")) {
124
+ parts.push(`Só é possível anexar imagens ${imageTypeList(accept)}.`);
125
+ }
126
+ if (rejections.some((r) => r.reason === "limit")) {
127
+ parts.push(
128
+ max === 1 ? "Cada mensagem aceita 1 imagem." : `Cada mensagem aceita até ${max} imagens.`,
129
+ );
130
+ }
131
+ return parts.join(" ");
132
+ }
133
+
134
+ /** O que mudou nos anexos desde o render anterior, para a região viva anunciar. */
135
+ function statusAnnouncement(
136
+ previous: ReadonlyMap<string, ComposerAttachmentStatus>,
137
+ items: ComposerAttachment[],
138
+ ): string | null {
139
+ const changes: string[] = [];
140
+ for (const item of items) {
141
+ const before = previous.get(item.id);
142
+ if (before === item.status) continue;
143
+ if (item.status === "ready") changes.push(`${item.name} pronta para enviar.`);
144
+ else if (item.status === "failed") changes.push(`${item.name}: ${statusLabel(item)}`);
145
+ }
146
+ return changes.length > 0 ? changes.join(" ") : null;
147
+ }
148
+
149
+ function statusLabel(item: ComposerAttachment): string {
150
+ if (item.status === "uploading") return "Enviando…";
151
+ if (item.status === "failed") return item.error ?? "Não foi possível enviar.";
152
+ return "Pronta";
153
+ }
154
+
155
+ /** A faixa de anexos pendentes: miniatura, nome, estado e remover. */
156
+ function AttachmentStrip({
157
+ items,
158
+ max,
159
+ onRemove,
160
+ }: {
161
+ items: ComposerAttachment[];
162
+ max: number;
163
+ onRemove: (item: ComposerAttachment) => void;
164
+ }): React.ReactElement {
165
+ return (
166
+ <div data-slot="composer-attachments" className="flex items-end gap-2 px-1 pb-1.5">
167
+ <ul aria-label="Imagens anexadas" className="flex min-w-0 flex-1 flex-wrap gap-1.5">
168
+ {items.map((item) => (
169
+ <li
170
+ key={item.id}
171
+ data-slot="composer-attachment"
172
+ data-status={item.status}
173
+ aria-busy={item.status === "uploading" || undefined}
174
+ className={cn(
175
+ "flex min-w-0 max-w-52 items-center gap-2 rounded-lg border bg-muted/40 p-1 text-xs",
176
+ item.status === "failed" ? "border-context-danger-border" : "border-border",
177
+ )}
178
+ >
179
+ <span className="relative grid size-10 shrink-0 place-items-center overflow-hidden rounded-md bg-muted">
180
+ {item.previewUrl !== undefined ? (
181
+ // O nome está ao lado: a miniatura é decorativa para leitores de tela.
182
+ <img src={item.previewUrl} alt="" className="size-full object-cover" />
183
+ ) : (
184
+ <ImageIcon aria-hidden className="size-4 text-muted-foreground" />
185
+ )}
186
+ {item.status === "uploading" && (
187
+ <span className="absolute inset-0 grid place-items-center bg-background/60">
188
+ <Spinner size="xs" />
189
+ </span>
190
+ )}
191
+ </span>
192
+ <span className="min-w-0 flex-1 leading-tight">
193
+ <span className="block truncate font-medium text-foreground" title={item.name}>
194
+ {item.name}
195
+ </span>
196
+ <span
197
+ className={cn(
198
+ "block truncate",
199
+ item.status === "failed"
200
+ ? "text-context-danger-emphasis"
201
+ : "text-muted-foreground",
202
+ )}
203
+ title={item.status === "failed" ? statusLabel(item) : undefined}
204
+ >
205
+ {statusLabel(item)}
206
+ </span>
207
+ </span>
208
+ <Button
209
+ type="button"
210
+ size="icon-xs"
211
+ variant="ghost"
212
+ shape="pill"
213
+ aria-label={`Remover ${item.name}`}
214
+ onClick={() => onRemove(item)}
215
+ className="shrink-0 self-start text-muted-foreground hover:text-foreground"
216
+ >
217
+ <XIcon />
218
+ </Button>
219
+ </li>
220
+ ))}
221
+ </ul>
222
+ <span
223
+ data-slot="composer-attachment-count"
224
+ className="shrink-0 pb-1 text-xs text-muted-foreground"
225
+ >
226
+ {max === 1 ? `${items.length} de 1 imagem` : `${items.length} de ${max} imagens`}
227
+ </span>
228
+ </div>
229
+ );
230
+ }
231
+
35
232
  /**
36
233
  * <Composer /> — a caixa de escrever da casa: textarea (Enter envia / Shift+Enter quebra
37
234
  * linha) numa pílula elevada (rounded-xl + border + shadow-sm), com o enviar dentro.
38
235
  * É o composer do `<Chat>` extraído pra ser reusável SOZINHO — o Maestro cria sessão com
39
236
  * ele (sem chat nenhum), e a GB prende seletores nele. Com `actions`, ganha uma barra
40
- * embaixo pros seletores; sem, é a linha única de sempre.
237
+ * embaixo pros seletores; sem, é a linha única de sempre. Com `attachments`, ganha a faixa
238
+ * de anexos e o botão **Anexar imagem** (ADR 0058).
41
239
  */
42
240
  export function Composer({
43
241
  value,
@@ -51,19 +249,118 @@ export function Composer({
51
249
  rows = 1,
52
250
  autoFocus,
53
251
  actions,
252
+ attachments,
54
253
  className,
55
254
  }: ComposerProps): React.ReactElement {
56
- const canSubmit = value.trim() !== "" && !busy && !submitDisabled;
255
+ const items = attachments?.items ?? [];
256
+ const max = attachments?.max ?? COMPOSER_MAX_ATTACHMENTS;
257
+ const accept = attachments?.accept ?? IMAGE_MEDIA_TYPES;
258
+ const [notice, setNotice] = React.useState<string | null>(null);
259
+ const [dragging, setDragging] = React.useState(false);
260
+ const [announcement, setAnnouncement] = React.useState("");
261
+ const textareaRef = React.useRef<HTMLTextAreaElement>(null);
262
+ const fileInputRef = React.useRef<HTMLInputElement>(null);
263
+ const pastedCount = React.useRef(0);
264
+ const pickerUsed = React.useRef(false);
265
+ const previousStatus = React.useRef(new Map<string, ComposerAttachmentStatus>());
266
+ const bridge = React.useContext(ComposerDropBridge);
267
+ const full = items.length >= max;
268
+
269
+ // Anexo que fica pronto ou falha é anunciado: a mudança na faixa é só visual. O estado com que
270
+ // o composer nasce não é novidade e não é anunciado.
271
+ const statusKnown = React.useRef(false);
272
+ React.useEffect(() => {
273
+ const next = statusKnown.current ? statusAnnouncement(previousStatus.current, items) : null;
274
+ statusKnown.current = true;
275
+ previousStatus.current = new Map(items.map((item) => [item.id, item.status]));
276
+ if (next !== null) setAnnouncement(next);
277
+ }, [items]);
278
+
279
+ // Quem atinge o limite pelo botão fica com o foco num botão desabilitado; o foco vai ao campo.
280
+ React.useEffect(() => {
281
+ if (!full || !pickerUsed.current) return;
282
+ pickerUsed.current = false;
283
+ textareaRef.current?.focus();
284
+ }, [full]);
285
+
286
+ // Envio: texto ou pelo menos um anexo pronto; nunca com anexo em envio ou em falha, para que
287
+ // nada do que está na faixa fique de fora sem aviso.
288
+ const hasReady = items.some((item) => item.status === "ready");
289
+ const pending = items.some((item) => item.status !== "ready");
290
+ const canSubmit = (value.trim() !== "" || hasReady) && !pending && !busy && !submitDisabled;
57
291
  const fire = (): void => {
58
- if (canSubmit) onSubmit();
292
+ if (!canSubmit) return;
293
+ setNotice(null);
294
+ onSubmit();
295
+ };
296
+ const twoRow = actions !== undefined || attachments !== undefined;
297
+
298
+ /** Triagem: tipo aceito e espaço na mensagem. Os aceitos vão ao app; os recusados, ao
299
+ * `onReject` do app ou ao aviso do composer. */
300
+ const intake = (files: File[]): void => {
301
+ if (attachments === undefined || files.length === 0) return;
302
+ const remaining = Math.max(0, max - items.length);
303
+ const accepted: File[] = [];
304
+ const rejected: ComposerFileRejection[] = [];
305
+ for (const file of files) {
306
+ if (!(accept as readonly string[]).includes(file.type)) rejected.push({ file, reason: "type" });
307
+ else if (accepted.length >= remaining) rejected.push({ file, reason: "limit" });
308
+ else accepted.push(file);
309
+ }
310
+ if (accepted.length > 0) attachments.onAddFiles(accepted);
311
+ if (rejected.length > 0 && attachments.onReject !== undefined) {
312
+ attachments.onReject(rejected);
313
+ setNotice(null);
314
+ } else {
315
+ setNotice(rejected.length > 0 ? rejectionMessage(rejected, accept, max) : null);
316
+ }
317
+ };
318
+
319
+ // Dentro do <Chat>, o soltar é recebido pela conversa inteira e chega aqui pela ponte. A
320
+ // ponte acompanha cada render, para a triagem contar os anexos atuais.
321
+ React.useEffect(() => {
322
+ if (bridge === null) return;
323
+ bridge.current = attachments !== undefined ? intake : null;
324
+ });
325
+ React.useEffect(
326
+ () => () => {
327
+ if (bridge !== null) bridge.current = null;
328
+ },
329
+ [bridge],
330
+ );
331
+
332
+ const remove = (item: ComposerAttachment): void => {
333
+ attachments?.onRemove(item.id);
334
+ setNotice(null);
335
+ // O botão some com o anexo; o foco volta ao campo em vez de cair no documento.
336
+ textareaRef.current?.focus();
59
337
  };
60
- const twoRow = actions !== undefined;
61
338
 
62
339
  const textarea = (
63
340
  <Textarea
341
+ ref={textareaRef}
64
342
  autoFocus={autoFocus}
65
343
  value={value}
66
344
  onChange={(e) => onChange(e.target.value)}
345
+ onPaste={
346
+ attachments === undefined
347
+ ? undefined
348
+ : (e) => {
349
+ // Texto vence: copiar células de uma planilha traz texto e uma imagem juntos, e
350
+ // quem cola ali quer o texto. Só a imagem sem texto, como um print, vira anexo.
351
+ if (e.clipboardData.getData("text/plain") !== "") return;
352
+ const files = Array.from(e.clipboardData.files ?? []);
353
+ if (files.length === 0) return;
354
+ e.preventDefault();
355
+ intake(
356
+ files.map((file) =>
357
+ PASTED_EXTENSIONS[file.type] === undefined
358
+ ? file
359
+ : namePasted(file, (pastedCount.current += 1)),
360
+ ),
361
+ );
362
+ }
363
+ }
67
364
  onKeyDown={(e) => {
68
365
  if (e.key === "ArrowUp" && onHistoryPrevious?.()) {
69
366
  e.preventDefault();
@@ -101,29 +398,122 @@ export function Composer({
101
398
  {busy ? <Spinner size="sm" /> : <SendHorizontalIcon className="size-4" />}
102
399
  </Button>
103
400
  );
401
+ const attach = attachments !== undefined && (
402
+ <>
403
+ <Button
404
+ type="button"
405
+ size="icon-sm"
406
+ variant="ghost"
407
+ shape="pill"
408
+ aria-label="Anexar imagem"
409
+ title="Anexar imagem"
410
+ disabled={full}
411
+ onClick={() => {
412
+ pickerUsed.current = true;
413
+ fileInputRef.current?.click();
414
+ }}
415
+ onBlur={(e) => {
416
+ // Abrir o seletor tira o foco da janela (sem destino); ir a outro controle desfaz a marca.
417
+ if (e.relatedTarget !== null) pickerUsed.current = false;
418
+ }}
419
+ className="text-muted-foreground hover:text-foreground"
420
+ >
421
+ <ImagePlusIcon />
422
+ </Button>
423
+ <input
424
+ ref={fileInputRef}
425
+ data-slot="composer-file-input"
426
+ type="file"
427
+ accept={accept.join(",")}
428
+ multiple={max - items.length > 1}
429
+ hidden
430
+ tabIndex={-1}
431
+ onChange={(e) => {
432
+ intake(Array.from(e.target.files ?? []));
433
+ // Escolher o mesmo arquivo de novo precisa disparar `change` outra vez.
434
+ e.target.value = "";
435
+ }}
436
+ />
437
+ </>
438
+ );
439
+ // Fora do <Chat>, o próprio composer recebe o soltar.
440
+ const ownDrop = attachments !== undefined && bridge === null;
104
441
 
105
442
  return (
106
443
  <form
107
444
  data-slot="composer"
445
+ data-dragging={dragging || undefined}
108
446
  onSubmit={(e) => {
109
447
  e.preventDefault();
110
448
  fire();
111
449
  }}
450
+ onDragOver={
451
+ ownDrop
452
+ ? (e) => {
453
+ if (!dragHasFiles(e.dataTransfer)) return;
454
+ e.preventDefault();
455
+ e.stopPropagation();
456
+ setDragging(true);
457
+ }
458
+ : undefined
459
+ }
460
+ onDragLeave={
461
+ ownDrop
462
+ ? (e) => {
463
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragging(false);
464
+ }
465
+ : undefined
466
+ }
467
+ onDrop={
468
+ ownDrop
469
+ ? (e) => {
470
+ if (!dragHasFiles(e.dataTransfer)) return;
471
+ e.preventDefault();
472
+ // O soltar foi consumido aqui; um contêiner do app não deve recebê-lo de novo.
473
+ e.stopPropagation();
474
+ setDragging(false);
475
+ intake(Array.from(e.dataTransfer.files));
476
+ }
477
+ : undefined
478
+ }
112
479
  className={cn(
113
480
  "rounded-xl border border-border bg-card text-card-foreground shadow-sm transition",
114
481
  twoRow ? "p-2" : "relative flex items-end gap-2 p-2 pl-3.5",
482
+ dragging && "border-primary ring-2 ring-primary/30",
115
483
  className,
116
484
  )}
117
485
  >
118
486
  {twoRow ? (
119
487
  <>
488
+ {attachments !== undefined && (
489
+ // Região viva montada desde o início: um aviso que nasce junto com ela não é
490
+ // anunciado por todos os leitores de tela.
491
+ <p
492
+ role="status"
493
+ data-slot="composer-attachment-notice"
494
+ className={cn(
495
+ notice === null ? "sr-only" : "px-1.5 pb-1.5 text-xs text-context-warning-emphasis",
496
+ )}
497
+ >
498
+ {notice}
499
+ </p>
500
+ )}
501
+ {attachments !== undefined && (
502
+ <p role="status" data-slot="composer-attachment-announcement" className="sr-only">
503
+ {announcement}
504
+ </p>
505
+ )}
506
+ {items.length > 0 && <AttachmentStrip items={items} max={max} onRemove={remove} />}
120
507
  {textarea}
121
508
  <div
122
509
  data-slot="composer-actions"
123
510
  className="mt-1 flex items-center justify-between gap-2"
124
511
  >
125
- {/* Seletores à esquerda (discretos), enviar à direita. */}
126
- <div className="flex min-w-0 items-center gap-1">{actions}</div>
512
+ {/* Anexar e seletores à esquerda (discretos), enviar à direita. */}
513
+ <div className="flex min-w-0 items-center gap-1">
514
+ {attach}
515
+ {actions}
516
+ </div>
127
517
  {send}
128
518
  </div>
129
519
  </>
@@ -5,6 +5,7 @@ import { cn } from "../../lib/cn.ts";
5
5
  import {
6
6
  SurfaceLayoutProvider,
7
7
  surfaceActionsClasses,
8
+ surfaceDescriptionClasses,
8
9
  surfaceHeaderRowClasses,
9
10
  surfaceRegionClasses,
10
11
  surfaceTitleClasses,
@@ -26,6 +27,7 @@ import {
26
27
  exemptLayerFirst,
27
28
  focusIsInLayer,
28
29
  handleModalTabKey,
30
+ skipHelpOnAutoFocus,
29
31
  isLayerInteraction,
30
32
  routeEscapeToLayer,
31
33
  useLayerPortal,
@@ -415,6 +417,7 @@ export function DialogContent({
415
417
  onEscapeKeyDown,
416
418
  onPointerDownOutside,
417
419
  onInteractOutside,
420
+ onOpenAutoFocus,
418
421
  onCloseAutoFocus,
419
422
  onKeyDown,
420
423
  ref,
@@ -481,6 +484,7 @@ export function DialogContent({
481
484
  aria-describedby={ariaDescribedBy}
482
485
  // O `AlertDialog` do Radix cancela sozinho o clique fora; só o Esc é composto.
483
486
  onEscapeKeyDown={dismiss.onEscapeKeyDown}
487
+ onOpenAutoFocus={onOpenAutoFocus}
484
488
  onCloseAutoFocus={dismiss.onCloseAutoFocus}
485
489
  onKeyDown={onKeyDown}
486
490
  {...props}
@@ -532,6 +536,10 @@ export function DialogContent({
532
536
  onPointerDownOutside={dismiss.onPointerDownOutside}
533
537
  onInteractOutside={dismiss.onInteractOutside}
534
538
  onCloseAutoFocus={dismiss.onCloseAutoFocus}
539
+ onOpenAutoFocus={(event) => {
540
+ onOpenAutoFocus?.(event);
541
+ skipHelpOnAutoFocus(event);
542
+ }}
535
543
  onKeyDown={handleKeyDown}
536
544
  {...props}
537
545
  >
@@ -584,6 +592,7 @@ export function DialogHeader({
584
592
  leading: DialogNavigation,
585
593
  media: DialogMedia,
586
594
  title: DialogTitle,
595
+ description: DialogDescription,
587
596
  actions: DialogActions,
588
597
  }}
589
598
  leadingPlacement="inline"
@@ -645,7 +654,11 @@ export function DialogIntro({
645
654
  <StructuralHeader
646
655
  name="DialogIntro"
647
656
  slot="dialog-intro"
648
- slots={{ title: DialogTitle, actions: DialogIntroActions }}
657
+ slots={{
658
+ title: DialogTitle,
659
+ description: DialogDescription,
660
+ actions: DialogIntroActions,
661
+ }}
649
662
  leadingPlacement="inline"
650
663
  titleRequired
651
664
  className={cn("text-left", className)}
@@ -770,3 +783,17 @@ export function DialogTitle({
770
783
  </Title>
771
784
  );
772
785
  }
786
+
787
+ /** Orientação opcional sob o título; condições e consequências ficam no corpo. */
788
+ export function DialogDescription({
789
+ className,
790
+ ...props
791
+ }: React.ComponentProps<"p">) {
792
+ return (
793
+ <p
794
+ data-slot="dialog-description"
795
+ className={cn("mt-1", surfaceDescriptionClasses("dialog"), className)}
796
+ {...props}
797
+ />
798
+ );
799
+ }
@@ -5,6 +5,7 @@ import { cn } from "../../lib/cn.ts";
5
5
  import {
6
6
  SurfaceLayoutProvider,
7
7
  surfaceActionsClasses,
8
+ surfaceDescriptionClasses,
8
9
  surfaceHeaderRowClasses,
9
10
  surfaceRegionClasses,
10
11
  surfaceTitleClasses,
@@ -28,6 +29,7 @@ import {
28
29
  LAYER_MODAL_ATTRIBUTE,
29
30
  LayerPortalProvider,
30
31
  handleModalTabKey,
32
+ skipHelpOnAutoFocus,
31
33
  useLayerPortal,
32
34
  useModalLayerEntry,
33
35
  } from "../../lib/modal-layers.ts";
@@ -113,7 +115,9 @@ function DrawerOverlay({
113
115
  return (
114
116
  <DrawerPrimitive.Overlay
115
117
  data-slot="drawer-overlay"
116
- {...{ [LAYER_MODAL_ATTRIBUTE]: layer.container !== undefined || undefined }}
118
+ {...{
119
+ [LAYER_MODAL_ATTRIBUTE]: layer.container !== undefined || undefined,
120
+ }}
117
121
  className={cn(overlayClasses, layer.zIndexClass, className)}
118
122
  {...props}
119
123
  />
@@ -146,6 +150,7 @@ function DrawerContent({
146
150
  onEscapeKeyDown,
147
151
  onPointerDownOutside,
148
152
  onInteractOutside,
153
+ onOpenAutoFocus,
149
154
  onCloseAutoFocus,
150
155
  onKeyDown,
151
156
  ref,
@@ -218,6 +223,10 @@ function DrawerContent({
218
223
  onPointerDownOutside={dismiss.onPointerDownOutside}
219
224
  onInteractOutside={dismiss.onInteractOutside}
220
225
  onCloseAutoFocus={dismiss.onCloseAutoFocus}
226
+ onOpenAutoFocus={(event) => {
227
+ onOpenAutoFocus?.(event);
228
+ skipHelpOnAutoFocus(event);
229
+ }}
221
230
  onKeyDown={handleKeyDown}
222
231
  {...props}
223
232
  >
@@ -251,6 +260,7 @@ function DrawerHeader({
251
260
  leading: DrawerNavigation,
252
261
  media: DrawerMedia,
253
262
  title: DrawerTitle,
263
+ description: DrawerDescription,
254
264
  actions: DrawerActions,
255
265
  }}
256
266
  leadingPlacement="inline"
@@ -314,7 +324,11 @@ function DrawerIntro({
314
324
  <StructuralHeader
315
325
  name="DrawerIntro"
316
326
  slot="drawer-intro"
317
- slots={{ title: DrawerTitle, actions: DrawerIntroActions }}
327
+ slots={{
328
+ title: DrawerTitle,
329
+ description: DrawerDescription,
330
+ actions: DrawerIntroActions,
331
+ }}
318
332
  leadingPlacement="inline"
319
333
  titleRequired
320
334
  className={cn("text-left", className)}
@@ -435,3 +449,17 @@ export {
435
449
  DrawerFooter,
436
450
  DrawerTitle,
437
451
  };
452
+
453
+ /** Orientação opcional sob o título; condições e consequências ficam no corpo. */
454
+ export function DrawerDescription({
455
+ className,
456
+ ...props
457
+ }: React.ComponentProps<"p">) {
458
+ return (
459
+ <p
460
+ data-slot="drawer-description"
461
+ className={cn("mt-1", surfaceDescriptionClasses("drawer"), className)}
462
+ {...props}
463
+ />
464
+ );
465
+ }
@@ -28,7 +28,7 @@ a mesma.
28
28
  | ------------------- | ---------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
29
  | `title` | `string` | | Título do cabeçalho. Sem ele e sem `description`, o card começa diretamente pelo corpo. |
30
30
  | `titleLevel` | `1 \| 2 \| 3 \| 4 \| 5 \| 6` | `3` | Nível do heading do título. Muda a hierarquia do documento, não a escala visual. |
31
- | `description` | `string` | | Texto de apoio abaixo do título. |
31
+ | `description` | `string` | | Orientação opcional sobre a finalidade. Regras e consequências ficam junto dos campos ou no corpo. |
32
32
  | `cardClassName` | `string` | | Classes aplicadas à superfície do card. |
33
33
  | `className` | `string` | | Classes aplicadas ao `<form>` interno. |
34
34
  | Demais propriedades | Propriedades de `ActionForm` | | `action`, `defaultValues`, `fieldOptions`, `submitLabel`, `cancelLabel`, `cancelVariant`, `onCancel`, `onSuccess`, `disabled`, `onLoadingChange` e `children` mantêm o comportamento de `ActionForm`. `body` e `footer` não são aceitas: o card fornece o corpo e o rodapé. |
@@ -35,12 +35,20 @@ salvo escolha explícita em `cancelVariant`.
35
35
 
36
36
  Para consultar uma coleção no mesmo formato de modal, use [`ActionListDialog`](/ui/action-list-dialog).
37
37
 
38
+ ## Orientação e contexto
39
+
40
+ `description` apresenta a finalidade no cabeçalho, como “Escolha onde a pasta e suas subpastas
41
+ aparecem.”. É opcional; não repita o título. `intro` apresenta no corpo informações necessárias
42
+ à decisão que não pertencem a um campo específico. Regras, condições e consequências não ficam
43
+ somente no subheading. Quando ambos são fornecidos, ambos participam de `aria-describedby`.
44
+
38
45
  ## Propriedades de ActionFormDialog
39
46
 
40
47
  | Propriedade | Tipo | Padrão | Descrição |
41
48
  | --------------------- | ------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
49
  | `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e, quando não há `onCancel` próprio, ao cancelar. |
43
50
  | `title` | `string` | | Operação e recurso exibidos no cabeçalho, como `Criar workspace` ou `Editar workspace`. |
51
+ | `description` | `ReactNode` | | Orientação opcional sob o título; não recebe regras de negócio. |
44
52
  | `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
45
53
  | `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`. |
46
54
  | `footerDistribution` | `'content' \| 'equal'` | `'content'` | Mantém a largura das ações pelo conteúdo ou divide a faixa igualmente. |