@softize/opus 30.0.0 → 31.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 (64) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/bin/lib/check.mjs +109 -101
  3. package/bin/lib/copy.mjs +2973 -1801
  4. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +52 -0
  5. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +27 -2
  6. package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +142 -0
  7. package/docs/adr/0064-action-list-com-itens-e-fontes-externas.md +47 -0
  8. package/docs/adr/0065-presentation-composes-settings-sections.md +295 -0
  9. package/docs/adr/0066-civil-date-input-and-calendar-list.md +28 -0
  10. package/docs/adr/README.md +6 -0
  11. package/package.json +1 -1
  12. package/registry/skills/build-opus-ui/SKILL.md +21 -19
  13. package/registry/skills/build-opus-ui/references/evaluations.md +13 -11
  14. package/registry/skills/build-opus-ui/references/ui-patterns.md +24 -27
  15. package/src/core/presentation.ts +938 -32
  16. package/src/core/types.ts +2 -0
  17. package/src/ui/components/patterns/form.tsx +22 -1
  18. package/src/ui/components/patterns/grouped-list.ts +2 -2
  19. package/src/ui/components/patterns/list-calendar.tsx +129 -0
  20. package/src/ui/components/patterns/list.tsx +612 -382
  21. package/src/ui/components/patterns/page-heading-context.tsx +12 -5
  22. package/src/ui/components/patterns/page.tsx +227 -197
  23. package/src/ui/components/patterns/presentation-fields.tsx +246 -0
  24. package/src/ui/components/patterns/presentation-sections.tsx +494 -0
  25. package/src/ui/components/patterns/presentation.tsx +1142 -229
  26. package/src/ui/components/patterns/structural-header.tsx +23 -32
  27. package/src/ui/components/patterns/surface-anatomy.ts +27 -217
  28. package/src/ui/components/patterns/surface.tsx +19 -29
  29. package/src/ui/components/patterns/trigger.tsx +10 -2
  30. package/src/ui/components/primitives/calendar.tsx +39 -18
  31. package/src/ui/components/primitives/close-button.tsx +11 -3
  32. package/src/ui/components/primitives/command.tsx +2 -8
  33. package/src/ui/components/primitives/date-input.tsx +276 -0
  34. package/src/ui/components/primitives/detail.tsx +1 -1
  35. package/src/ui/components/primitives/dialog.tsx +19 -81
  36. package/src/ui/components/primitives/drawer.tsx +17 -79
  37. package/src/ui/components/primitives/field.tsx +1 -1
  38. package/src/ui/components/primitives/pagination.tsx +3 -2
  39. package/src/ui/components/primitives/table.tsx +1 -1
  40. package/src/ui/components/primitives/tabs.tsx +13 -2
  41. package/src/ui/docs/content/action-form.md +6 -1
  42. package/src/ui/docs/content/action-list.md +156 -68
  43. package/src/ui/docs/content/action-trigger.md +5 -0
  44. package/src/ui/docs/content/button.md +5 -6
  45. package/src/ui/docs/content/calendar-month-view.md +50 -0
  46. package/src/ui/docs/content/calendar.md +6 -1
  47. package/src/ui/docs/content/command.md +62 -50
  48. package/src/ui/docs/content/compositions.md +129 -459
  49. package/src/ui/docs/content/date-input.md +67 -0
  50. package/src/ui/docs/content/dialog.md +44 -112
  51. package/src/ui/docs/content/drawer.md +44 -103
  52. package/src/ui/docs/content/field.md +2 -1
  53. package/src/ui/docs/content/input.md +3 -0
  54. package/src/ui/docs/content/page.md +204 -412
  55. package/src/ui/docs/content/presentation.md +371 -23
  56. package/src/ui/docs/content/separator.md +1 -1
  57. package/src/ui/docs/content/surface.md +65 -120
  58. package/src/ui/docs/content/tokens.md +18 -4
  59. package/src/ui/docs/registry.tsx +38 -9
  60. package/src/ui/drivers/context.tsx +11 -4
  61. package/src/ui/drivers/react.tsx +27 -7
  62. package/src/ui/meta.ts +17 -6
  63. package/src/ui/react.tsx +41 -8
  64. package/src/ui/theme.css +44 -33
@@ -1,3 +1,29 @@
1
+ import { useViewAction, useListAction } from "../../drivers/react.tsx";
2
+ import { DataState } from "./data-state.tsx";
3
+ import { dictionaryDescriptor } from "../../../core/dictionary.ts";
4
+ import { objectSchemaShape } from "../../lib/object-schema.ts";
5
+ import {
6
+ canAccessPresentation,
7
+ meetsPresentationRequirement,
8
+ } from "../../../core/presentation.ts";
9
+ import {
10
+ PresentationSwitch,
11
+ presentationTextCell,
12
+ PresentationFormFields,
13
+ PresentationViewSections,
14
+ } from "./presentation-fields.tsx";
15
+ import { PageState } from "./page-state.tsx";
16
+ import {
17
+ Tabs,
18
+ TabsList,
19
+ TabsListItem,
20
+ TabsPanel,
21
+ } from "../primitives/tabs.tsx";
22
+ import {
23
+ PresentationSections,
24
+ type PresentationCompositionRuntime,
25
+ } from "./presentation-sections.tsx";
26
+ import { ArrowLeft, Braces, MessageCircle } from "lucide-react";
1
27
  import {
2
28
  useCallback,
3
29
  useEffect,
@@ -6,14 +32,19 @@ import {
6
32
  type ReactNode,
7
33
  } from "react";
8
34
  import { createPortal } from "react-dom";
9
- import { ArrowLeft, Braces, MessageCircle } from "lucide-react";
10
35
  import type {
11
- PresentationActionRegistry,
36
+ FormContract,
37
+ ListContract,
38
+ SimpleContract,
39
+ ViewContract,
40
+ } from "../../../core/contracts.ts";
41
+ import type {
12
42
  PresentationActionBody,
43
+ PresentationActionRegistry,
13
44
  PresentationBindingContext,
14
45
  PresentationCommand,
15
- PresentationDiagnostic,
16
46
  PresentationDefinition,
47
+ PresentationDiagnostic,
17
48
  PresentationInvocation,
18
49
  PresentationJsonValue,
19
50
  PresentationSurface,
@@ -22,22 +53,25 @@ import {
22
53
  applyPresentationEffects,
23
54
  createPresentationInspectionSnapshot,
24
55
  isPresentationActionBody,
56
+ isPresentationComponentBody,
57
+ isPresentationSectionsBody,
58
+ isPresentationTabsBody,
59
+ matchesPresentationConditions,
25
60
  openPresentation,
26
61
  resolvePresentationBindings,
27
62
  } from "../../../core/presentation.ts";
28
- import type {
29
- FormContract,
30
- ListContract,
31
- SimpleContract,
32
- ViewContract,
33
- } from "../../../core/contracts.ts";
34
63
  import { cn } from "../../lib/cn.ts";
64
+ import { ButtonGroup } from "../primitives/button-group.tsx";
35
65
  import {
36
66
  Button,
37
67
  buttonVariants,
38
68
  type ButtonSize,
39
69
  } from "../primitives/button.tsx";
40
- import { ButtonGroup } from "../primitives/button-group.tsx";
70
+ import {
71
+ Tooltip,
72
+ TooltipTrigger,
73
+ TooltipContent,
74
+ } from "../primitives/tooltip.tsx";
41
75
  import { Copyable } from "../primitives/copyable.tsx";
42
76
  import { DetailField, DetailGroup } from "../primitives/detail.tsx";
43
77
  import {
@@ -47,9 +81,10 @@ import {
47
81
  DialogContent,
48
82
  DialogFooter,
49
83
  DialogHeader,
50
- DialogIntro,
51
84
  DialogNavigation,
52
85
  DialogTitle,
86
+ DialogMeta,
87
+ DialogDescription,
53
88
  } from "../primitives/dialog.tsx";
54
89
  import {
55
90
  Drawer,
@@ -58,39 +93,38 @@ import {
58
93
  DrawerContent,
59
94
  DrawerFooter,
60
95
  DrawerHeader,
61
- DrawerIntro,
62
96
  DrawerNavigation,
63
97
  DrawerTitle,
98
+ DrawerMeta,
99
+ DrawerDescription,
64
100
  } from "../primitives/drawer.tsx";
101
+ import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
102
+ import { ActionForm } from "./form.tsx";
103
+ import { ActionList, type ActionListState } from "./list.tsx";
65
104
  import {
66
105
  Page,
106
+ PageBack,
67
107
  PageActions,
68
108
  PageBody,
69
109
  PageFooter,
70
110
  PageHeader,
71
- PageIntro,
72
- PageNavigation,
111
+ PageShellContribution,
73
112
  PageTitle,
74
- useInsidePageShell,
113
+ PageMeta,
114
+ PageDescription,
75
115
  } from "./page.tsx";
76
- import { ActionForm } from "./form.tsx";
77
- import { ActionList, type ActionListState } from "./list.tsx";
116
+ import { SurfaceAssistant } from "./surface-assistant.tsx";
78
117
  import { ActionTrigger } from "./trigger.tsx";
79
118
  import { ActionView } from "./view.tsx";
80
- import {
81
- Surface,
82
- SurfaceActions,
83
- SurfaceBody,
84
- SurfaceHeader,
85
- SurfaceTitle,
86
- } from "./surface.tsx";
87
- import { SurfaceAssistant } from "./surface-assistant.tsx";
88
- import { fillRegionClassName, useFillRegion } from "./fill-region.ts";
89
119
 
90
120
  interface PresentationFrameProps {
121
+ tabs?: ReactNode;
91
122
  surface: PresentationSurface;
92
123
  layout?: "flow" | "fill";
124
+ pageWidth?: "narrow" | "default" | "wide";
93
125
  title: ReactNode;
126
+ description?: ReactNode;
127
+ meta?: ReactNode;
94
128
  navigation?: ReactNode;
95
129
  headerActions?: ReactNode;
96
130
  footerActions?: ReactNode;
@@ -101,7 +135,7 @@ interface PresentationFrameProps {
101
135
  onOpenChange?: (open: boolean) => void;
102
136
  className?: string;
103
137
  bodyClassName?: string;
104
- listBody?: boolean;
138
+ bodyFlush?: boolean;
105
139
  contentOwnsHeading?: boolean;
106
140
  assistantOpen?: boolean;
107
141
  assistantLabel?: string;
@@ -118,12 +152,14 @@ const bodyClassName =
118
152
  */
119
153
  function PresentationBody({
120
154
  page = false,
155
+ ownsPage = false,
121
156
  layout,
122
157
  className,
123
158
  children,
124
159
  }: {
125
160
  layout: "flow" | "fill";
126
161
  page?: boolean;
162
+ ownsPage?: boolean;
127
163
  className?: string;
128
164
  children: ReactNode;
129
165
  }): ReactElement {
@@ -134,6 +170,8 @@ function PresentationBody({
134
170
  {...fill.attributes}
135
171
  className={cn(
136
172
  !page && bodyClassName,
173
+ page && (layout === "fill" || ownsPage) && "h-full",
174
+ ownsPage && "flex min-h-0 min-w-0 flex-1 flex-col",
137
175
  (layout === "fill" || fill.filling) && fillRegionClassName,
138
176
  className,
139
177
  )}
@@ -159,9 +197,13 @@ function PresentationActions({
159
197
 
160
198
  /** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
161
199
  function PresentationFrame({
200
+ tabs,
162
201
  surface,
163
202
  layout = "flow",
203
+ pageWidth,
164
204
  title,
205
+ description,
206
+ meta,
165
207
  navigation,
166
208
  headerActions,
167
209
  footerActions,
@@ -172,14 +214,14 @@ function PresentationFrame({
172
214
  onOpenChange,
173
215
  className,
174
216
  bodyClassName: bodyClassNameProp,
175
- listBody = false,
217
+ bodyFlush = false,
218
+
176
219
  contentOwnsHeading = false,
177
220
  assistantOpen = false,
178
221
  assistantLabel,
179
222
  assistantAction,
180
223
  assistantPanel,
181
224
  }: PresentationFrameProps): ReactElement {
182
- const insidePageShell = useInsidePageShell();
183
225
  const [footerTarget, setFooterTarget] = useState<HTMLElement | null>(null);
184
226
  const setFooterNode = useCallback((node: HTMLSpanElement | null) => {
185
227
  setFooterTarget(node);
@@ -202,6 +244,7 @@ function PresentationFrame({
202
244
  const body = (
203
245
  <PresentationBody
204
246
  page={surface === "page"}
247
+ ownsPage={surface === "page" && contentOwnsHeading}
205
248
  layout={layout}
206
249
  className={bodyClassNameProp}
207
250
  >
@@ -224,99 +267,49 @@ function PresentationFrame({
224
267
  );
225
268
 
226
269
  if (surface === "page") {
227
- if (listBody) {
228
- const content = (
229
- <Surface as="section" level={1}>
230
- <SurfaceHeader>
231
- <SurfaceTitle>{title}</SurfaceTitle>
232
- {headerActions === undefined ? null : (
233
- <SurfaceActions>
234
- <PresentationActions>{headerActions}</PresentationActions>
235
- </SurfaceActions>
236
- )}
237
- </SurfaceHeader>
238
- <SurfaceBody>{body}</SurfaceBody>
239
- </Surface>
240
- );
241
- return (
242
- <Page
243
- layout={layout}
244
- className={cn("flex min-h-full flex-col", className)}
245
- >
246
- {navigation === undefined && assistantAction === undefined ? null : (
247
- <PageHeader>
248
- {navigation === undefined ? null : (
249
- <PageNavigation>{navigation}</PageNavigation>
250
- )}
251
- {assistantAction === undefined ? null : (
252
- <PageActions>{assistantAction}</PageActions>
253
- )}
254
- </PageHeader>
255
- )}
256
- <PageBody>{withAssistant(content)}</PageBody>
257
- {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
258
- </Page>
259
- );
260
- }
261
- if (insidePageShell) {
270
+ if (contentOwnsHeading)
262
271
  return (
263
- <Page
264
- layout={layout}
265
- className={cn("flex min-h-full flex-col", className)}
272
+ <PageShellContribution
273
+ width={pageWidth}
274
+ navigation={navigation}
275
+ actions={
276
+ <>
277
+ {headerActions}
278
+ {assistantAction}
279
+ </>
280
+ }
281
+ footer={hasFooter ? renderFooterActions() : undefined}
266
282
  >
267
- {navigation === undefined &&
268
- contentOwnsHeading &&
269
- headerActions === undefined &&
270
- assistantAction === undefined ? null : (
271
- <PageHeader>
272
- {navigation === undefined ? null : (
273
- <PageNavigation>{navigation}</PageNavigation>
274
- )}
275
- {headerActions === undefined &&
276
- assistantAction === undefined ? null : (
277
- <PageActions>
278
- <PresentationActions>
279
- {headerActions}
280
- {assistantAction}
281
- </PresentationActions>
282
- </PageActions>
283
- )}
284
- </PageHeader>
285
- )}
286
- <PageBody>
287
- {contentOwnsHeading ? null : (
288
- <PageIntro>
289
- <PageTitle>{title}</PageTitle>
290
- </PageIntro>
283
+ <div
284
+ data-slot="presentation-page"
285
+ className={cn(
286
+ "flex h-full min-h-0 min-w-0 flex-1 flex-col",
287
+ className,
291
288
  )}
289
+ >
292
290
  {withAssistant(body)}
293
- </PageBody>
294
- {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
295
- </Page>
291
+ </div>
292
+ </PageShellContribution>
296
293
  );
297
- }
298
294
  return (
299
- <Page layout={layout} className={className} contentClassName="max-w-none">
300
- <PageHeader>
301
- {navigation === undefined ? null : (
302
- <PageNavigation>{navigation}</PageNavigation>
295
+ <Page layout={layout} width={pageWidth} className={className}>
296
+ <PageHeader tabs={tabs}>
297
+ {navigation}
298
+ <PageTitle>{title}</PageTitle>
299
+ {description != null && (
300
+ <PageDescription>{description}</PageDescription>
303
301
  )}
304
- {headerActions === undefined &&
302
+ {meta != null && <PageMeta>{meta}</PageMeta>}
303
+ {navigation === undefined &&
304
+ headerActions === undefined &&
305
305
  assistantAction === undefined ? null : (
306
306
  <PageActions>
307
- <PresentationActions>
308
- {headerActions}
309
- {assistantAction}
310
- </PresentationActions>
307
+ {headerActions}
308
+ {assistantAction}
311
309
  </PageActions>
312
310
  )}
313
311
  </PageHeader>
314
- <PageBody>
315
- <PageIntro>
316
- <PageTitle>{title}</PageTitle>
317
- </PageIntro>
318
- {withAssistant(body)}
319
- </PageBody>
312
+ <PageBody flush={bodyFlush}>{withAssistant(body)}</PageBody>
320
313
  {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
321
314
  </Page>
322
315
  );
@@ -335,7 +328,12 @@ function PresentationFrame({
335
328
  closeDisabled={blocked}
336
329
  >
337
330
  <div className="flex h-full min-h-0 flex-col">
338
- <DialogHeader>
331
+ <DialogHeader tabs={tabs}>
332
+ <DialogTitle>{title}</DialogTitle>
333
+ {description != null && (
334
+ <DialogDescription>{description}</DialogDescription>
335
+ )}
336
+ {meta != null && <DialogMeta>{meta}</DialogMeta>}
339
337
  {navigation === undefined ? null : (
340
338
  <DialogNavigation>{navigation}</DialogNavigation>
341
339
  )}
@@ -349,14 +347,7 @@ function PresentationFrame({
349
347
  </DialogActions>
350
348
  )}
351
349
  </DialogHeader>
352
- {withAssistant(
353
- <DialogBody>
354
- <DialogIntro>
355
- <DialogTitle>{title}</DialogTitle>
356
- </DialogIntro>
357
- {body}
358
- </DialogBody>,
359
- )}
350
+ {withAssistant(<DialogBody>{body}</DialogBody>)}
360
351
  {hasFooter ? (
361
352
  <DialogFooter>{renderFooterActions()}</DialogFooter>
362
353
  ) : null}
@@ -370,16 +361,21 @@ function PresentationFrame({
370
361
  <Drawer open={open} onOpenChange={onOpenChange}>
371
362
  <DrawerContent
372
363
  className={cn(
364
+ className,
373
365
  assistantOpen
374
366
  ? "w-[min(80rem,calc(100%-2rem))] max-w-none"
375
367
  : undefined,
376
- className,
377
368
  )}
378
369
  aria-describedby={undefined}
379
370
  closeDisabled={blocked}
380
371
  >
381
372
  <div className="flex h-full min-h-0 flex-col">
382
- <DrawerHeader>
373
+ <DrawerHeader tabs={tabs}>
374
+ <DrawerTitle>{title}</DrawerTitle>
375
+ {description != null && (
376
+ <DrawerDescription>{description}</DrawerDescription>
377
+ )}
378
+ {meta != null && <DrawerMeta>{meta}</DrawerMeta>}
383
379
  {navigation === undefined ? null : (
384
380
  <DrawerNavigation>{navigation}</DrawerNavigation>
385
381
  )}
@@ -393,14 +389,7 @@ function PresentationFrame({
393
389
  </DrawerActions>
394
390
  )}
395
391
  </DrawerHeader>
396
- {withAssistant(
397
- <DrawerBody>
398
- <DrawerIntro>
399
- <DrawerTitle>{title}</DrawerTitle>
400
- </DrawerIntro>
401
- {body}
402
- </DrawerBody>,
403
- )}
392
+ {withAssistant(<DrawerBody>{body}</DrawerBody>)}
404
393
  {hasFooter ? (
405
394
  <DrawerFooter>{renderFooterActions()}</DrawerFooter>
406
395
  ) : null}
@@ -412,10 +401,20 @@ function PresentationFrame({
412
401
 
413
402
  export interface PresentationAssistantRuntime {
414
403
  icon?: ReactNode;
415
- render: (controls: { close: () => void }) => ReactNode;
404
+ render: (controls: {
405
+ close: () => void;
406
+ context: PresentationBindingContext;
407
+ }) => ReactNode;
408
+ trigger?: (controls: {
409
+ open: boolean;
410
+ onOpenChange: (open: boolean) => void;
411
+ context: PresentationBindingContext;
412
+ }) => ReactNode;
416
413
  }
417
414
 
418
415
  export interface PresentationProps {
416
+ embedded?: boolean;
417
+ can?: (permission: string) => boolean;
419
418
  /** Definição estática registrada e publicada no manifest. */
420
419
  definition: PresentationDefinition;
421
420
  /** Definições alcançáveis por open, navigate ou back. */
@@ -439,6 +438,8 @@ export interface PresentationProps {
439
438
  bodyClassName?: string;
440
439
  /** `fill` limita a página ao shell e delega a rolagem ao conteúdo React. */
441
440
  layout?: "flow" | "fill";
441
+ /** Extensões nomeadas da composição e da lista; não fazem parte do JSON. */
442
+ compositionRuntime?: PresentationCompositionRuntime;
442
443
  /** Conteúdo React da boundary declarada por `body.component`. */
443
444
  children?: ReactNode;
444
445
  /** Painel runtime; a página fecha sobre os dados do recurso que ela carregou. */
@@ -465,6 +466,7 @@ function readField(record: unknown, key: string): unknown {
465
466
 
466
467
  function PresentationCommandTrigger({
467
468
  command,
469
+ icon,
468
470
  action,
469
471
  input,
470
472
  size,
@@ -473,6 +475,7 @@ function PresentationCommandTrigger({
473
475
  onSuccess,
474
476
  }: {
475
477
  command: PresentationCommand;
478
+ icon?: ReactNode;
476
479
  action: SimpleContract<Record<string, unknown>, unknown>;
477
480
  input: Record<string, unknown>;
478
481
  size: ButtonSize;
@@ -488,17 +491,200 @@ function PresentationCommandTrigger({
488
491
  <ActionTrigger
489
492
  action={action}
490
493
  input={input}
494
+ label={icon ? commandAccessibleLabel(command, action) : command.label}
495
+ icon={icon}
496
+ confirm={command.confirm}
497
+ aria-label={commandAccessibleLabel(command, action)}
491
498
  variant="ghost"
492
- size={size}
499
+ size={icon ? "icon" : size}
493
500
  disabled={disabled}
494
501
  onLoadingChange={reportLoading}
502
+ successMessage={command.successMessage}
495
503
  onSuccess={onSuccess}
496
504
  />
497
505
  );
498
506
  }
499
507
 
508
+ /** Mantém o recurso no nome acessível e preserva o texto visível de uma ação contextual. */
509
+ function commandAccessibleLabel(
510
+ command: PresentationCommand,
511
+ action: Parameters<typeof actionLabel>[0],
512
+ ): string | undefined {
513
+ if (command.accessibleLabel !== undefined) return command.accessibleLabel;
514
+ if (command.label === undefined) return undefined;
515
+ const fullLabel = actionLabel(action);
516
+ return fullLabel.includes(command.label)
517
+ ? fullLabel
518
+ : `${command.label}: ${fullLabel}`;
519
+ }
520
+
500
521
  /** Renderiza uma definição declarativa delegando cada kind ao pattern Opus correspondente. */
501
- export function Presentation({
522
+ export function Presentation(props: PresentationProps): ReactElement {
523
+ if (props.compositionRuntime?.state != null)
524
+ return <PresentationContent {...props} />;
525
+ if (
526
+ props.definition.externalSource &&
527
+ canAccessPresentation(props.definition, props.actions, props.can)
528
+ )
529
+ return <PresentationExternalSource {...props} />;
530
+ if (
531
+ props.definition.source &&
532
+ canAccessPresentation(props.definition, props.actions, props.can)
533
+ )
534
+ return props.definition.source.record ? (
535
+ <PresentationListSource {...props} />
536
+ ) : (
537
+ <PresentationSource {...props} />
538
+ );
539
+ return <PresentationContent {...props} />;
540
+ }
541
+
542
+ function PresentationExternalSource(props: PresentationProps): ReactElement {
543
+ const source =
544
+ props.compositionRuntime?.recordSources?.[props.definition.externalSource!];
545
+ if (!source) throw new Error("Fonte externa da apresentação não registrada.");
546
+ const [attempt, retry] = useState(0);
547
+ const [state, setState] = useState<{
548
+ key: string;
549
+ source: typeof source;
550
+ data?: Record<string, unknown>;
551
+ error?: Error;
552
+ }>();
553
+ const input = props.bindingContext?.route ?? props.invocation.input;
554
+ const key = JSON.stringify([props.definition.externalSource, input, attempt]);
555
+ useEffect(() => {
556
+ const controller = new AbortController();
557
+ void source
558
+ .load(JSON.parse(JSON.stringify(input)), { signal: controller.signal })
559
+ .then(
560
+ (data) => {
561
+ if (!controller.signal.aborted) setState({ key, source, data });
562
+ },
563
+ () => {
564
+ if (!controller.signal.aborted)
565
+ setState({
566
+ key,
567
+ source,
568
+ error: new Error("Não foi possível carregar os dados."),
569
+ });
570
+ },
571
+ );
572
+ return () => controller.abort();
573
+ }, [source, key]);
574
+ const current =
575
+ state?.key === key && state.source === source ? state : undefined;
576
+ return (
577
+ <PresentationContent
578
+ {...props}
579
+ bindingContext={{ ...props.bindingContext, record: current?.data }}
580
+ compositionRuntime={{
581
+ ...props.compositionRuntime,
582
+ state: current?.data ? (
583
+ props.compositionRuntime?.state
584
+ ) : (
585
+ <DataState
586
+ loading={!current}
587
+ error={current?.error}
588
+ errorMessage="Não foi possível carregar os dados."
589
+ onRetry={() => retry((value) => value + 1)}
590
+ >
591
+ {null}
592
+ </DataState>
593
+ ),
594
+ }}
595
+ />
596
+ );
597
+ }
598
+
599
+ function PresentationListSource(props: PresentationProps): ReactElement {
600
+ const source = props.definition.source!;
601
+ const action = props.actions[source.action];
602
+ if (action?.kind !== "list")
603
+ throw new Error(`Seleção de registro exige action list: ${source.action}.`);
604
+ const route = props.bindingContext?.route ?? props.invocation.input;
605
+ const input = resolvePresentationBindings(source.input, {
606
+ ...props.bindingContext,
607
+ route,
608
+ });
609
+ const result = useListAction<Record<string, PresentationJsonValue>>(
610
+ action,
611
+ input,
612
+ );
613
+ const record = result.items.find(
614
+ (item) => item[source.record!.field] === route[source.record!.param],
615
+ );
616
+ return (
617
+ <PresentationContent
618
+ {...props}
619
+ bindingContext={{ ...props.bindingContext, record }}
620
+ compositionRuntime={{
621
+ ...props.compositionRuntime,
622
+ state:
623
+ (result.isLoading && !record) || result.error || !record ? (
624
+ <DataState
625
+ loading={result.isLoading}
626
+ error={result.error ?? null}
627
+ empty={!record}
628
+ emptyMessage="Item não encontrado neste recorte."
629
+ errorMessage="Não foi possível carregar o item."
630
+ onRetry={result.refetch}
631
+ >
632
+ {null}
633
+ </DataState>
634
+ ) : (
635
+ props.compositionRuntime?.state
636
+ ),
637
+ }}
638
+ />
639
+ );
640
+ }
641
+
642
+ function PresentationSource(props: PresentationProps): ReactElement {
643
+ const source = props.definition.source!;
644
+ const action = props.actions[source.action];
645
+ if (action?.kind !== "view")
646
+ throw new Error(`Fonte de registro exige action view: ${source.action}.`);
647
+ const input = resolvePresentationBindings(source.input, {
648
+ ...props.bindingContext,
649
+ route: props.bindingContext?.route ?? props.invocation.input,
650
+ });
651
+ const result = useViewAction<
652
+ Record<string, unknown>,
653
+ Record<string, PresentationJsonValue>
654
+ >(action, input);
655
+ return (
656
+ <PresentationContent
657
+ {...props}
658
+ bindingContext={{ ...props.bindingContext, record: result.data }}
659
+ compositionRuntime={{
660
+ ...props.compositionRuntime,
661
+ state:
662
+ (result.isLoading && !result.data) ||
663
+ (result.error && !source.retainOnError) ||
664
+ !result.data ? (
665
+ <DataState
666
+ loading={result.isLoading}
667
+ error={result.error ?? null}
668
+ empty={!result.data}
669
+ emptyMessage="Conteúdo indisponível."
670
+ errorMessage={
671
+ source.errorMessage ?? "Não foi possível carregar o conteúdo."
672
+ }
673
+ onRetry={result.refetch}
674
+ >
675
+ {null}
676
+ </DataState>
677
+ ) : (
678
+ props.compositionRuntime?.state
679
+ ),
680
+ }}
681
+ />
682
+ );
683
+ }
684
+
685
+ function PresentationContent({
686
+ embedded = false,
687
+ can,
502
688
  definition,
503
689
  definitions,
504
690
  actions,
@@ -512,10 +698,53 @@ export function Presentation({
512
698
  onOpenChange,
513
699
  className,
514
700
  bodyClassName: bodyClassNameProp,
515
- layout = "flow",
701
+ layout: layoutProp,
702
+ compositionRuntime,
516
703
  children,
517
704
  assistant,
518
705
  }: PresentationProps): ReactElement {
706
+ const layout = layoutProp ?? definition.page?.layout ?? "flow";
707
+ const [asideOpen, setAsideOpen] = useState(true);
708
+ const boundSlots = Object.fromEntries(
709
+ Object.entries(compositionRuntime?.components ?? {}).map(
710
+ ([id, adapter]) => [
711
+ id,
712
+ compositionRuntime?.state != null ||
713
+ !canAccessPresentation(definition, actions, can) ||
714
+ (adapter.action !== undefined &&
715
+ (!actions[adapter.action] ||
716
+ !meetsPresentationRequirement(
717
+ actions[adapter.action]?.requires,
718
+ can,
719
+ )))
720
+ ? null
721
+ : adapter.render({
722
+ input: invocation.input,
723
+ context: {
724
+ ...bindingContext,
725
+ route: bindingContext?.route ?? invocation.input,
726
+ },
727
+ onClose: () => onInvocationChange?.(null),
728
+ asideOpen,
729
+ onAsideOpenChange: setAsideOpen,
730
+ }),
731
+ ],
732
+ ),
733
+ );
734
+ compositionRuntime = {
735
+ ...compositionRuntime,
736
+ asideOpen: compositionRuntime?.asideOpen ?? asideOpen,
737
+ slots: { ...compositionRuntime?.slots, ...boundSlots },
738
+ };
739
+ const headerSlot = (id: string | undefined): ReactNode => {
740
+ if (!canAccessPresentation(definition, actions, can)) return null;
741
+ if (id === undefined || compositionRuntime?.state != null) return undefined;
742
+ if (!Object.hasOwn(compositionRuntime?.slots ?? {}, id))
743
+ throw new Error(
744
+ `Slot “${id}” do cabeçalho da Presentation não registrado.`,
745
+ );
746
+ return compositionRuntime!.slots![id];
747
+ };
519
748
  if (definition.id !== invocation.presentationId) {
520
749
  throw new Error(
521
750
  `A invocação “${invocation.presentationId}” não corresponde à Presentation “${definition.id}”.`,
@@ -525,6 +754,52 @@ export function Presentation({
525
754
  ReadonlySet<PresentationCommand>
526
755
  >(() => new Set());
527
756
  const [bodyLoading, setBodyLoading] = useState(false);
757
+ const [inspected, setInspected] = useState<{
758
+ presentation: string;
759
+ item: Record<string, unknown>;
760
+ }>();
761
+ const [localTab, setLocalTab] = useState<{
762
+ presentation: string;
763
+ value: string;
764
+ }>();
765
+ const tabsBody = isPresentationTabsBody(definition.body)
766
+ ? definition.body
767
+ : null;
768
+ const tabValue =
769
+ compositionRuntime?.tabs?.value ??
770
+ (localTab?.presentation === definition.id &&
771
+ tabsBody?.tabs.some((tab) => tab.id === localTab.value)
772
+ ? localTab.value
773
+ : tabsBody?.defaultTab);
774
+ if (tabsBody !== null) {
775
+ if (!tabsBody.tabs.some((tab) => tab.id === tabValue))
776
+ throw new Error("A aba selecionada não existe na Presentation.");
777
+ for (const tab of tabsBody.tabs) {
778
+ if (
779
+ tab.presentation !== undefined &&
780
+ !Object.hasOwn(
781
+ compositionRuntime?.presentations ?? {},
782
+ tab.presentation,
783
+ )
784
+ )
785
+ throw new Error(
786
+ `Presentation embutida “${tab.presentation}” não registrada.`,
787
+ );
788
+ if (
789
+ tab.content !== undefined &&
790
+ !Object.hasOwn(compositionRuntime?.slots ?? {}, tab.content)
791
+ )
792
+ throw new Error(
793
+ `Slot “${tab.content}” da Presentation não registrado.`,
794
+ );
795
+ if (
796
+ tab.icon !== undefined &&
797
+ (!Object.hasOwn(compositionRuntime?.icons ?? {}, tab.icon) ||
798
+ compositionRuntime?.icons?.[tab.icon] == null)
799
+ )
800
+ throw new Error(`Ícone “${tab.icon}” da Presentation não registrado.`);
801
+ }
802
+ }
528
803
  const [assistantOpen, setAssistantOpen] = useState(false);
529
804
  useEffect(() => {
530
805
  setAssistantOpen(false);
@@ -535,12 +810,16 @@ export function Presentation({
535
810
  };
536
811
 
537
812
  const applyEffects = useCallback(
538
- (effects: PresentationActionBody["onSuccess"], result?: unknown): void => {
813
+ (
814
+ effects: PresentationActionBody["onSuccess"],
815
+ result?: unknown,
816
+ effectContext: PresentationBindingContext = context,
817
+ ): void => {
539
818
  const next = applyPresentationEffects(
540
819
  invocation,
541
820
  effects,
542
821
  {
543
- ...context,
822
+ ...effectContext,
544
823
  ...(result !== undefined &&
545
824
  result !== null &&
546
825
  typeof result === "object"
@@ -622,12 +901,21 @@ export function Presentation({
622
901
  string,
623
902
  unknown
624
903
  >;
904
+ const icon = command.icon
905
+ ? compositionRuntime?.icons?.[command.icon]
906
+ : undefined;
907
+ if (command.icon && icon == null) {
908
+ throw new Error(
909
+ `Ícone “${command.icon}” da Presentation não registrado.`,
910
+ );
911
+ }
625
912
  // A região chamadora escolhe a escala; o footer conserva o botão padrão.
626
- if (action.kind === "simple") {
913
+ if (action.kind === "simple" && command.target === undefined) {
627
914
  return (
628
915
  <PresentationCommandTrigger
629
- key={`${command.placement}:${command.action}`}
916
+ key={`${command.placement}:${command.id ?? command.action}`}
630
917
  command={command}
918
+ icon={icon}
631
919
  action={action as SimpleContract<Record<string, unknown>, unknown>}
632
920
  input={input}
633
921
  size={size}
@@ -642,11 +930,21 @@ export function Presentation({
642
930
  `A action “${command.action}” precisa declarar uma Presentation de destino.`,
643
931
  );
644
932
  }
645
- const label = actionLabel(action);
646
- return (
933
+ const label = command.label ?? actionLabel(action);
934
+ const accessibleLabel = commandAccessibleLabel(command, action) ?? label;
935
+ const button = (
647
936
  <Button
648
- key={`${command.placement}:${command.action}`}
649
- size={size}
937
+ key={`${command.placement}:${command.id ?? command.action}`}
938
+ size={command.icon ? "icon" : size}
939
+ icon={icon}
940
+ variant={
941
+ command.icon
942
+ ? "ghost"
943
+ : command.placement === "section"
944
+ ? "ghost"
945
+ : "solid"
946
+ }
947
+ aria-label={accessibleLabel}
650
948
  disabled={isBlocked(command)}
651
949
  onClick={() =>
652
950
  openTarget(
@@ -656,9 +954,17 @@ export function Presentation({
656
954
  )
657
955
  }
658
956
  >
659
- {label}
957
+ {command.icon ? null : label}
660
958
  </Button>
661
959
  );
960
+ return icon ? (
961
+ <Tooltip key={`${command.placement}:${command.id ?? command.action}`}>
962
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
963
+ <TooltipContent>{accessibleLabel}</TooltipContent>
964
+ </Tooltip>
965
+ ) : (
966
+ button
967
+ );
662
968
  };
663
969
 
664
970
  const actionBody = isPresentationActionBody(definition.body)
@@ -668,14 +974,31 @@ export function Presentation({
668
974
  if (actionBody !== null && bodyAction === undefined) {
669
975
  throw new Error(`Action “${actionBody.action}” não registrada.`);
670
976
  }
671
- if (actionBody === null && children === undefined) {
977
+ const componentBody = isPresentationComponentBody(definition.body)
978
+ ? definition.body
979
+ : null;
980
+ const componentAdapter = componentBody
981
+ ? compositionRuntime?.components?.[componentBody.component]
982
+ : undefined;
983
+ if (
984
+ componentBody?.contract &&
985
+ componentAdapter?.action !== componentBody.contract
986
+ )
987
+ throw new Error(
988
+ `Componente “${componentBody.component}” ausente ou incompatível com o contrato.`,
989
+ );
990
+ if (
991
+ isPresentationComponentBody(definition.body) &&
992
+ children === undefined &&
993
+ !componentAdapter
994
+ ) {
672
995
  throw new Error(
673
996
  `A Presentation “${definition.id}” declara body.component e exige children.`,
674
997
  );
675
998
  }
676
- if (actionBody !== null && children !== undefined) {
999
+ if (!isPresentationComponentBody(definition.body) && children !== undefined) {
677
1000
  throw new Error(
678
- `A Presentation “${definition.id}” usa body.action e não aceita children.`,
1001
+ `A Presentation “${definition.id}” não usa body.component e não aceita children.`,
679
1002
  );
680
1003
  }
681
1004
  if (definition.assistant !== undefined && assistant === undefined) {
@@ -688,56 +1011,499 @@ export function Presentation({
688
1011
  `A Presentation “${definition.id}” não declara assistência e não aceita a prop assistant.`,
689
1012
  );
690
1013
  }
1014
+ const listContentAdapter =
1015
+ actionBody?.beforeContent === undefined
1016
+ ? undefined
1017
+ : compositionRuntime?.listContent?.[actionBody.beforeContent];
1018
+ if (
1019
+ actionBody?.beforeContent !== undefined &&
1020
+ (bodyAction?.kind !== "list" ||
1021
+ !Object.hasOwn(
1022
+ compositionRuntime?.listContent ?? {},
1023
+ actionBody.beforeContent,
1024
+ ) ||
1025
+ listContentAdapter?.action !== actionBody.action)
1026
+ )
1027
+ throw new Error(
1028
+ `Conteúdo de lista “${actionBody.beforeContent}” ausente ou incompatível com “${actionBody.action}”.`,
1029
+ );
1030
+ const collectionAdapter = actionBody?.collection
1031
+ ? compositionRuntime?.collections?.[actionBody.collection]
1032
+ : undefined;
1033
+ const rowContentAdapter =
1034
+ actionBody?.rowContent === undefined
1035
+ ? undefined
1036
+ : compositionRuntime?.rowContent?.[actionBody.rowContent];
1037
+ const toolbarContentAdapter =
1038
+ actionBody?.toolbarContent === undefined
1039
+ ? undefined
1040
+ : compositionRuntime?.listContent?.[actionBody.toolbarContent];
1041
+ for (const [id, adapter] of [
1042
+ [actionBody?.rowContent, rowContentAdapter],
1043
+ [actionBody?.collection, collectionAdapter],
1044
+ [actionBody?.toolbarContent, toolbarContentAdapter],
1045
+ ] as const) {
1046
+ if (
1047
+ id !== undefined &&
1048
+ (bodyAction?.kind !== "list" || adapter?.action !== actionBody?.action)
1049
+ )
1050
+ throw new Error(
1051
+ `Conteúdo de lista “${id}” ausente ou incompatível com “${actionBody?.action}”.`,
1052
+ );
1053
+ }
1054
+ const cellsAdapter =
1055
+ actionBody?.cells === undefined
1056
+ ? undefined
1057
+ : compositionRuntime?.cells?.[actionBody.cells];
1058
+ if (
1059
+ actionBody?.cells !== undefined &&
1060
+ (!Object.hasOwn(compositionRuntime?.cells ?? {}, actionBody.cells) ||
1061
+ cellsAdapter?.action !== actionBody.action)
1062
+ ) {
1063
+ throw new Error(
1064
+ `Adaptador de células “${actionBody.cells}” ausente ou incompatível com “${actionBody.action}”.`,
1065
+ );
1066
+ }
1067
+ for (const [field, id] of Object.entries(actionBody?.fieldComponents ?? {})) {
1068
+ const adapter = compositionRuntime?.fields?.[id];
1069
+ if (
1070
+ !Object.hasOwn(compositionRuntime?.fields ?? {}, id) ||
1071
+ adapter?.action !== actionBody?.action ||
1072
+ adapter?.field !== field
1073
+ )
1074
+ throw new Error(
1075
+ `Componente de campo “${id}” ausente ou incompatível com “${actionBody?.action}.${field}”.`,
1076
+ );
1077
+ }
1078
+ for (const lookup of Object.values(actionBody?.lookups ?? {})) {
1079
+ if (
1080
+ lookup.format !== undefined &&
1081
+ (!Object.hasOwn(compositionRuntime?.options ?? {}, lookup.format) ||
1082
+ compositionRuntime?.options?.[lookup.format]?.action !== lookup.action)
1083
+ )
1084
+ throw new Error(
1085
+ `Formatador de opções “${lookup.format}” ausente ou incompatível com “${lookup.action}”.`,
1086
+ );
1087
+ }
1088
+ const inspection = actionBody?.inspect;
1089
+ const detailAdapter =
1090
+ inspection === undefined
1091
+ ? undefined
1092
+ : compositionRuntime?.details?.[inspection.component];
1093
+ if (
1094
+ inspection &&
1095
+ (!Object.hasOwn(compositionRuntime?.details ?? {}, inspection.component) ||
1096
+ detailAdapter?.action !== actionBody?.action)
1097
+ )
1098
+ throw new Error(
1099
+ `Adaptador de detalhe “${inspection.component}” ausente ou incompatível com “${actionBody?.action}”.`,
1100
+ );
1101
+ if (
1102
+ inspection &&
1103
+ (actionBody?.open || actionBody?.opens || bodyAction?.kind !== "list")
1104
+ )
1105
+ throw new Error(
1106
+ "Inspect exige list e não pode coexistir com open ou opens.",
1107
+ );
1108
+ const canInspect = (item: Record<string, unknown>): boolean =>
1109
+ inspection !== undefined &&
1110
+ meetsPresentationRequirement(inspection.requires, can) &&
1111
+ matchesPresentationConditions(inspection.when, item);
1112
+ const canOpenPresentation = (id: string): boolean => {
1113
+ const target = definitions.find((candidate) => candidate.id === id);
1114
+ return target !== undefined && canAccessPresentation(target, actions, can);
1115
+ };
1116
+ const canUseCommand = (command: PresentationCommand): boolean => {
1117
+ const action = actions[command.action];
1118
+ return (
1119
+ action !== undefined &&
1120
+ meetsPresentationRequirement(command.requires, can) &&
1121
+ meetsPresentationRequirement(action.requires, can) &&
1122
+ (command.target === undefined ||
1123
+ canOpenPresentation(command.target.presentation))
1124
+ );
1125
+ };
1126
+ const rowCommands = definition.actions.filter(
1127
+ (command) => command.placement === "row",
1128
+ );
1129
+ for (const command of rowCommands) {
1130
+ if (bodyAction?.kind !== "list" || command.blocking !== "action")
1131
+ throw new Error("Comando de linha exige lista e bloqueio por ação.");
1132
+ if (
1133
+ command.icon !== undefined &&
1134
+ (!Object.hasOwn(compositionRuntime?.icons ?? {}, command.icon) ||
1135
+ compositionRuntime?.icons?.[command.icon] == null)
1136
+ ) {
1137
+ throw new Error(
1138
+ `Ícone “${command.icon}” da Presentation não registrado.`,
1139
+ );
1140
+ }
1141
+ }
1142
+ const renderRowCommand = (
1143
+ command: PresentationCommand,
1144
+ item: Record<string, unknown>,
1145
+ ): ReactNode => {
1146
+ if (
1147
+ !canUseCommand(command) ||
1148
+ !matchesPresentationConditions(command.when, item)
1149
+ )
1150
+ return null;
1151
+ const action = actions[command.action];
1152
+ if (action === undefined)
1153
+ throw new Error(`Action “${command.action}” não registrada.`);
1154
+ const rowContext = { ...context, item };
1155
+ const input = resolvePresentationBindings(command.input, rowContext);
1156
+ const label = command.label ?? actionLabel(action);
1157
+ const value =
1158
+ command.itemLabel === undefined ? undefined : item[command.itemLabel];
1159
+ // O nome acessível usa o vocabulário do contrato, inclusive quando o item guarda um código.
1160
+ const labelSchema =
1161
+ command.itemLabel === undefined
1162
+ ? undefined
1163
+ : objectSchemaShape(bodyAction?.output)?.[command.itemLabel];
1164
+ const itemLabel =
1165
+ typeof value === "string" || typeof value === "number"
1166
+ ? (dictionaryDescriptor(labelSchema)?.entries[String(value)]?.label ??
1167
+ String(value))
1168
+ : undefined;
1169
+ const accessibleLabel =
1170
+ itemLabel === undefined
1171
+ ? (commandAccessibleLabel(command, action) ?? label)
1172
+ : `${label} ${itemLabel}`;
1173
+ const icon =
1174
+ command.icon === undefined
1175
+ ? undefined
1176
+ : compositionRuntime?.icons?.[command.icon];
1177
+ if (action.kind === "simple" && command.target === undefined)
1178
+ return (
1179
+ <ActionTrigger
1180
+ key={command.id ?? command.action}
1181
+ action={action as SimpleContract<Record<string, unknown>, unknown>}
1182
+ input={input}
1183
+ label={label}
1184
+ aria-label={accessibleLabel}
1185
+ icon={icon}
1186
+ itemLabel={itemLabel}
1187
+ confirm={command.confirm}
1188
+ disabled={surfaceBlocked}
1189
+ successMessage={command.successMessage}
1190
+ onSuccess={(result) => {
1191
+ applyEffects(command.onSuccess, result, rowContext);
1192
+ }}
1193
+ />
1194
+ );
1195
+ if (command.target === undefined)
1196
+ throw new Error(
1197
+ `A action “${command.action}” precisa declarar uma Presentation de destino.`,
1198
+ );
1199
+ const button = (
1200
+ <Button
1201
+ variant="ghost"
1202
+ size={icon === undefined ? "default" : "icon-xs"}
1203
+ aria-label={accessibleLabel}
1204
+ disabled={surfaceBlocked}
1205
+ className={
1206
+ icon === undefined ? undefined : "shrink-0 text-muted-foreground/60"
1207
+ }
1208
+ onClick={(event) => {
1209
+ event.stopPropagation();
1210
+ openTarget(
1211
+ command.target!.presentation,
1212
+ command.target!.surface,
1213
+ command.input,
1214
+ rowContext,
1215
+ );
1216
+ }}
1217
+ >
1218
+ {icon ?? label}
1219
+ </Button>
1220
+ );
1221
+ return icon === undefined ? (
1222
+ <span
1223
+ key={`${command.id ?? command.action}:${command.target?.presentation ?? "execute"}`}
1224
+ >
1225
+ {button}
1226
+ </span>
1227
+ ) : (
1228
+ <Tooltip
1229
+ key={`${command.id ?? command.action}:${command.target?.presentation ?? "execute"}`}
1230
+ >
1231
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
1232
+ <TooltipContent>{label}</TooltipContent>
1233
+ </Tooltip>
1234
+ );
1235
+ };
691
1236
  const bodyInput =
692
- actionBody === null
1237
+ actionBody === null ||
1238
+ compositionRuntime?.state != null ||
1239
+ !canAccessPresentation(definition, actions, can)
693
1240
  ? {}
694
1241
  : (resolvePresentationBindings(actionBody.input, context) as Record<
695
1242
  string,
696
1243
  unknown
697
1244
  >);
1245
+ const renderLink = (
1246
+ link: PresentationDefinition["links"][number],
1247
+ ): ReactNode => {
1248
+ const target = definitions.find((item) => item.id === link.presentation);
1249
+ if (!target?.route || !canAccessPresentation(target, actions, can))
1250
+ return null;
1251
+ const icon = link.icon ? compositionRuntime?.icons?.[link.icon] : undefined;
1252
+ if (link.icon && icon == null) {
1253
+ throw new Error(`Ícone “${link.icon}” da Presentation não registrado.`);
1254
+ }
1255
+ const iconOnly = icon != null && link.showLabel !== true;
1256
+ const button = (
1257
+ <Button
1258
+ key={link.id}
1259
+ variant={link.placement === "section" || icon ? "ghost" : "outline"}
1260
+ size={iconOnly ? "icon" : "default"}
1261
+ icon={icon}
1262
+ aria-label={link.label}
1263
+ disabled={surfaceBlocked}
1264
+ onClick={() =>
1265
+ onInvocationChange?.(
1266
+ openPresentation(invocation, {
1267
+ presentationId: target.id,
1268
+ surface: target.route!.surface,
1269
+ input: resolvePresentationBindings(link.input, context) as Record<
1270
+ string,
1271
+ PresentationJsonValue
1272
+ >,
1273
+ }),
1274
+ )
1275
+ }
1276
+ >
1277
+ {iconOnly ? null : link.label}
1278
+ </Button>
1279
+ );
1280
+ return iconOnly ? (
1281
+ <Tooltip key={link.id}>
1282
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
1283
+ <TooltipContent>{link.label}</TooltipContent>
1284
+ </Tooltip>
1285
+ ) : (
1286
+ button
1287
+ );
1288
+ };
698
1289
  const renderBody = (footerTarget: HTMLElement | null): ReactNode => {
699
- if (actionBody === null) return children;
1290
+ if (!canAccessPresentation(definition, actions, can))
1291
+ return <PageState status="empty" title="Sem acesso a este conteúdo" />;
1292
+ if (compositionRuntime?.state != null) return compositionRuntime.state;
1293
+ if (tabsBody !== null)
1294
+ return tabsBody.tabs.map((tab) => (
1295
+ <TabsPanel key={tab.id} value={tab.id} className="min-w-0">
1296
+ {tab.presentation === undefined
1297
+ ? compositionRuntime!.slots![tab.content!]
1298
+ : compositionRuntime!.presentations![tab.presentation]}
1299
+ </TabsPanel>
1300
+ ));
1301
+ if (isPresentationSectionsBody(definition.body)) {
1302
+ return (
1303
+ <PresentationSections
1304
+ body={definition.body}
1305
+ can={can}
1306
+ context={context}
1307
+ runtime={{
1308
+ ...compositionRuntime,
1309
+ slots: {
1310
+ ...compositionRuntime?.slots,
1311
+ ...Object.fromEntries(
1312
+ definition.links
1313
+ .filter((link) => link.placement === "section")
1314
+ .map((link) => [link.id, renderLink(link)]),
1315
+ ),
1316
+ ...Object.fromEntries(
1317
+ definition.actions
1318
+ .filter((command) => command.placement === "section")
1319
+ .map((command) => [
1320
+ command.id ?? command.action,
1321
+ canUseCommand(command) &&
1322
+ matchesPresentationConditions(
1323
+ command.when,
1324
+ context.record ?? {},
1325
+ )
1326
+ ? renderCommand(command)
1327
+ : null,
1328
+ ]),
1329
+ ),
1330
+ },
1331
+ }}
1332
+ />
1333
+ );
1334
+ }
1335
+ if (actionBody === null)
1336
+ return componentAdapter && componentBody
1337
+ ? componentAdapter.render({
1338
+ input: resolvePresentationBindings(
1339
+ componentBody.input ?? {},
1340
+ context,
1341
+ ),
1342
+ context,
1343
+ onClose: closeSurface,
1344
+ footerTarget,
1345
+ onBusyChange: setBodyLoading,
1346
+ })
1347
+ : children;
700
1348
  if (bodyAction === null) return null;
701
1349
  if (bodyAction.kind === "form") {
702
1350
  return (
703
1351
  <ActionForm
1352
+ key={`${definition.id}:${JSON.stringify(invocation.input)}`}
704
1353
  action={bodyAction as FormContract<Record<string, unknown>, unknown>}
705
- defaultValues={bodyInput}
1354
+ defaultValues={
1355
+ actionBody.defaultsFromRecord
1356
+ ? { ...actionBody.initialValues, ...context.record, ...bodyInput }
1357
+ : { ...actionBody.initialValues, ...bodyInput }
1358
+ }
706
1359
  submitLabel={actionBody.submitLabel}
707
1360
  onCancel={invocation.surface === "page" ? undefined : closeSurface}
708
1361
  onSuccess={(data) => applyEffects(actionBody.onSuccess, data)}
709
1362
  disabled={hasSurfaceBlock}
710
1363
  onLoadingChange={setBodyLoading}
711
- footer={(actions) =>
712
- footerTarget === null ? null : createPortal(actions, footerTarget)
1364
+ footer={
1365
+ embedded && invocation.surface === "page"
1366
+ ? undefined
1367
+ : (actions) =>
1368
+ footerTarget === null
1369
+ ? null
1370
+ : createPortal(actions, footerTarget)
713
1371
  }
714
- />
1372
+ >
1373
+ {actionBody.lookups !== undefined ||
1374
+ actionBody.formFields !== undefined ||
1375
+ actionBody.formGroups !== undefined ||
1376
+ actionBody.fieldComponents !== undefined ? (
1377
+ <PresentationFormFields
1378
+ body={actionBody}
1379
+ action={bodyAction}
1380
+ actions={actions}
1381
+ runtime={compositionRuntime}
1382
+ context={context}
1383
+ can={can}
1384
+ />
1385
+ ) : undefined}
1386
+ </ActionForm>
715
1387
  );
716
1388
  }
717
1389
  if (bodyAction.kind === "list") {
1390
+ const findTarget = (item: Record<string, unknown>) =>
1391
+ (actionBody.opens ?? (actionBody.open ? [actionBody.open] : [])).find(
1392
+ (open) =>
1393
+ meetsPresentationRequirement(open.requires, can) &&
1394
+ canOpenPresentation(open.presentation) &&
1395
+ matchesPresentationConditions(open.when, item),
1396
+ );
1397
+ const openItem = (item: Record<string, unknown>) => {
1398
+ if (inspection) {
1399
+ if (canInspect(item))
1400
+ setInspected({ presentation: definition.id, item });
1401
+ return;
1402
+ }
1403
+ const target = findTarget(item);
1404
+ if (target)
1405
+ openTarget(target.presentation, target.surface, target.input, {
1406
+ ...context,
1407
+ item,
1408
+ });
1409
+ };
1410
+ const renderItemActions = (item: Record<string, unknown>) => (
1411
+ <>
1412
+ {rowContentAdapter?.render(item)}
1413
+ {rowCommands.map((command) => renderRowCommand(command, item))}
1414
+ </>
1415
+ );
718
1416
  return (
719
- <ActionList<Record<string, unknown>, Record<string, unknown>>
720
- action={
721
- bodyAction as ListContract<
722
- Record<string, unknown>,
723
- Record<string, unknown>
724
- >
725
- }
726
- input={bodyInput}
727
- state={listState}
728
- onStateChange={onListStateChange}
729
- {...(actionBody.open === undefined
730
- ? {}
731
- : {
732
- onRowClick: (item: Record<string, unknown>) =>
733
- openTarget(
734
- actionBody.open!.presentation,
735
- actionBody.open!.surface,
736
- actionBody.open!.input,
737
- { ...context, item },
738
- ),
739
- })}
740
- />
1417
+ <>
1418
+ <ActionList<Record<string, unknown>, Record<string, unknown>>
1419
+ action={
1420
+ bodyAction as ListContract<
1421
+ Record<string, unknown>,
1422
+ Record<string, unknown>
1423
+ >
1424
+ }
1425
+ input={bodyInput}
1426
+ rowId={
1427
+ actionBody.rowKey
1428
+ ? (item) => String(item[actionBody.rowKey!])
1429
+ : undefined
1430
+ }
1431
+ beforeContent={listContentAdapter?.render}
1432
+ cells={
1433
+ actionBody.columns === undefined
1434
+ ? cellsAdapter?.cells
1435
+ : {
1436
+ ...cellsAdapter?.cells,
1437
+ ...Object.fromEntries(
1438
+ Object.entries(actionBody.columns).map(
1439
+ ([field, column]) => [
1440
+ field,
1441
+ (item: Record<string, unknown>) =>
1442
+ column.kind === "switch" ? (
1443
+ <PresentationSwitch
1444
+ column={column}
1445
+ item={item}
1446
+ checked={item[field] === true}
1447
+ actions={actions}
1448
+ can={can}
1449
+ context={context}
1450
+ />
1451
+ ) : (
1452
+ presentationTextCell(
1453
+ item[field],
1454
+ column,
1455
+ compositionRuntime?.icons,
1456
+ )
1457
+ ),
1458
+ ],
1459
+ ),
1460
+ ),
1461
+ }
1462
+ }
1463
+ emptyMessage={actionBody.emptyMessage}
1464
+ filterDefaults={actionBody.filterDefaults}
1465
+ rowActions={
1466
+ rowCommands.length === 0 && rowContentAdapter === undefined
1467
+ ? undefined
1468
+ : renderItemActions
1469
+ }
1470
+ state={listState}
1471
+ onStateChange={onListStateChange}
1472
+ toolbarActions={
1473
+ toolbarActions.length === 0 &&
1474
+ toolbarContentAdapter === undefined ? undefined : (
1475
+ <>
1476
+ {toolbarContentAdapter?.render(bodyInput)}
1477
+ {toolbarActions}
1478
+ </>
1479
+ )
1480
+ }
1481
+ onRowClick={
1482
+ actionBody.open || actionBody.opens || inspection
1483
+ ? openItem
1484
+ : undefined
1485
+ }
1486
+ children={
1487
+ collectionAdapter
1488
+ ? (items, refetch) =>
1489
+ collectionAdapter.render(items, {
1490
+ onOpen: openItem,
1491
+ canOpen: (item) =>
1492
+ inspection
1493
+ ? canInspect(item)
1494
+ : findTarget(item) !== undefined,
1495
+ actions: renderItemActions,
1496
+ refetch,
1497
+ })
1498
+ : undefined
1499
+ }
1500
+ />
1501
+ {inspected?.presentation === definition.id &&
1502
+ canInspect(inspected.item) &&
1503
+ detailAdapter?.render(inspected.item, () =>
1504
+ setInspected(undefined),
1505
+ )}
1506
+ </>
741
1507
  );
742
1508
  }
743
1509
  if (bodyAction.kind === "view") {
@@ -747,18 +1513,22 @@ export function Presentation({
747
1513
  action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
748
1514
  input={bodyInput}
749
1515
  >
750
- {(data) => (
751
- <DetailGroup>
752
- {fields.map((field) => (
753
- <DetailField
754
- key={field.key}
755
- label={field.label}
756
- value={readField(data, field.key) as ReactNode}
757
- empty={field.empty}
758
- />
759
- ))}
760
- </DetailGroup>
761
- )}
1516
+ {(data) =>
1517
+ fields.some((field) => field.format !== undefined) ? (
1518
+ <PresentationViewSections fields={fields} data={data} />
1519
+ ) : (
1520
+ <DetailGroup>
1521
+ {fields.map((field) => (
1522
+ <DetailField
1523
+ key={field.key}
1524
+ label={field.label}
1525
+ value={readField(data, field.key) as ReactNode}
1526
+ empty={field.empty}
1527
+ />
1528
+ ))}
1529
+ </DetailGroup>
1530
+ )
1531
+ }
762
1532
  </ActionView>
763
1533
  );
764
1534
  }
@@ -768,31 +1538,80 @@ export function Presentation({
768
1538
  };
769
1539
 
770
1540
  const headerActions = definition.actions
771
- .filter((command) => command.placement === "header")
772
- .map((command) => renderCommand(command, "sm"));
1541
+ .filter(
1542
+ (command) =>
1543
+ command.placement === "header" &&
1544
+ matchesPresentationConditions(command.when, context.record ?? {}) &&
1545
+ compositionRuntime?.state == null &&
1546
+ canAccessPresentation(definition, actions, can) &&
1547
+ canUseCommand(command),
1548
+ )
1549
+ .map((command) => renderCommand(command, "default"));
1550
+ const toolbarActions = definition.actions
1551
+ .filter(
1552
+ (command) =>
1553
+ command.placement === "toolbar" &&
1554
+ matchesPresentationConditions(command.when, context.record ?? {}) &&
1555
+ canAccessPresentation(definition, actions, can) &&
1556
+ canUseCommand(command),
1557
+ )
1558
+ .map((command) => renderCommand(command));
773
1559
  const footerActions = definition.actions
774
- .filter((command) => command.placement === "footer")
1560
+ .filter(
1561
+ (command) =>
1562
+ command.placement === "footer" &&
1563
+ matchesPresentationConditions(command.when, context.record ?? {}) &&
1564
+ canAccessPresentation(definition, actions, can) &&
1565
+ canUseCommand(command),
1566
+ )
775
1567
  .map((command) => renderCommand(command));
776
1568
  const parentSurface = invocation.stack.at(-1)?.surface;
777
1569
  const canGoBack =
778
1570
  parentSurface !== undefined &&
779
1571
  (invocation.surface === "page" || parentSurface !== "page");
780
- const navigation = !canGoBack ? undefined : (
781
- <Button
782
- size="icon-sm"
783
- variant="ghost"
1572
+ const parentTitle = definitions.find(
1573
+ (candidate) => candidate.id === invocation.stack.at(-1)?.presentationId,
1574
+ )?.title;
1575
+ const backLabel = parentTitle
1576
+ ? `Voltar para ${parentTitle}`
1577
+ : "Voltar para a página anterior";
1578
+ const navigation = !canGoBack ? undefined : invocation.surface === "page" ? (
1579
+ <PageBack
784
1580
  disabled={surfaceBlocked}
785
- aria-label="Voltar"
786
- title="Voltar"
787
- onClick={() => applyEffects([{ effect: "back" }])}
1581
+ aria-label={backLabel}
1582
+ onBack={() => applyEffects([{ effect: "back" }])}
788
1583
  >
789
- <ArrowLeft aria-hidden />
790
- </Button>
1584
+ {parentTitle ?? "a página anterior"}
1585
+ </PageBack>
1586
+ ) : (
1587
+ <Tooltip>
1588
+ <TooltipTrigger asChild>
1589
+ <Button
1590
+ size="icon"
1591
+ variant="ghost"
1592
+ disabled={surfaceBlocked}
1593
+ aria-label={backLabel}
1594
+ onClick={() => applyEffects([{ effect: "back" }])}
1595
+ >
1596
+ <ArrowLeft aria-hidden />
1597
+ </Button>
1598
+ </TooltipTrigger>
1599
+ <TooltipContent>{backLabel}</TooltipContent>
1600
+ </Tooltip>
791
1601
  );
792
1602
  const assistantAction =
793
- definition.assistant === undefined || assistantOpen ? undefined : (
1603
+ !canAccessPresentation(definition, actions, can) ||
1604
+ definition.assistant === undefined ||
1605
+ compositionRuntime?.state != null ||
1606
+ assistantOpen ? undefined : assistant?.trigger ? (
1607
+ assistant.trigger({
1608
+ open: assistantOpen,
1609
+ onOpenChange: setAssistantOpen,
1610
+ context,
1611
+ })
1612
+ ) : (
794
1613
  <Button
795
- size="icon-sm"
1614
+ size="icon"
796
1615
  variant="ghost"
797
1616
  aria-label={definition.assistant.triggerLabel}
798
1617
  title={definition.assistant.triggerLabel}
@@ -802,42 +1621,136 @@ export function Presentation({
802
1621
  </Button>
803
1622
  );
804
1623
  const assistantPanel =
805
- definition.assistant === undefined || assistant === undefined
1624
+ !canAccessPresentation(definition, actions, can) ||
1625
+ definition.assistant === undefined ||
1626
+ assistant === undefined ||
1627
+ compositionRuntime?.state != null
806
1628
  ? undefined
807
- : assistant.render({ close: () => setAssistantOpen(false) });
1629
+ : assistant.render({ close: () => setAssistantOpen(false), context });
808
1630
 
809
- return (
810
- <PresentationFrame
811
- surface={invocation.surface}
812
- layout={layout}
813
- title={definition.title}
814
- navigation={navigation}
815
- headerActions={headerActions.length === 0 ? undefined : headerActions}
816
- footerActions={footerActions.length === 0 ? undefined : footerActions}
817
- open={open}
818
- onOpenChange={(nextOpen) => {
819
- if (!nextOpen && invocation.surface !== "page") {
820
- closeSurface();
821
- return;
1631
+ const renderContent = (footerTarget: HTMLElement | null) => (
1632
+ <>
1633
+ {actionBody?.intro && (
1634
+ <p className="mb-4 text-sm text-muted-foreground">{actionBody.intro}</p>
1635
+ )}
1636
+ {renderBody(footerTarget)}
1637
+ </>
1638
+ );
1639
+ const tabList =
1640
+ tabsBody === null ? undefined : (
1641
+ <TabsList aria-label={tabsBody.tabLabel}>
1642
+ {tabsBody.tabs.map((tab) => (
1643
+ <TabsListItem
1644
+ key={tab.id}
1645
+ value={tab.id}
1646
+ icon={
1647
+ tab.icon === undefined
1648
+ ? undefined
1649
+ : compositionRuntime!.icons![tab.icon]
1650
+ }
1651
+ >
1652
+ {tab.label}
1653
+ </TabsListItem>
1654
+ ))}
1655
+ </TabsList>
1656
+ );
1657
+ const renderedHeaderActions =
1658
+ definition.header?.actions === undefined &&
1659
+ headerActions.length === 0 &&
1660
+ !definition.links.some(
1661
+ (link) => link.placement === "header",
1662
+ ) ? undefined : (
1663
+ <>
1664
+ {headerSlot(definition.header?.actions)}
1665
+ {headerActions}
1666
+ {definition.links
1667
+ .filter((link) => link.placement === "header")
1668
+ .map(renderLink)}
1669
+ </>
1670
+ );
1671
+ const frame =
1672
+ embedded && invocation.surface === "page" ? (
1673
+ <>
1674
+ {renderedHeaderActions !== undefined && (
1675
+ <ButtonGroup mode="spaced" className="justify-end">
1676
+ {renderedHeaderActions}
1677
+ </ButtonGroup>
1678
+ )}
1679
+ {tabList}
1680
+ {renderContent(null)}
1681
+ {footerActions.length > 0 && (
1682
+ <ButtonGroup mode="spaced" className="justify-end">
1683
+ {footerActions}
1684
+ </ButtonGroup>
1685
+ )}
1686
+ </>
1687
+ ) : (
1688
+ <PresentationFrame
1689
+ tabs={tabList}
1690
+ surface={invocation.surface}
1691
+ layout={layout}
1692
+ pageWidth={definition.page?.width}
1693
+ title={headerSlot(definition.header?.title) ?? definition.title}
1694
+ meta={headerSlot(definition.header?.meta)}
1695
+ description={headerSlot(definition.header?.description)}
1696
+ navigation={navigation}
1697
+ headerActions={renderedHeaderActions}
1698
+ footerActions={footerActions.length === 0 ? undefined : footerActions}
1699
+ open={open}
1700
+ onOpenChange={(nextOpen) => {
1701
+ if (!nextOpen && invocation.surface !== "page") {
1702
+ closeSurface();
1703
+ return;
1704
+ }
1705
+ onOpenChange?.(nextOpen);
1706
+ }}
1707
+ className={cn(
1708
+ definition.width === "wide" &&
1709
+ (invocation.surface === "dialog"
1710
+ ? "max-w-4xl"
1711
+ : invocation.surface === "drawer"
1712
+ ? "w-[56rem] max-w-[calc(100%-2rem)]"
1713
+ : undefined),
1714
+ className,
1715
+ )}
1716
+ bodyClassName={bodyClassNameProp}
1717
+ bodyFlush={definition.page?.flush}
1718
+ hasBodyFooter={
1719
+ bodyAction?.kind === "form" || componentBody?.footer === true
822
1720
  }
823
- onOpenChange?.(nextOpen);
1721
+ blocked={surfaceBlocked}
1722
+ contentOwnsHeading={
1723
+ isPresentationComponentBody(definition.body) &&
1724
+ definition.body.heading === "content"
1725
+ }
1726
+ assistantOpen={assistantOpen}
1727
+ assistantLabel={definition.assistant?.triggerLabel}
1728
+ assistantAction={assistantAction}
1729
+ assistantPanel={assistantPanel}
1730
+ >
1731
+ {renderContent}
1732
+ </PresentationFrame>
1733
+ );
1734
+ return tabsBody === null ? (
1735
+ frame
1736
+ ) : (
1737
+ <Tabs
1738
+ framed={tabsBody.framed}
1739
+ distribution={tabsBody.distribution}
1740
+ value={tabValue}
1741
+ onValueChange={(value) => {
1742
+ if (compositionRuntime?.tabs !== undefined)
1743
+ compositionRuntime.tabs.onValueChange(value);
1744
+ else setLocalTab({ presentation: definition.id, value });
824
1745
  }}
825
- className={className}
826
- bodyClassName={bodyClassNameProp}
827
- hasBodyFooter={bodyAction?.kind === "form"}
828
- blocked={surfaceBlocked}
829
- listBody={bodyAction?.kind === "list"}
830
- contentOwnsHeading={
831
- !isPresentationActionBody(definition.body) &&
832
- definition.body.heading === "content"
1746
+ className={
1747
+ invocation.surface === "dialog" || invocation.surface === "drawer"
1748
+ ? "contents"
1749
+ : "h-full min-h-0 min-w-0 flex-1"
833
1750
  }
834
- assistantOpen={assistantOpen}
835
- assistantLabel={definition.assistant?.triggerLabel}
836
- assistantAction={assistantAction}
837
- assistantPanel={assistantPanel}
838
1751
  >
839
- {renderBody}
840
- </PresentationFrame>
1752
+ {frame}
1753
+ </Tabs>
841
1754
  );
842
1755
  }
843
1756