@softize/opus 15.2.2 → 16.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 (37) hide show
  1. package/CHANGELOG.md +60 -18
  2. package/bin/lib/check.mjs +98 -15
  3. package/bin/lib/copy.mjs +811 -314
  4. package/bin/lib/gen-manifest.mjs +24 -23
  5. package/bin/lib/gen-runner.mjs +188 -148
  6. package/docs/adr/0010-page-header-owns-page-chrome.md +3 -4
  7. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -3
  8. package/docs/adr/0012-modal-header-only-names-the-surface.md +45 -0
  9. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +92 -0
  10. package/docs/code-style.md +24 -19
  11. package/package.json +5 -1
  12. package/registry/skills/build-opus-ui/references/ui-patterns.md +43 -26
  13. package/src/core/presentation.ts +512 -0
  14. package/src/presentation/index.ts +1 -0
  15. package/src/ui/components/patterns/action-list-dialog.tsx +26 -9
  16. package/src/ui/components/patterns/confirm.tsx +34 -29
  17. package/src/ui/components/patterns/form-dialog.tsx +20 -8
  18. package/src/ui/components/patterns/list.tsx +26 -9
  19. package/src/ui/components/patterns/page.tsx +99 -139
  20. package/src/ui/components/patterns/presentation.tsx +539 -0
  21. package/src/ui/components/patterns/sidebar.tsx +3 -3
  22. package/src/ui/components/patterns/trigger.tsx +38 -17
  23. package/src/ui/components/primitives/button-group.tsx +53 -43
  24. package/src/ui/components/primitives/command.tsx +30 -72
  25. package/src/ui/components/primitives/dialog.tsx +23 -89
  26. package/src/ui/components/primitives/drawer.tsx +8 -34
  27. package/src/ui/docs/content/action-form-dialog.md +12 -10
  28. package/src/ui/docs/content/action-list-dialog.md +22 -17
  29. package/src/ui/docs/content/button.md +52 -35
  30. package/src/ui/docs/content/communication.md +26 -26
  31. package/src/ui/docs/content/dialog.md +173 -154
  32. package/src/ui/docs/content/drawer.md +12 -11
  33. package/src/ui/docs/content/page.md +72 -91
  34. package/src/ui/docs/content/presentation.md +188 -0
  35. package/src/ui/docs/registry.tsx +6 -0
  36. package/src/ui/meta.ts +8 -2
  37. package/src/ui/react.tsx +16 -3
@@ -0,0 +1,539 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useId,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type ReactElement,
11
+ type ReactNode,
12
+ } from "react";
13
+ import { Braces, X } from "lucide-react";
14
+ import type {
15
+ PresentationDiagnostic,
16
+ PresentationDefinition,
17
+ PresentationInvocation,
18
+ PresentationSurface,
19
+ } from "../../../core/presentation.ts";
20
+ import { createPresentationInspectionSnapshot } from "../../../core/presentation.ts";
21
+ import { cn } from "../../lib/cn.ts";
22
+ import { Button, buttonVariants } from "../primitives/button.tsx";
23
+ import { ButtonGroup } from "../primitives/button-group.tsx";
24
+ import { Copyable } from "../primitives/copyable.tsx";
25
+ import {
26
+ Dialog,
27
+ DialogBody,
28
+ DialogClose,
29
+ DialogContent,
30
+ DialogFooter,
31
+ DialogHeader,
32
+ DialogTitle,
33
+ } from "../primitives/dialog.tsx";
34
+ import {
35
+ Menu,
36
+ MenuContent,
37
+ MenuItem,
38
+ MenuLabel,
39
+ MenuTrigger,
40
+ } from "../primitives/menu.tsx";
41
+ import {
42
+ Drawer,
43
+ DrawerBody,
44
+ DrawerClose,
45
+ DrawerContent,
46
+ DrawerFooter,
47
+ DrawerHeader,
48
+ DrawerTitle,
49
+ } from "../primitives/drawer.tsx";
50
+ import {
51
+ Page,
52
+ PageActions,
53
+ PageBody,
54
+ PageFooter,
55
+ PageHeader,
56
+ PageIntro,
57
+ PageNavigation,
58
+ PageTitle,
59
+ useInsidePageShell,
60
+ } from "./page.tsx";
61
+
62
+ interface PresentationBaseProps {
63
+ surface: PresentationSurface;
64
+ title: ReactNode;
65
+ navigation?: ReactNode;
66
+ headerActions?: ReactNode;
67
+ footerActions?: ReactNode;
68
+ children: ReactNode;
69
+ className?: string;
70
+ bodyClassName?: string;
71
+ }
72
+
73
+ export type PresentationProps = PresentationBaseProps &
74
+ (
75
+ | { open?: undefined; onOpenChange?: (open: boolean) => void }
76
+ | { open: boolean; onOpenChange: (open: boolean) => void }
77
+ );
78
+
79
+ const bodyClassName =
80
+ "h-full min-h-full [&>[data-slot=data-state]]:flex [&>[data-slot=data-state]]:min-h-full [&>[data-slot=data-state]]:items-center [&>[data-slot=data-state]]:justify-center";
81
+
82
+ function PresentationActions({
83
+ children,
84
+ equal = false,
85
+ }: {
86
+ children: ReactNode;
87
+ equal?: boolean;
88
+ }) {
89
+ return (
90
+ <ButtonGroup mode="spaced" distribution={equal ? "equal" : "content"}>
91
+ {children}
92
+ </ButtonGroup>
93
+ );
94
+ }
95
+
96
+ function CloseAction({
97
+ surface,
98
+ }: {
99
+ surface: "dialog" | "drawer";
100
+ }): ReactElement {
101
+ const button = (
102
+ <Button size="icon-sm" variant="ghost" aria-label="Fechar">
103
+ <X aria-hidden />
104
+ </Button>
105
+ );
106
+ return surface === "dialog" ? (
107
+ <DialogClose asChild>{button}</DialogClose>
108
+ ) : (
109
+ <DrawerClose asChild>{button}</DrawerClose>
110
+ );
111
+ }
112
+
113
+ /** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
114
+ export function Presentation({
115
+ surface,
116
+ title,
117
+ navigation,
118
+ headerActions,
119
+ footerActions,
120
+ children,
121
+ open,
122
+ onOpenChange,
123
+ className,
124
+ bodyClassName: bodyClassNameProp,
125
+ }: PresentationProps): ReactElement {
126
+ const insidePageShell = useInsidePageShell();
127
+ const [internalOpen, setInternalOpen] = useState(true);
128
+ const modalOpen = open ?? internalOpen;
129
+ function handleOpenChange(nextOpen: boolean): void {
130
+ if (open === undefined) setInternalOpen(nextOpen);
131
+ onOpenChange?.(nextOpen);
132
+ }
133
+ const body = (
134
+ <div
135
+ data-slot="presentation-body"
136
+ className={cn(bodyClassName, bodyClassNameProp)}
137
+ >
138
+ {children}
139
+ </div>
140
+ );
141
+
142
+ if (surface === "page") {
143
+ if (insidePageShell) {
144
+ return (
145
+ <Page className={cn("flex min-h-full flex-col", className)}>
146
+ <PageIntro>
147
+ {navigation === undefined ? null : (
148
+ <PageNavigation>{navigation}</PageNavigation>
149
+ )}
150
+ <PageTitle>{title}</PageTitle>
151
+ {headerActions === undefined ? null : (
152
+ <PageActions>
153
+ <PresentationActions>{headerActions}</PresentationActions>
154
+ </PageActions>
155
+ )}
156
+ </PageIntro>
157
+ <PageBody className="min-h-0 flex-1 overflow-y-auto">{body}</PageBody>
158
+ {footerActions === undefined ? null : (
159
+ <PageFooter>
160
+ <PresentationActions>{footerActions}</PresentationActions>
161
+ </PageFooter>
162
+ )}
163
+ </Page>
164
+ );
165
+ }
166
+ return (
167
+ <Page
168
+ className={cn(
169
+ "flex min-h-full max-w-none flex-col space-y-0 px-0 py-0",
170
+ className,
171
+ )}
172
+ >
173
+ <PageHeader className="border-b px-3 py-4">
174
+ {navigation === undefined ? null : (
175
+ <PageNavigation>{navigation}</PageNavigation>
176
+ )}
177
+ <PageTitle className="text-lg leading-none">{title}</PageTitle>
178
+ {headerActions === undefined ? null : (
179
+ <PageActions>
180
+ <PresentationActions>{headerActions}</PresentationActions>
181
+ </PageActions>
182
+ )}
183
+ </PageHeader>
184
+ <PageBody className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
185
+ {body}
186
+ </PageBody>
187
+ {footerActions === undefined ? null : (
188
+ <PageFooter>
189
+ <PresentationActions>{footerActions}</PresentationActions>
190
+ </PageFooter>
191
+ )}
192
+ </Page>
193
+ );
194
+ }
195
+
196
+ if (surface === "dialog") {
197
+ return (
198
+ <Dialog open={modalOpen} onOpenChange={handleOpenChange}>
199
+ <DialogContent
200
+ className={className}
201
+ showCloseButton={false}
202
+ aria-describedby={undefined}
203
+ >
204
+ <DialogHeader>
205
+ <div className="flex items-center justify-between gap-4">
206
+ {navigation}
207
+ <DialogTitle className="min-w-0 flex-1 truncate">
208
+ {title}
209
+ </DialogTitle>
210
+ <PresentationActions>
211
+ {headerActions}
212
+ <CloseAction surface="dialog" />
213
+ </PresentationActions>
214
+ </div>
215
+ </DialogHeader>
216
+ <DialogBody className={bodyClassNameProp}>{body}</DialogBody>
217
+ {footerActions === undefined ? null : (
218
+ <DialogFooter>
219
+ <PresentationActions equal>{footerActions}</PresentationActions>
220
+ </DialogFooter>
221
+ )}
222
+ </DialogContent>
223
+ </Dialog>
224
+ );
225
+ }
226
+
227
+ return (
228
+ <Drawer open={modalOpen} onOpenChange={handleOpenChange}>
229
+ <DrawerContent
230
+ className={className}
231
+ showCloseButton={false}
232
+ aria-describedby={undefined}
233
+ >
234
+ <DrawerHeader>
235
+ <div className="flex items-center justify-between gap-4">
236
+ {navigation}
237
+ <DrawerTitle className="min-w-0 flex-1 truncate">
238
+ {title}
239
+ </DrawerTitle>
240
+ <PresentationActions>
241
+ {headerActions}
242
+ <CloseAction surface="drawer" />
243
+ </PresentationActions>
244
+ </div>
245
+ </DrawerHeader>
246
+ <DrawerBody className={bodyClassNameProp}>{body}</DrawerBody>
247
+ {footerActions === undefined ? null : (
248
+ <DrawerFooter>
249
+ <PresentationActions equal>{footerActions}</PresentationActions>
250
+ </DrawerFooter>
251
+ )}
252
+ </DrawerContent>
253
+ </Drawer>
254
+ );
255
+ }
256
+
257
+ export interface PresentationInspectorProps {
258
+ definition: PresentationDefinition | unknown;
259
+ invocation?: PresentationInvocation | unknown;
260
+ resolved?: unknown;
261
+ diagnostics?: readonly PresentationDiagnostic[];
262
+ triggerLabel?: string;
263
+ title?: string;
264
+ /** Apresenta o gatilho como FAB; o consumidor continua responsável por posicioná-lo. */
265
+ floating?: boolean;
266
+ className?: string;
267
+ }
268
+
269
+ export interface PresentationRegistration
270
+ extends Pick<
271
+ PresentationInspectorProps,
272
+ "invocation" | "resolved" | "diagnostics"
273
+ > {
274
+ definition: PresentationDefinition;
275
+ /** Retira temporariamente a Presentation do inventário ativo sem desmontar o consumidor. */
276
+ enabled?: boolean;
277
+ }
278
+
279
+ interface ActivePresentationRegistration
280
+ extends Omit<PresentationRegistration, "enabled"> {
281
+ key: string;
282
+ }
283
+
284
+ interface PresentationRegistrationContextValue {
285
+ upsert: (key: string, registration: ActivePresentationRegistration) => void;
286
+ remove: (key: string) => void;
287
+ }
288
+
289
+ interface PresentationDevtoolsContextValue {
290
+ keys: readonly string[];
291
+ read: (key: string) => ActivePresentationRegistration | undefined;
292
+ }
293
+
294
+ const PresentationRegistrationContext =
295
+ createContext<PresentationRegistrationContextValue | null>(null);
296
+ const PresentationDevtoolsContext =
297
+ createContext<PresentationDevtoolsContextValue | null>(null);
298
+
299
+ export interface PresentationDevtoolsProviderProps {
300
+ children: ReactNode;
301
+ }
302
+
303
+ /** Mantém o inventário das Presentations montadas para uma única ferramenta do shell. */
304
+ export function PresentationDevtoolsProvider({
305
+ children,
306
+ }: PresentationDevtoolsProviderProps): ReactElement {
307
+ const registrations = useRef(
308
+ new Map<string, ActivePresentationRegistration>(),
309
+ );
310
+ const [keys, setKeys] = useState<readonly string[]>([]);
311
+ const upsert = useCallback(
312
+ (key: string, registration: ActivePresentationRegistration): void => {
313
+ const exists = registrations.current.has(key);
314
+ registrations.current.set(key, registration);
315
+ if (!exists) setKeys((current) => [...current, key]);
316
+ },
317
+ [],
318
+ );
319
+ const remove = useCallback((key: string): void => {
320
+ if (!registrations.current.delete(key)) return;
321
+ setKeys((current) => current.filter((candidate) => candidate !== key));
322
+ }, []);
323
+ const read = useCallback(
324
+ (key: string) => registrations.current.get(key),
325
+ [],
326
+ );
327
+ const registration = useMemo(() => ({ upsert, remove }), [upsert, remove]);
328
+ const devtools = useMemo(() => ({ keys, read }), [keys, read]);
329
+
330
+ return (
331
+ <PresentationRegistrationContext.Provider value={registration}>
332
+ <PresentationDevtoolsContext.Provider value={devtools}>
333
+ {children}
334
+ </PresentationDevtoolsContext.Provider>
335
+ </PresentationRegistrationContext.Provider>
336
+ );
337
+ }
338
+
339
+ /** Anuncia um snapshot ao provider mais próximo; fora dele, permanece inofensivo. */
340
+ export function usePresentationRegistration({
341
+ definition,
342
+ invocation,
343
+ resolved,
344
+ diagnostics,
345
+ enabled = true,
346
+ }: PresentationRegistration): void {
347
+ const context = useContext(PresentationRegistrationContext);
348
+ const upsert = context?.upsert;
349
+ const remove = context?.remove;
350
+ const key = useId();
351
+
352
+ // Sem dependências de propósito: os dados podem mudar mantendo a mesma Presentation.
353
+ // `upsert` substitui o snapshot em memória sem provocar um ciclo de render.
354
+ useEffect(() => {
355
+ if (upsert === undefined || !enabled) {
356
+ remove?.(key);
357
+ return;
358
+ }
359
+ upsert(key, { key, definition, invocation, resolved, diagnostics });
360
+ });
361
+
362
+ useEffect(
363
+ () => () => {
364
+ remove?.(key);
365
+ },
366
+ [key, remove],
367
+ );
368
+ }
369
+
370
+ interface SelectedPresentation {
371
+ key: string;
372
+ title: string;
373
+ json: string;
374
+ }
375
+
376
+ export interface PresentationDevtoolsProps {
377
+ /** Nome acessível e tooltip do launcher. */
378
+ triggerLabel?: string;
379
+ className?: string;
380
+ }
381
+
382
+ /** Launcher único do shell para escolher e inspecionar qualquer Presentation ativa. */
383
+ export function PresentationDevtools({
384
+ triggerLabel = "Inspecionar Presentations ativas",
385
+ className,
386
+ }: PresentationDevtoolsProps): ReactElement | null {
387
+ const registry = useContext(PresentationDevtoolsContext);
388
+ const [selected, setSelected] = useState<SelectedPresentation | null>(null);
389
+ const [menuOpen, setMenuOpen] = useState(false);
390
+ const registrations =
391
+ registry?.keys.flatMap((key) => {
392
+ const registration = registry.read(key);
393
+ return registration === undefined ? [] : [registration];
394
+ }) ?? [];
395
+
396
+ useEffect(() => {
397
+ if (selected !== null && !registry?.keys.includes(selected.key)) {
398
+ setSelected(null);
399
+ }
400
+ }, [registry?.keys, selected]);
401
+
402
+ if (registrations.length === 0) return null;
403
+
404
+ function inspect(key: string): void {
405
+ const registration = registry?.read(key);
406
+ if (registration === undefined) return;
407
+ const json =
408
+ JSON.stringify(createPresentationInspectionSnapshot(registration), null, 2) ??
409
+ "null";
410
+ setSelected({ key, title: registration.definition.title, json });
411
+ }
412
+
413
+ return (
414
+ <>
415
+ <Menu open={menuOpen} onOpenChange={setMenuOpen}>
416
+ <MenuTrigger asChild>
417
+ <Button
418
+ size="icon"
419
+ variant="outline"
420
+ data-slot="presentation-devtools"
421
+ data-appearance="fab"
422
+ className={cn("rounded-full shadow-md", className)}
423
+ aria-label={triggerLabel}
424
+ title={triggerLabel}
425
+ >
426
+ <Braces aria-hidden />
427
+ </Button>
428
+ </MenuTrigger>
429
+ <MenuContent side="left" align="center" className="min-w-64">
430
+ <MenuLabel>Presentations ativas</MenuLabel>
431
+ {[...registrations].reverse().map((registration) => (
432
+ <MenuItem key={registration.key} onSelect={() => inspect(registration.key)}>
433
+ <span className="min-w-0 flex-1 truncate">
434
+ {registration.definition.title}
435
+ </span>
436
+ <span className="text-xs text-muted-foreground">
437
+ {registration.invocation === undefined ||
438
+ registration.invocation === null ||
439
+ typeof registration.invocation !== "object" ||
440
+ !("surface" in registration.invocation)
441
+ ? "Presentation"
442
+ : String(registration.invocation.surface)}
443
+ </span>
444
+ </MenuItem>
445
+ ))}
446
+ </MenuContent>
447
+ </Menu>
448
+ {selected === null ? null : (
449
+ <Dialog open onOpenChange={(open) => !open && setSelected(null)}>
450
+ <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
451
+ <DialogHeader>
452
+ <DialogTitle>{selected.title}</DialogTitle>
453
+ </DialogHeader>
454
+ <DialogBody>
455
+ <pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
456
+ {selected.json}
457
+ </pre>
458
+ </DialogBody>
459
+ <DialogFooter>
460
+ <Copyable
461
+ value={selected.json}
462
+ className={buttonVariants({ variant: "outline" })}
463
+ >
464
+ Copiar JSON
465
+ </Copyable>
466
+ </DialogFooter>
467
+ </DialogContent>
468
+ </Dialog>
469
+ )}
470
+ </>
471
+ );
472
+ }
473
+
474
+ /** Ferramenta de desenvolvimento para conferir e copiar o artefato consumido pelo renderer. */
475
+ export function PresentationInspector({
476
+ definition,
477
+ invocation,
478
+ resolved,
479
+ diagnostics,
480
+ triggerLabel = "Ver JSON da Presentation",
481
+ title = "JSON da Presentation",
482
+ floating = false,
483
+ className,
484
+ }: PresentationInspectorProps): ReactElement {
485
+ const [open, setOpen] = useState(false);
486
+ const [json, setJson] = useState<string | null>(null);
487
+
488
+ function inspect(): void {
489
+ const snapshot = createPresentationInspectionSnapshot({
490
+ definition,
491
+ invocation,
492
+ resolved,
493
+ diagnostics,
494
+ });
495
+ setJson(JSON.stringify(snapshot, null, 2) ?? "null");
496
+ setOpen(true);
497
+ }
498
+
499
+ function handleOpenChange(nextOpen: boolean): void {
500
+ setOpen(nextOpen);
501
+ if (!nextOpen) setJson(null);
502
+ }
503
+
504
+ return (
505
+ <>
506
+ <Button
507
+ size={floating ? "icon" : "icon-sm"}
508
+ variant={floating ? "outline" : "ghost"}
509
+ data-appearance={floating ? "fab" : "inline"}
510
+ className={cn(floating && "rounded-full shadow-md", className)}
511
+ aria-label={triggerLabel}
512
+ title={triggerLabel}
513
+ onClick={inspect}
514
+ >
515
+ <Braces aria-hidden />
516
+ </Button>
517
+ <Dialog open={open} onOpenChange={handleOpenChange}>
518
+ <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
519
+ <DialogHeader>
520
+ <DialogTitle>{title}</DialogTitle>
521
+ </DialogHeader>
522
+ <DialogBody>
523
+ <pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
524
+ {json ?? ""}
525
+ </pre>
526
+ </DialogBody>
527
+ <DialogFooter>
528
+ <Copyable
529
+ value={json ?? ""}
530
+ className={buttonVariants({ variant: "outline" })}
531
+ >
532
+ Copiar JSON
533
+ </Copyable>
534
+ </DialogFooter>
535
+ </DialogContent>
536
+ </Dialog>
537
+ </>
538
+ );
539
+ }
@@ -48,7 +48,7 @@ export function Sidebar({
48
48
  className={cn(
49
49
  "group/sidebar flex h-full min-h-0 shrink-0 flex-col transition-[width] duration-200 ease-out",
50
50
  divider && "border-r border-border",
51
- collapsed ? "w-14" : "w-64",
51
+ collapsed ? "w-16" : "w-64",
52
52
  className,
53
53
  )}
54
54
  >
@@ -182,7 +182,7 @@ export function SidebarItem({
182
182
  aria-current={active ? "page" : undefined}
183
183
  onClick={onClick}
184
184
  className={cn(
185
- "mx-auto grid size-9 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40",
185
+ "mx-auto grid size-10 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40",
186
186
  "outline-none",
187
187
  focusRing,
188
188
  active
@@ -423,7 +423,7 @@ export function SidebarNav({
423
423
  <nav
424
424
  aria-label={navLabel}
425
425
  data-slot="sidebar-nav"
426
- className={cn("space-y-4 p-2", className)}
426
+ className={cn("space-y-4 p-3", className)}
427
427
  >
428
428
  {groups.map((group, gi) =>
429
429
  !groupFilled(group) ? null : (
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import type { ReactNode } from 'react'
16
- import { useState } from 'react'
16
+ import { useId, useState } from 'react'
17
17
  import type { SimpleContract } from '../../../core/index.ts'
18
18
  import { useTriggerAction } from '../../drivers/react.tsx'
19
19
  import { toast } from '../primitives/sonner.tsx'
@@ -23,9 +23,9 @@ import { Button, type ButtonProps } from '../primitives/button.tsx'
23
23
  import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip.tsx'
24
24
  import {
25
25
  Dialog,
26
+ DialogBody,
26
27
  DialogClose,
27
28
  DialogContent,
28
- DialogDescription,
29
29
  DialogFooter,
30
30
  DialogHeader,
31
31
  DialogTitle,
@@ -59,7 +59,8 @@ export interface ActionTriggerProps<TInput, TData> {
59
59
  * contrato — declarativo). Sem nenhum dos dois, dispara direto. */
60
60
  confirm?: {
61
61
  title: string
62
- description?: string
62
+ /** Conteúdo relevante apresentado antes das ações. */
63
+ body?: ReactNode
63
64
  actionLabel?: string
64
65
  cancelLabel?: string
65
66
  }
@@ -88,6 +89,7 @@ export function ActionTrigger<TInput, TData>({
88
89
  itemLabel,
89
90
  className,
90
91
  }: ActionTriggerProps<TInput, TData>) {
92
+ const confirmBodyId = useId()
91
93
  const [open, setOpen] = useState(false)
92
94
  const { trigger, isLoading } = useTriggerAction(action, {
93
95
  onSuccess: (data) => {
@@ -98,7 +100,9 @@ export function ActionTrigger<TInput, TData>({
98
100
  onError: (err) => {
99
101
  // A frase de negócio do servidor vence o rótulo do contrato (ver lib/action-errors.ts);
100
102
  // fora da allowlist, vale o rótulo — nunca o texto técnico cru.
101
- toast.error(humanizeActionError(err, msgText(action.messages?.error, 'Não foi possível concluir.')))
103
+ toast.error(
104
+ humanizeActionError(err, msgText(action.messages?.error, 'Não foi possível concluir.')),
105
+ )
102
106
  },
103
107
  })
104
108
 
@@ -111,9 +115,13 @@ export function ActionTrigger<TInput, TData>({
111
115
  (spec !== undefined
112
116
  ? {
113
117
  title: msgText(spec.title, 'Confirmar?'),
114
- ...(spec.message !== undefined ? { description: msgText(spec.message, '') } : {}),
115
- ...(spec.confirmLabel !== undefined ? { actionLabel: msgText(spec.confirmLabel, buttonLabel) } : {}),
116
- ...(spec.cancelLabel !== undefined ? { cancelLabel: msgText(spec.cancelLabel, 'Cancelar') } : {}),
118
+ ...(spec.message !== undefined ? { body: msgText(spec.message, '') } : {}),
119
+ ...(spec.confirmLabel !== undefined
120
+ ? { actionLabel: msgText(spec.confirmLabel, buttonLabel) }
121
+ : {}),
122
+ ...(spec.cancelLabel !== undefined
123
+ ? { cancelLabel: msgText(spec.cancelLabel, 'Cancelar') }
124
+ : {}),
117
125
  }
118
126
  : undefined)
119
127
  const destructive = spec?.destructive === true
@@ -126,7 +134,8 @@ export function ActionTrigger<TInput, TData>({
126
134
  * botão é sempre `solid` — não herda o `variant` do gatilho, que descreve o item, não a
127
135
  * decisão.
128
136
  */
129
- const triggerContext = context ?? (destructive ? 'danger' : icon !== undefined ? 'neutral' : 'primary')
137
+ const triggerContext =
138
+ context ?? (destructive ? 'danger' : icon !== undefined ? 'neutral' : 'primary')
130
139
  const triggerVariant = variant ?? (icon !== undefined ? 'ghost' : 'solid')
131
140
  const confirmContext = destructive ? 'danger' : (context ?? 'primary')
132
141
  const confirmVariant = 'solid'
@@ -173,24 +182,36 @@ export function ActionTrigger<TInput, TData>({
173
182
  {renderButton(() => setOpen(true))}
174
183
 
175
184
  <Dialog mode="alert" open={open} onOpenChange={setOpen}>
176
- <DialogContent data-action={action.name}>
185
+ <DialogContent
186
+ data-action={action.name}
187
+ aria-describedby={
188
+ itemLabel !== undefined || confirm.body !== undefined ? confirmBodyId : undefined
189
+ }
190
+ >
177
191
  <DialogHeader>
178
192
  <DialogTitle>{confirm.title}</DialogTitle>
179
- {(itemLabel !== undefined || confirm.description !== undefined) && (
180
- <DialogDescription>
181
- {itemLabel !== undefined && <span className="font-medium text-foreground">“{itemLabel}”</span>}
182
- {itemLabel !== undefined && confirm.description !== undefined ? ' — ' : ''}
183
- {confirm.description}
184
- </DialogDescription>
185
- )}
186
193
  </DialogHeader>
194
+ {(itemLabel !== undefined || confirm.body !== undefined) && (
195
+ <DialogBody id={confirmBodyId} className="text-sm text-muted-foreground">
196
+ {itemLabel !== undefined && (
197
+ <span className="font-medium text-foreground">“{itemLabel}”</span>
198
+ )}
199
+ {itemLabel !== undefined && confirm.body !== undefined ? ' — ' : ''}
200
+ {confirm.body}
201
+ </DialogBody>
202
+ )}
187
203
  <DialogFooter>
188
204
  <DialogClose initialFocus asChild>
189
205
  <Button context="neutral" variant="ghost" disabled={isLoading}>
190
206
  {confirm.cancelLabel ?? 'Cancelar'}
191
207
  </Button>
192
208
  </DialogClose>
193
- <Button context={confirmContext} variant={confirmVariant} busy={isLoading} onClick={fire}>
209
+ <Button
210
+ context={confirmContext}
211
+ variant={confirmVariant}
212
+ busy={isLoading}
213
+ onClick={fire}
214
+ >
194
215
  {confirm.actionLabel ?? buttonLabel}
195
216
  </Button>
196
217
  </DialogFooter>