@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,512 @@
1
+ import { z } from "zod";
2
+ import type { ActionContract } from "./contracts.ts";
3
+
4
+ const identifierSchema = z.string().regex(/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/);
5
+
6
+ export type PresentationJsonValue =
7
+ | string
8
+ | number
9
+ | boolean
10
+ | null
11
+ | PresentationJsonValue[]
12
+ | { [key: string]: PresentationJsonValue };
13
+
14
+ export const presentationJsonValueSchema: z.ZodType<PresentationJsonValue> =
15
+ z.lazy(() =>
16
+ z.union([
17
+ z.string(),
18
+ z.number().finite(),
19
+ z.boolean(),
20
+ z.null(),
21
+ z.array(presentationJsonValueSchema),
22
+ z.record(presentationJsonValueSchema),
23
+ ]),
24
+ );
25
+
26
+ const presentationInputSchema = z.record(presentationJsonValueSchema);
27
+
28
+ export const presentationSurfaceSchema = z.enum(["page", "dialog", "drawer"]);
29
+ export type PresentationSurface = z.infer<typeof presentationSurfaceSchema>;
30
+
31
+ export const presentationBindingSchema = z.discriminatedUnion("source", [
32
+ z.object({ source: z.literal("route"), param: identifierSchema }).strict(),
33
+ z.object({ source: z.literal("record"), field: z.string().min(1) }).strict(),
34
+ z.object({ source: z.literal("item"), field: z.string().min(1) }).strict(),
35
+ z.object({ source: z.literal("selection") }).strict(),
36
+ z.object({ source: z.literal("session"), field: z.string().min(1) }).strict(),
37
+ z.object({ source: z.literal("result"), field: z.string().min(1) }).strict(),
38
+ z
39
+ .object({ source: z.literal("fixed"), value: presentationJsonValueSchema })
40
+ .strict(),
41
+ z.object({ source: z.literal("now") }).strict(),
42
+ ]);
43
+ export type PresentationBinding = z.infer<typeof presentationBindingSchema>;
44
+
45
+ export const presentationEffectSchema = z.discriminatedUnion("effect", [
46
+ z
47
+ .object({
48
+ effect: z.literal("refresh"),
49
+ action: z.string().min(1).optional(),
50
+ })
51
+ .strict(),
52
+ z.object({ effect: z.literal("close") }).strict(),
53
+ z.object({ effect: z.literal("back") }).strict(),
54
+ z
55
+ .object({
56
+ effect: z.literal("navigate"),
57
+ presentation: identifierSchema,
58
+ surface: presentationSurfaceSchema.optional(),
59
+ input: z.record(presentationBindingSchema).default({}),
60
+ mode: z.enum(["push", "replace"]).default("push"),
61
+ })
62
+ .strict(),
63
+ ]);
64
+ export type PresentationEffect = z.infer<typeof presentationEffectSchema>;
65
+ export type PresentationEffectInput = z.input<typeof presentationEffectSchema>;
66
+
67
+ const presentationTargetSchema = z
68
+ .object({
69
+ presentation: identifierSchema,
70
+ surface: presentationSurfaceSchema.optional(),
71
+ })
72
+ .strict();
73
+
74
+ const presentationCommandSchema = z
75
+ .object({
76
+ action: z.string().min(1),
77
+ placement: z.enum(["header", "footer"]),
78
+ input: z.record(presentationBindingSchema).default({}),
79
+ target: presentationTargetSchema.optional(),
80
+ blocking: z.enum(["action", "group", "surface"]).default("action"),
81
+ onSuccess: z.array(presentationEffectSchema).default([]),
82
+ })
83
+ .strict();
84
+
85
+ const presentationFieldSchema = z
86
+ .object({
87
+ key: z.string().min(1),
88
+ label: z.string().min(1),
89
+ empty: z.string().min(1).optional(),
90
+ })
91
+ .strict();
92
+
93
+ const presentationBodySchema = z
94
+ .object({
95
+ action: z.string().min(1),
96
+ input: z.record(presentationBindingSchema).default({}),
97
+ fields: z.array(presentationFieldSchema).min(1).optional(),
98
+ open: z
99
+ .object({
100
+ presentation: identifierSchema,
101
+ surface: presentationSurfaceSchema.optional(),
102
+ input: z.record(presentationBindingSchema).default({}),
103
+ })
104
+ .strict()
105
+ .optional(),
106
+ })
107
+ .strict();
108
+
109
+ export const presentationSchema = z
110
+ .object({
111
+ schemaVersion: z.literal(1),
112
+ id: identifierSchema,
113
+ title: z.string().min(1),
114
+ body: presentationBodySchema,
115
+ actions: z.array(presentationCommandSchema).default([]),
116
+ })
117
+ .strict();
118
+
119
+ export type PresentationDefinition = z.infer<typeof presentationSchema>;
120
+ export type PresentationDefinitionInput = z.input<typeof presentationSchema>;
121
+ export type PresentationCommand = PresentationDefinition["actions"][number];
122
+ type DefaultedProperty<Input, Key extends PropertyKey, Output> =
123
+ Key extends keyof Input
124
+ ? undefined extends Input[Key]
125
+ ? Exclude<Input[Key], undefined> | Output
126
+ : Input[Key] & Output
127
+ : Output;
128
+ type NavigateEffect = Extract<PresentationEffect, { effect: "navigate" }>;
129
+ type DefinedPresentationEffect<Effect> = Effect extends { effect: "navigate" }
130
+ ? Omit<Effect, "input" | "mode"> &
131
+ Omit<NavigateEffect, "input" | "mode"> & {
132
+ input: DefaultedProperty<Effect, "input", NavigateEffect["input"]>;
133
+ mode: DefaultedProperty<Effect, "mode", NavigateEffect["mode"]>;
134
+ }
135
+ : Effect & PresentationEffect;
136
+ type DefinedPresentationEffects<Input, Key extends PropertyKey> =
137
+ Key extends keyof Input
138
+ ? undefined extends Input[Key]
139
+ ? PresentationCommand["onSuccess"]
140
+ : Input[Key] extends readonly unknown[]
141
+ ? {
142
+ -readonly [Index in keyof Input[Key]]: DefinedPresentationEffect<
143
+ Input[Key][Index]
144
+ >;
145
+ }
146
+ : PresentationCommand["onSuccess"]
147
+ : PresentationCommand["onSuccess"];
148
+ type PresentationOpen = NonNullable<PresentationDefinition["body"]["open"]>;
149
+ type DefinedPresentationOpen<Open> = Open extends object
150
+ ? Omit<Open, "input"> &
151
+ Omit<PresentationOpen, "input"> & {
152
+ input: DefaultedProperty<Open, "input", PresentationOpen["input"]>;
153
+ }
154
+ : never;
155
+ type DefinedPresentationBody<Body extends PresentationDefinitionInput["body"]> =
156
+ Omit<Body, "input" | "open"> &
157
+ Omit<PresentationDefinition["body"], "input" | "open"> & {
158
+ input: DefaultedProperty<
159
+ Body,
160
+ "input",
161
+ PresentationDefinition["body"]["input"]
162
+ >;
163
+ } & ("open" extends keyof Body
164
+ ? undefined extends Body["open"]
165
+ ? { open?: DefinedPresentationOpen<Exclude<Body["open"], undefined>> }
166
+ : { open: DefinedPresentationOpen<Body["open"]> }
167
+ : { open?: PresentationDefinition["body"]["open"] });
168
+ type DefinedPresentationCommand<Command> = Command extends object
169
+ ? Omit<Command, "input" | "blocking" | "onSuccess"> &
170
+ Omit<PresentationCommand, "input" | "blocking" | "onSuccess"> & {
171
+ input: DefaultedProperty<Command, "input", PresentationCommand["input"]>;
172
+ blocking: DefaultedProperty<
173
+ Command,
174
+ "blocking",
175
+ PresentationCommand["blocking"]
176
+ >;
177
+ onSuccess: DefinedPresentationEffects<Command, "onSuccess">;
178
+ }
179
+ : never;
180
+ type DefinedPresentationActions<T extends PresentationDefinitionInput> =
181
+ T extends { actions: infer Actions extends readonly unknown[] }
182
+ ? {
183
+ -readonly [Index in keyof Actions]: DefinedPresentationCommand<
184
+ Actions[Index]
185
+ >;
186
+ }
187
+ : PresentationDefinition["actions"];
188
+ export type DefinedPresentation<T extends PresentationDefinitionInput> = Omit<
189
+ T,
190
+ "body" | "actions"
191
+ > &
192
+ Omit<PresentationDefinition, "body" | "actions"> & {
193
+ body: DefinedPresentationBody<T["body"]>;
194
+ actions: DefinedPresentationActions<T>;
195
+ };
196
+
197
+ export const presentationFrameSchema = z
198
+ .object({
199
+ presentationId: identifierSchema,
200
+ surface: presentationSurfaceSchema,
201
+ input: presentationInputSchema.default({}),
202
+ })
203
+ .strict();
204
+
205
+ export const presentationInvocationSchema = presentationFrameSchema
206
+ .extend({
207
+ schemaVersion: z.literal(1),
208
+ stack: z.array(presentationFrameSchema).default([]),
209
+ })
210
+ .strict();
211
+
212
+ export type PresentationFrame = z.infer<typeof presentationFrameSchema>;
213
+ export type PresentationInvocation = z.infer<
214
+ typeof presentationInvocationSchema
215
+ >;
216
+
217
+ export interface PresentationTransition {
218
+ invocation: PresentationInvocation | null;
219
+ refresh: Array<string | null>;
220
+ exit?: "back" | "close";
221
+ }
222
+
223
+ export interface PresentationDiagnostic {
224
+ level: "info" | "warning" | "error";
225
+ code: string;
226
+ message: string;
227
+ path?: string;
228
+ }
229
+
230
+ export interface PresentationInspectionSnapshot {
231
+ definition: unknown;
232
+ invocation?: unknown;
233
+ resolved?: unknown;
234
+ diagnostics: PresentationDiagnostic[];
235
+ }
236
+
237
+ export interface PresentationActionRegistry {
238
+ readonly [name: string]: ActionContract;
239
+ }
240
+
241
+ export interface PresentationBindingContext {
242
+ route?: Readonly<Record<string, unknown>>;
243
+ record?: Readonly<Record<string, unknown>>;
244
+ item?: Readonly<Record<string, unknown>>;
245
+ selection?: readonly unknown[];
246
+ session?: Readonly<Record<string, unknown>>;
247
+ result?: Readonly<Record<string, unknown>>;
248
+ now?: () => Date;
249
+ }
250
+
251
+ export function definePresentation<const T extends PresentationDefinitionInput>(
252
+ definition: T,
253
+ ): DefinedPresentation<T> {
254
+ return presentationSchema.parse(definition) as unknown as DefinedPresentation<T>;
255
+ }
256
+
257
+ export function definePresentationInvocation(
258
+ invocation: z.input<typeof presentationInvocationSchema>,
259
+ ): PresentationInvocation {
260
+ return presentationInvocationSchema.parse(invocation);
261
+ }
262
+
263
+ function frameFromInvocation(
264
+ invocation: PresentationInvocation,
265
+ ): PresentationFrame {
266
+ return {
267
+ presentationId: invocation.presentationId,
268
+ surface: invocation.surface,
269
+ input: invocation.input,
270
+ };
271
+ }
272
+
273
+ export function openPresentation(
274
+ invocation: PresentationInvocation,
275
+ target: PresentationFrame,
276
+ mode: "push" | "replace" = "push",
277
+ ): PresentationInvocation {
278
+ const parsedTarget = presentationFrameSchema.parse(target);
279
+ return definePresentationInvocation({
280
+ schemaVersion: 1,
281
+ ...parsedTarget,
282
+ stack:
283
+ mode === "push"
284
+ ? [...invocation.stack, frameFromInvocation(invocation)]
285
+ : invocation.stack,
286
+ });
287
+ }
288
+
289
+ export function backPresentation(
290
+ invocation: PresentationInvocation,
291
+ ): PresentationInvocation | null {
292
+ const parent = invocation.stack.at(-1);
293
+ if (parent === undefined) return null;
294
+ return definePresentationInvocation({
295
+ schemaVersion: 1,
296
+ ...parent,
297
+ stack: invocation.stack.slice(0, -1),
298
+ });
299
+ }
300
+
301
+ export function closePresentation(invocation: PresentationInvocation): null {
302
+ if (invocation.surface === "page") {
303
+ throw new Error(
304
+ "Uma Presentation em page não pode receber o efeito close.",
305
+ );
306
+ }
307
+ return null;
308
+ }
309
+
310
+ export function applyPresentationEffects(
311
+ invocation: PresentationInvocation,
312
+ effects: readonly PresentationEffectInput[],
313
+ context: PresentationBindingContext = {},
314
+ ): PresentationTransition {
315
+ let current: PresentationInvocation | null = invocation;
316
+ const refresh: Array<string | null> = [];
317
+ let exit: PresentationTransition["exit"];
318
+
319
+ for (const rawEffect of effects) {
320
+ const effect = presentationEffectSchema.parse(rawEffect);
321
+ if (effect.effect === "refresh") {
322
+ refresh.push(effect.action ?? null);
323
+ continue;
324
+ }
325
+ if (current === null) {
326
+ throw new Error(
327
+ `O efeito ${effect.effect} não possui Presentation ativa.`,
328
+ );
329
+ }
330
+ if (effect.effect === "close") {
331
+ current = closePresentation(current);
332
+ exit = "close";
333
+ continue;
334
+ }
335
+ if (effect.effect === "back") {
336
+ current = backPresentation(current);
337
+ if (current === null) exit = "back";
338
+ continue;
339
+ }
340
+ current = openPresentation(
341
+ current,
342
+ {
343
+ presentationId: effect.presentation,
344
+ surface: effect.surface ?? current.surface,
345
+ input: resolvePresentationBindings(effect.input, context) as Record<
346
+ string,
347
+ PresentationJsonValue
348
+ >,
349
+ },
350
+ effect.mode,
351
+ );
352
+ }
353
+
354
+ return {
355
+ invocation: current,
356
+ refresh,
357
+ ...(exit === undefined ? {} : { exit }),
358
+ };
359
+ }
360
+
361
+ const sensitiveKeyPattern =
362
+ /(?:authorization|cookie|password|secret|token|api[-_]?key)/iu;
363
+
364
+ export function redactPresentationInspectionValue(
365
+ value: unknown,
366
+ key = "",
367
+ ): unknown {
368
+ if (sensitiveKeyPattern.test(key)) return "[REDACTED]";
369
+ if (Array.isArray(value)) {
370
+ return value.map((item) => redactPresentationInspectionValue(item));
371
+ }
372
+ if (value !== null && typeof value === "object") {
373
+ return Object.fromEntries(
374
+ Object.entries(value).map(([entryKey, entryValue]) => [
375
+ entryKey,
376
+ redactPresentationInspectionValue(entryValue, entryKey),
377
+ ]),
378
+ );
379
+ }
380
+ return value;
381
+ }
382
+
383
+ export function createPresentationInspectionSnapshot(input: {
384
+ definition: unknown;
385
+ invocation?: unknown;
386
+ resolved?: unknown;
387
+ diagnostics?: readonly PresentationDiagnostic[];
388
+ }): PresentationInspectionSnapshot {
389
+ return redactPresentationInspectionValue({
390
+ definition: input.definition,
391
+ ...(input.invocation === undefined ? {} : { invocation: input.invocation }),
392
+ ...(input.resolved === undefined ? {} : { resolved: input.resolved }),
393
+ diagnostics: input.diagnostics ?? [],
394
+ }) as PresentationInspectionSnapshot;
395
+ }
396
+
397
+ export function validatePresentations(
398
+ input: unknown,
399
+ actions: PresentationActionRegistry,
400
+ ): PresentationDefinition[] {
401
+ const parsed = z.array(presentationSchema).parse(input);
402
+ const byId = new Map<string, PresentationDefinition>();
403
+ const problems: string[] = [];
404
+
405
+ for (const presentation of parsed) {
406
+ if (byId.has(presentation.id))
407
+ problems.push(`Presentation “${presentation.id}” duplicada.`);
408
+ byId.set(presentation.id, presentation);
409
+
410
+ const bodyAction = actions[presentation.body.action];
411
+ if (bodyAction === undefined) {
412
+ problems.push(
413
+ `A action “${presentation.body.action}” não está disponível.`,
414
+ );
415
+ } else if (bodyAction.kind === "simple") {
416
+ problems.push(
417
+ `A action simple “${bodyAction.name}” não pode ocupar o body.`,
418
+ );
419
+ } else {
420
+ if (
421
+ bodyAction.kind === "view" &&
422
+ presentation.body.fields === undefined
423
+ ) {
424
+ problems.push(
425
+ `A Presentation “${presentation.id}” precisa declarar fields para a view.`,
426
+ );
427
+ }
428
+ if (
429
+ bodyAction.kind !== "view" &&
430
+ presentation.body.fields !== undefined
431
+ ) {
432
+ problems.push(`Fields só podem ser declarados para uma action view.`);
433
+ }
434
+ if (bodyAction.kind !== "list" && presentation.body.open !== undefined) {
435
+ problems.push(`Open só pode ser declarado para uma action list.`);
436
+ }
437
+ }
438
+
439
+ for (const command of presentation.actions) {
440
+ const action = actions[command.action];
441
+ if (action === undefined) {
442
+ problems.push(`A action “${command.action}” não está disponível.`);
443
+ } else if (action.kind === "simple" && command.target !== undefined) {
444
+ problems.push(
445
+ `A action simple “${command.action}” executa diretamente e não aceita target.`,
446
+ );
447
+ } else if (action.kind !== "simple" && command.target === undefined) {
448
+ problems.push(
449
+ `A action ${action.kind} “${command.action}” precisa abrir outra Presentation.`,
450
+ );
451
+ }
452
+ }
453
+ }
454
+
455
+ for (const presentation of parsed) {
456
+ const open = presentation.body.open;
457
+ if (open !== undefined && !byId.has(open.presentation)) {
458
+ problems.push(
459
+ `A lista “${presentation.id}” abre a Presentation inexistente “${open.presentation}”.`,
460
+ );
461
+ }
462
+ for (const command of presentation.actions) {
463
+ for (const effect of command.onSuccess) {
464
+ if (effect.effect === "navigate" && !byId.has(effect.presentation)) {
465
+ problems.push(
466
+ `A action “${command.action}” navega para a Presentation inexistente “${effect.presentation}”.`,
467
+ );
468
+ }
469
+ }
470
+ if (command.target === undefined) continue;
471
+ const target = byId.get(command.target.presentation);
472
+ if (target === undefined) {
473
+ problems.push(
474
+ `A action “${command.action}” abre a Presentation inexistente “${command.target.presentation}”.`,
475
+ );
476
+ } else if (target.body.action !== command.action) {
477
+ problems.push(
478
+ `A Presentation “${target.id}” usa “${target.body.action}”, mas foi aberta por “${command.action}”.`,
479
+ );
480
+ }
481
+ }
482
+ }
483
+
484
+ if (problems.length > 0) throw new Error(problems.join("\n"));
485
+ return parsed;
486
+ }
487
+
488
+ export function resolvePresentationBinding(
489
+ binding: PresentationBinding,
490
+ context: PresentationBindingContext,
491
+ ): unknown {
492
+ if (binding.source === "route") return context.route?.[binding.param];
493
+ if (binding.source === "record") return context.record?.[binding.field];
494
+ if (binding.source === "item") return context.item?.[binding.field];
495
+ if (binding.source === "selection") return context.selection;
496
+ if (binding.source === "session") return context.session?.[binding.field];
497
+ if (binding.source === "result") return context.result?.[binding.field];
498
+ if (binding.source === "fixed") return binding.value;
499
+ return (context.now ?? (() => new Date()))().toISOString();
500
+ }
501
+
502
+ export function resolvePresentationBindings(
503
+ bindings: Readonly<Record<string, PresentationBinding>>,
504
+ context: PresentationBindingContext,
505
+ ): Record<string, unknown> {
506
+ return Object.fromEntries(
507
+ Object.entries(bindings).map(([key, binding]) => [
508
+ key,
509
+ resolvePresentationBinding(binding, context),
510
+ ]),
511
+ );
512
+ }
@@ -0,0 +1 @@
1
+ export * from "../core/presentation.ts";
@@ -11,20 +11,22 @@
11
11
  * - `empty` sobrepõe o vazio derivado (ex.: form inline de criar aberto ⇒ children).
12
12
  */
13
13
 
14
- import type { ReactNode } from 'react'
14
+ import { useId, type ReactNode } from 'react'
15
15
  import { cn } from '../../lib/cn.ts'
16
16
  import {
17
17
  Dialog,
18
18
  DialogBody,
19
19
  DialogContent,
20
- DialogDescription,
21
20
  DialogHeader,
22
21
  DialogTitle,
23
22
  } from '../primitives/dialog.tsx'
24
23
  import { ActionList } from './list.tsx'
25
24
  import type { ListAction } from '../../../core/index.ts'
26
25
 
27
- export interface ActionListDialogProps<TItem, TInput extends Record<string, unknown> = Record<string, unknown>> {
26
+ export interface ActionListDialogProps<
27
+ TItem,
28
+ TInput extends Record<string, unknown> = Record<string, unknown>,
29
+ > {
28
30
  /** A ListAction do Opus (estrutural: name + kind — o mesmo contrato da família). */
29
31
  action: { name: string; kind: 'list' }
30
32
  /** Input da action (o escopo base; ex.: { workspaceId }). Mudou, re-busca. */
@@ -32,7 +34,8 @@ export interface ActionListDialogProps<TItem, TInput extends Record<string, unkn
32
34
  open: boolean
33
35
  onOpenChange: (open: boolean) => void
34
36
  title: string
35
- description?: string
37
+ /** Conteúdo relevante apresentado antes da lista, como texto ou Alert. */
38
+ intro?: ReactNode
36
39
  /** Nota à esquerda da linha de ações (contexto curto). O total já mora no rodapé. */
37
40
  note?: ReactNode
38
41
  /** Ação à direita da linha — em geral o botão de criar. */
@@ -51,13 +54,16 @@ export interface ActionListDialogProps<TItem, TInput extends Record<string, unkn
51
54
  children: (items: TItem[], refetch: () => Promise<void>) => ReactNode
52
55
  }
53
56
 
54
- export function ActionListDialog<TItem, TInput extends Record<string, unknown> = Record<string, unknown>>({
57
+ export function ActionListDialog<
58
+ TItem,
59
+ TInput extends Record<string, unknown> = Record<string, unknown>,
60
+ >({
55
61
  action,
56
62
  input,
57
63
  open,
58
64
  onOpenChange,
59
65
  title,
60
- description,
66
+ intro,
61
67
  note,
62
68
  actions,
63
69
  emptyMessage,
@@ -68,16 +74,27 @@ export function ActionListDialog<TItem, TInput extends Record<string, unknown> =
68
74
  className,
69
75
  children,
70
76
  }: ActionListDialogProps<TItem, TInput>): React.ReactElement {
77
+ const introId = useId()
71
78
  return (
72
79
  <Dialog open={open} onOpenChange={onOpenChange}>
73
- <DialogContent className={cn('sm:max-w-3xl', className)}>
80
+ <DialogContent
81
+ className={cn('sm:max-w-3xl', className)}
82
+ aria-describedby={intro === undefined ? undefined : introId}
83
+ >
74
84
  <DialogHeader>
75
85
  <DialogTitle>{title}</DialogTitle>
76
- {description !== undefined && <DialogDescription>{description}</DialogDescription>}
77
86
  </DialogHeader>
78
87
  <DialogBody>
88
+ {intro !== undefined && (
89
+ <div id={introId} className="mb-4">
90
+ {intro}
91
+ </div>
92
+ )}
79
93
  {(note !== undefined || actions !== undefined) && (
80
- <div data-slot="action-list-dialog-toolbar" className="mb-2 flex min-h-8 items-center justify-between gap-4">
94
+ <div
95
+ data-slot="action-list-dialog-toolbar"
96
+ className="mb-2 flex min-h-8 items-center justify-between gap-4"
97
+ >
81
98
  <div className="min-w-0 text-xs text-muted-foreground/70">{note}</div>
82
99
  {actions}
83
100
  </div>