@softize/opus 16.1.0 → 17.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 (33) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/lib/check.mjs +9 -13
  3. package/bin/lib/copy.mjs +29 -4
  4. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +6 -3
  5. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +5 -2
  6. package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +12 -14
  7. package/docs/adr/0012-modal-header-only-names-the-surface.md +8 -4
  8. package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +20 -11
  9. package/docs/adr/0014-structural-headers-do-not-carry-description.md +35 -0
  10. package/docs/adr/0015-action-size-follows-interaction-density.md +64 -0
  11. package/package.json +2 -2
  12. package/registry/skills/build-opus-ui/references/evaluations.md +1 -1
  13. package/registry/skills/build-opus-ui/references/ui-patterns.md +19 -18
  14. package/registry/templates/app/package.json +1 -1
  15. package/src/core/presentation.ts +142 -13
  16. package/src/ui/components/patterns/form.tsx +35 -10
  17. package/src/ui/components/patterns/page.tsx +93 -48
  18. package/src/ui/components/patterns/presentation.tsx +489 -329
  19. package/src/ui/components/patterns/surface-header.tsx +4 -3
  20. package/src/ui/components/patterns/trigger.tsx +8 -1
  21. package/src/ui/components/patterns/view.tsx +2 -2
  22. package/src/ui/components/primitives/command.tsx +82 -42
  23. package/src/ui/components/primitives/dialog.tsx +180 -97
  24. package/src/ui/components/primitives/drawer.tsx +63 -22
  25. package/src/ui/docs/content/button.md +8 -2
  26. package/src/ui/docs/content/command.md +3 -1
  27. package/src/ui/docs/content/content.md +1 -1
  28. package/src/ui/docs/content/dialog.md +9 -9
  29. package/src/ui/docs/content/drawer.md +4 -4
  30. package/src/ui/docs/content/page.md +16 -31
  31. package/src/ui/docs/content/presentation.md +73 -80
  32. package/src/ui/meta.ts +2 -2
  33. package/src/ui/react.tsx +1 -8
@@ -1,47 +1,53 @@
1
1
  import {
2
- createContext,
3
2
  useCallback,
4
- useContext,
5
- useEffect,
6
- useId,
7
- useMemo,
8
- useRef,
9
3
  useState,
10
4
  type ReactElement,
11
5
  type ReactNode,
12
6
  } from "react";
13
- import { Braces, X } from "lucide-react";
7
+ import { createPortal } from "react-dom";
8
+ import { ArrowLeft, Braces } from "lucide-react";
14
9
  import type {
10
+ PresentationActionRegistry,
11
+ PresentationBindingContext,
12
+ PresentationCommand,
15
13
  PresentationDiagnostic,
16
14
  PresentationDefinition,
17
15
  PresentationInvocation,
16
+ PresentationJsonValue,
18
17
  PresentationSurface,
19
18
  } from "../../../core/presentation.ts";
20
- import { createPresentationInspectionSnapshot } from "../../../core/presentation.ts";
19
+ import {
20
+ applyPresentationEffects,
21
+ createPresentationInspectionSnapshot,
22
+ openPresentation,
23
+ resolvePresentationBindings,
24
+ } from "../../../core/presentation.ts";
25
+ import type {
26
+ FormContract,
27
+ ListContract,
28
+ SimpleContract,
29
+ ViewContract,
30
+ } from "../../../core/contracts.ts";
21
31
  import { cn } from "../../lib/cn.ts";
22
- import { Button, buttonVariants } from "../primitives/button.tsx";
32
+ import {
33
+ Button,
34
+ buttonVariants,
35
+ type ButtonSize,
36
+ } from "../primitives/button.tsx";
23
37
  import { ButtonGroup } from "../primitives/button-group.tsx";
24
38
  import { Copyable } from "../primitives/copyable.tsx";
39
+ import { DetailField, DetailGroup } from "../primitives/detail.tsx";
25
40
  import {
26
41
  Dialog,
27
42
  DialogBody,
28
- DialogClose,
29
43
  DialogContent,
30
44
  DialogFooter,
31
45
  DialogHeader,
32
46
  DialogTitle,
33
47
  } from "../primitives/dialog.tsx";
34
- import {
35
- Menu,
36
- MenuContent,
37
- MenuItem,
38
- MenuLabel,
39
- MenuTrigger,
40
- } from "../primitives/menu.tsx";
41
48
  import {
42
49
  Drawer,
43
50
  DrawerBody,
44
- DrawerClose,
45
51
  DrawerContent,
46
52
  DrawerFooter,
47
53
  DrawerHeader,
@@ -58,24 +64,26 @@ import {
58
64
  PageTitle,
59
65
  useInsidePageShell,
60
66
  } from "./page.tsx";
67
+ import { ActionForm } from "./form.tsx";
68
+ import { ActionList, type ActionListState } from "./list.tsx";
69
+ import { ActionTrigger } from "./trigger.tsx";
70
+ import { ActionView } from "./view.tsx";
61
71
 
62
- interface PresentationBaseProps {
72
+ interface PresentationFrameProps {
63
73
  surface: PresentationSurface;
64
74
  title: ReactNode;
65
75
  navigation?: ReactNode;
66
76
  headerActions?: ReactNode;
67
77
  footerActions?: ReactNode;
68
- children: ReactNode;
78
+ children: ReactNode | ((footerTarget: HTMLElement | null) => ReactNode);
79
+ hasBodyFooter?: boolean;
80
+ blocked?: boolean;
81
+ open?: boolean;
82
+ onOpenChange?: (open: boolean) => void;
69
83
  className?: string;
70
84
  bodyClassName?: string;
71
85
  }
72
86
 
73
- export type PresentationProps = PresentationBaseProps &
74
- (
75
- | { open?: undefined; onOpenChange?: (open: boolean) => void }
76
- | { open: boolean; onOpenChange: (open: boolean) => void }
77
- );
78
-
79
87
  const bodyClassName =
80
88
  "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
89
 
@@ -93,49 +101,46 @@ function PresentationActions({
93
101
  );
94
102
  }
95
103
 
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
104
  /** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
114
- export function Presentation({
105
+ function PresentationFrame({
115
106
  surface,
116
107
  title,
117
108
  navigation,
118
109
  headerActions,
119
110
  footerActions,
120
111
  children,
121
- open,
112
+ hasBodyFooter = false,
113
+ blocked = false,
114
+ open = true,
122
115
  onOpenChange,
123
116
  className,
124
117
  bodyClassName: bodyClassNameProp,
125
- }: PresentationProps): ReactElement {
118
+ }: PresentationFrameProps): ReactElement {
126
119
  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
- }
120
+ const [footerTarget, setFooterTarget] = useState<HTMLElement | null>(null);
121
+ const setFooterNode = useCallback((node: HTMLSpanElement | null) => {
122
+ setFooterTarget(node);
123
+ }, []);
124
+ const hasFooter = hasBodyFooter || footerActions !== undefined;
125
+ const renderFooterActions = (
126
+ equal = false,
127
+ ): ReactElement => (
128
+ <PresentationActions equal={equal}>
129
+ {hasBodyFooter ? (
130
+ <span
131
+ ref={setFooterNode}
132
+ className="contents [&>*]:min-w-0 [&>*]:w-full [&>*]:flex-1"
133
+ />
134
+ ) : null}
135
+ {footerActions}
136
+ </PresentationActions>
137
+ );
133
138
  const body = (
134
139
  <div
135
140
  data-slot="presentation-body"
136
141
  className={cn(bodyClassName, bodyClassNameProp)}
137
142
  >
138
- {children}
143
+ {typeof children === "function" ? children(footerTarget) : children}
139
144
  </div>
140
145
  );
141
146
 
@@ -143,23 +148,21 @@ export function Presentation({
143
148
  if (insidePageShell) {
144
149
  return (
145
150
  <Page className={cn("flex min-h-full flex-col", className)}>
146
- <PageIntro>
151
+ <PageHeader>
147
152
  {navigation === undefined ? null : (
148
153
  <PageNavigation>{navigation}</PageNavigation>
149
154
  )}
150
- <PageTitle>{title}</PageTitle>
151
155
  {headerActions === undefined ? null : (
152
156
  <PageActions>
153
157
  <PresentationActions>{headerActions}</PresentationActions>
154
158
  </PageActions>
155
159
  )}
160
+ </PageHeader>
161
+ <PageIntro>
162
+ <PageTitle>{title}</PageTitle>
156
163
  </PageIntro>
157
164
  <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
- )}
165
+ {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
163
166
  </Page>
164
167
  );
165
168
  }
@@ -184,290 +187,479 @@ export function Presentation({
184
187
  <PageBody className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
185
188
  {body}
186
189
  </PageBody>
187
- {footerActions === undefined ? null : (
188
- <PageFooter>
189
- <PresentationActions>{footerActions}</PresentationActions>
190
- </PageFooter>
191
- )}
190
+ {hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
192
191
  </Page>
193
192
  );
194
193
  }
195
194
 
196
195
  if (surface === "dialog") {
197
196
  return (
198
- <Dialog open={modalOpen} onOpenChange={handleOpenChange}>
197
+ <Dialog
198
+ open={open}
199
+ onOpenChange={(nextOpen) => {
200
+ if (!nextOpen && blocked) return;
201
+ onOpenChange?.(nextOpen);
202
+ }}
203
+ >
199
204
  <DialogContent
200
205
  className={className}
201
- showCloseButton={false}
202
206
  aria-describedby={undefined}
207
+ closeDisabled={blocked}
208
+ onEscapeKeyDown={(event) => {
209
+ if (blocked) event.preventDefault();
210
+ }}
211
+ onPointerDownOutside={(event) => {
212
+ if (blocked) event.preventDefault();
213
+ }}
203
214
  >
204
215
  <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>
216
+ {navigation}
217
+ <DialogTitle className="truncate">{title}</DialogTitle>
218
+ {headerActions === undefined ? null : (
219
+ <PresentationActions>{headerActions}</PresentationActions>
220
+ )}
215
221
  </DialogHeader>
216
- <DialogBody className={bodyClassNameProp}>{body}</DialogBody>
217
- {footerActions === undefined ? null : (
218
- <DialogFooter>
219
- <PresentationActions equal>{footerActions}</PresentationActions>
220
- </DialogFooter>
221
- )}
222
+ <DialogBody>{body}</DialogBody>
223
+ {hasFooter ? <DialogFooter>{renderFooterActions(true)}</DialogFooter> : null}
222
224
  </DialogContent>
223
225
  </Dialog>
224
226
  );
225
227
  }
226
228
 
227
229
  return (
228
- <Drawer open={modalOpen} onOpenChange={handleOpenChange}>
230
+ <Drawer
231
+ open={open}
232
+ onOpenChange={(nextOpen) => {
233
+ if (!nextOpen && blocked) return;
234
+ onOpenChange?.(nextOpen);
235
+ }}
236
+ >
229
237
  <DrawerContent
230
238
  className={className}
231
- showCloseButton={false}
232
239
  aria-describedby={undefined}
240
+ closeDisabled={blocked}
241
+ onEscapeKeyDown={(event) => {
242
+ if (blocked) event.preventDefault();
243
+ }}
244
+ onPointerDownOutside={(event) => {
245
+ if (blocked) event.preventDefault();
246
+ }}
233
247
  >
234
248
  <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>
249
+ {navigation}
250
+ <DrawerTitle className="truncate">{title}</DrawerTitle>
251
+ {headerActions === undefined ? null : (
252
+ <PresentationActions>{headerActions}</PresentationActions>
253
+ )}
245
254
  </DrawerHeader>
246
- <DrawerBody className={bodyClassNameProp}>{body}</DrawerBody>
247
- {footerActions === undefined ? null : (
248
- <DrawerFooter>
249
- <PresentationActions equal>{footerActions}</PresentationActions>
250
- </DrawerFooter>
251
- )}
255
+ <DrawerBody>{body}</DrawerBody>
256
+ {hasFooter ? <DrawerFooter>{renderFooterActions(true)}</DrawerFooter> : null}
252
257
  </DrawerContent>
253
258
  </Drawer>
254
259
  );
255
260
  }
256
261
 
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
- > {
262
+ export interface PresentationProps {
263
+ /** Definição estática registrada e publicada no manifest. */
274
264
  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;
265
+ /** Definições alcançáveis por open, navigate ou back. */
266
+ definitions: readonly PresentationDefinition[];
267
+ /** Contratos compartilháveis das actions referenciadas pela definição. */
268
+ actions: PresentationActionRegistry;
269
+ /** Estado serializável da exibição atual. */
270
+ invocation: PresentationInvocation;
271
+ /** Dados disponíveis para resolver bindings além do input da invocação. */
272
+ bindingContext?: PresentationBindingContext;
273
+ /** Recebe navegação, retorno e fechamento produzidos pelo artefato. */
274
+ onInvocationChange?: (invocation: PresentationInvocation | null) => void;
275
+ /** Recebe cada invalidação declarada por um efeito refresh. */
276
+ onRefresh?: (action: string | null) => void;
277
+ /** Estado de recorte da action list quando a aplicação o sincroniza com a URL. */
278
+ listState?: ActionListState;
279
+ onListStateChange?: (state: ActionListState) => void;
280
+ open?: boolean;
281
+ onOpenChange?: (open: boolean) => void;
282
+ className?: string;
283
+ bodyClassName?: string;
282
284
  }
283
285
 
284
- interface PresentationRegistrationContextValue {
285
- upsert: (key: string, registration: ActivePresentationRegistration) => void;
286
- remove: (key: string) => void;
286
+ function actionLabel(action: { name: string; label?: unknown }): string {
287
+ if (typeof action.label === "string") return action.label;
288
+ if (
289
+ action.label !== null &&
290
+ typeof action.label === "object" &&
291
+ "default" in action.label &&
292
+ typeof (action.label as { default?: unknown }).default === "string"
293
+ ) {
294
+ return (action.label as { default: string }).default;
295
+ }
296
+ return action.name;
287
297
  }
288
298
 
289
- interface PresentationDevtoolsContextValue {
290
- keys: readonly string[];
291
- read: (key: string) => ActivePresentationRegistration | undefined;
299
+ function readField(record: unknown, key: string): unknown {
300
+ if (record === null || typeof record !== "object") return undefined;
301
+ return (record as Record<string, unknown>)[key];
292
302
  }
293
303
 
294
- const PresentationRegistrationContext =
295
- createContext<PresentationRegistrationContextValue | null>(null);
296
- const PresentationDevtoolsContext =
297
- createContext<PresentationDevtoolsContextValue | null>(null);
298
-
299
- export interface PresentationDevtoolsProviderProps {
300
- children: ReactNode;
304
+ function PresentationCommandTrigger({
305
+ command,
306
+ action,
307
+ input,
308
+ size,
309
+ disabled,
310
+ onLoadingChange,
311
+ onSuccess,
312
+ }: {
313
+ command: PresentationCommand;
314
+ action: SimpleContract<Record<string, unknown>, unknown>;
315
+ input: Record<string, unknown>;
316
+ size: ButtonSize;
317
+ disabled: boolean;
318
+ onLoadingChange: (command: PresentationCommand, loading: boolean) => void;
319
+ onSuccess: (data: unknown) => void;
320
+ }): ReactElement {
321
+ const reportLoading = useCallback(
322
+ (loading: boolean) => onLoadingChange(command, loading),
323
+ [command, onLoadingChange],
324
+ );
325
+ return (
326
+ <ActionTrigger
327
+ action={action}
328
+ input={input}
329
+ variant="ghost"
330
+ size={size}
331
+ disabled={disabled}
332
+ onLoadingChange={reportLoading}
333
+ onSuccess={onSuccess}
334
+ />
335
+ );
301
336
  }
302
337
 
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]);
338
+ /** Renderiza uma definição declarativa delegando cada kind ao pattern Opus correspondente. */
339
+ export function Presentation({
340
+ definition,
341
+ definitions,
342
+ actions,
343
+ invocation,
344
+ bindingContext,
345
+ onInvocationChange,
346
+ onRefresh,
347
+ listState,
348
+ onListStateChange,
349
+ open = true,
350
+ onOpenChange,
351
+ className,
352
+ bodyClassName: bodyClassNameProp,
353
+ }: PresentationProps): ReactElement {
354
+ if (definition.id !== invocation.presentationId) {
355
+ throw new Error(
356
+ `A invocação “${invocation.presentationId}” não corresponde à Presentation “${definition.id}”.`,
357
+ );
358
+ }
359
+ const [loadingCommands, setLoadingCommands] = useState<
360
+ ReadonlySet<PresentationCommand>
361
+ >(() => new Set());
362
+ const [bodyLoading, setBodyLoading] = useState(false);
363
+ const context: PresentationBindingContext = {
364
+ ...bindingContext,
365
+ route: bindingContext?.route ?? invocation.input,
366
+ };
367
+
368
+ const applyEffects = useCallback(
369
+ (
370
+ effects: PresentationDefinition["body"]["onSuccess"],
371
+ result?: unknown,
372
+ ): void => {
373
+ const next = applyPresentationEffects(invocation, effects, {
374
+ ...context,
375
+ ...(result !== undefined && result !== null && typeof result === "object"
376
+ ? { result: result as Record<string, unknown> }
377
+ : {}),
378
+ });
379
+ for (const action of next.refresh) onRefresh?.(action);
380
+ if (next.invocation !== invocation || next.exit !== undefined) {
381
+ onInvocationChange?.(next.invocation);
382
+ }
316
383
  },
317
- [],
384
+ [context, invocation, onInvocationChange, onRefresh],
318
385
  );
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),
386
+
387
+ const closeSurface = useCallback((): void => {
388
+ onOpenChange?.(false);
389
+ applyEffects([{ effect: "close" }]);
390
+ }, [applyEffects, onOpenChange]);
391
+
392
+ const reportLoading = useCallback(
393
+ (command: PresentationCommand, loading: boolean): void => {
394
+ setLoadingCommands((current) => {
395
+ const next = new Set(current);
396
+ if (loading) next.add(command);
397
+ else next.delete(command);
398
+ return next;
399
+ });
400
+ },
325
401
  [],
326
402
  );
327
- const registration = useMemo(() => ({ upsert, remove }), [upsert, remove]);
328
- const devtools = useMemo(() => ({ keys, read }), [keys, read]);
329
403
 
330
- return (
331
- <PresentationRegistrationContext.Provider value={registration}>
332
- <PresentationDevtoolsContext.Provider value={devtools}>
333
- {children}
334
- </PresentationDevtoolsContext.Provider>
335
- </PresentationRegistrationContext.Provider>
404
+ const hasSurfaceBlock = [...loadingCommands].some(
405
+ (active) => active.blocking === "surface",
336
406
  );
337
- }
407
+ const surfaceBlocked = bodyLoading || hasSurfaceBlock;
408
+ const isBlocked = (command: PresentationCommand): boolean =>
409
+ bodyLoading ||
410
+ [...loadingCommands].some(
411
+ (active) =>
412
+ active.blocking === "surface" ||
413
+ (active.blocking === "group" && active.placement === command.placement),
414
+ );
338
415
 
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();
416
+ const openTarget = (
417
+ presentationId: string,
418
+ surface: PresentationSurface | undefined,
419
+ bindings: PresentationCommand["input"],
420
+ targetContext: PresentationBindingContext = context,
421
+ ): void => {
422
+ const target = definitions.find((candidate) => candidate.id === presentationId);
423
+ if (target === undefined) {
424
+ throw new Error(`Presentation “${presentationId}” não registrada.`);
425
+ }
426
+ onInvocationChange?.(
427
+ openPresentation(invocation, {
428
+ presentationId: target.id,
429
+ surface: surface ?? invocation.surface,
430
+ input: resolvePresentationBindings(bindings, targetContext) as Record<
431
+ string,
432
+ PresentationJsonValue
433
+ >,
434
+ }),
435
+ );
436
+ };
351
437
 
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;
438
+ const renderCommand = (command: PresentationCommand): ReactElement => {
439
+ const action = actions[command.action];
440
+ if (action === undefined) {
441
+ throw new Error(`Action “${command.action}” não registrada.`);
442
+ }
443
+ const input = resolvePresentationBindings(command.input, context) as Record<
444
+ string,
445
+ unknown
446
+ >;
447
+ // Header e footer são regiões de decisão da superfície, não toolbars operacionais.
448
+ // Seus comandos textuais usam a linha padrão; listas e grids continuam donos da
449
+ // densidade compacta de suas próprias ações.
450
+ const size: ButtonSize = "default";
451
+ if (action.kind === "simple") {
452
+ return (
453
+ <PresentationCommandTrigger
454
+ key={`${command.placement}:${command.action}`}
455
+ command={command}
456
+ action={action as SimpleContract<Record<string, unknown>, unknown>}
457
+ input={input}
458
+ size={size}
459
+ disabled={isBlocked(command)}
460
+ onLoadingChange={reportLoading}
461
+ onSuccess={(data) => applyEffects(command.onSuccess, data)}
462
+ />
463
+ );
358
464
  }
359
- upsert(key, { key, definition, invocation, resolved, diagnostics });
360
- });
465
+ if (command.target === undefined) {
466
+ throw new Error(
467
+ `A action “${command.action}” precisa declarar uma Presentation de destino.`,
468
+ );
469
+ }
470
+ return (
471
+ <Button
472
+ key={`${command.placement}:${command.action}`}
473
+ size={size}
474
+ disabled={isBlocked(command)}
475
+ onClick={() =>
476
+ openTarget(
477
+ command.target!.presentation,
478
+ command.target!.surface,
479
+ command.input,
480
+ )
481
+ }
482
+ >
483
+ {actionLabel(action)}
484
+ </Button>
485
+ );
486
+ };
361
487
 
362
- useEffect(
363
- () => () => {
364
- remove?.(key);
365
- },
366
- [key, remove],
367
- );
368
- }
488
+ const bodyAction = actions[definition.body.action];
489
+ if (bodyAction === undefined) {
490
+ throw new Error(`Action “${definition.body.action}” não registrada.`);
491
+ }
492
+ const bodyInput = resolvePresentationBindings(
493
+ definition.body.input,
494
+ context,
495
+ ) as Record<string, unknown>;
496
+ const renderBody = (footerTarget: HTMLElement | null): ReactNode => {
497
+ if (bodyAction.kind === "form") {
498
+ return (
499
+ <ActionForm
500
+ action={bodyAction as FormContract<Record<string, unknown>, unknown>}
501
+ defaultValues={bodyInput}
502
+ submitLabel={definition.body.submitLabel}
503
+ onCancel={
504
+ invocation.surface === "page"
505
+ ? undefined
506
+ : closeSurface
507
+ }
508
+ onSuccess={(data) => applyEffects(definition.body.onSuccess, data)}
509
+ disabled={hasSurfaceBlock}
510
+ onLoadingChange={setBodyLoading}
511
+ footer={(actions) =>
512
+ footerTarget === null ? null : createPortal(actions, footerTarget)
513
+ }
514
+ />
515
+ );
516
+ }
517
+ if (bodyAction.kind === "list") {
518
+ return (
519
+ <ActionList<Record<string, unknown>, Record<string, unknown>>
520
+ action={bodyAction as ListContract<
521
+ Record<string, unknown>,
522
+ Record<string, unknown>
523
+ >}
524
+ input={bodyInput}
525
+ state={listState}
526
+ onStateChange={onListStateChange}
527
+ {...(definition.body.open === undefined
528
+ ? {}
529
+ : {
530
+ onRowClick: (item: Record<string, unknown>) =>
531
+ openTarget(
532
+ definition.body.open!.presentation,
533
+ definition.body.open!.surface,
534
+ definition.body.open!.input,
535
+ { ...context, item },
536
+ ),
537
+ })}
538
+ />
539
+ );
540
+ }
541
+ if (bodyAction.kind === "view") {
542
+ const fields = definition.body.fields ?? [];
543
+ return (
544
+ <ActionView
545
+ action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
546
+ input={bodyInput}
547
+ >
548
+ {(data) => (
549
+ <DetailGroup>
550
+ {fields.map((field) => (
551
+ <DetailField
552
+ key={field.key}
553
+ label={field.label}
554
+ value={readField(data, field.key) as ReactNode}
555
+ empty={field.empty}
556
+ />
557
+ ))}
558
+ </DetailGroup>
559
+ )}
560
+ </ActionView>
561
+ );
562
+ }
563
+ throw new Error(`A action simple “${bodyAction.name}” não pode ocupar o body.`);
564
+ };
565
+
566
+ const headerActions = definition.actions
567
+ .filter((command) => command.placement === "header")
568
+ .map(renderCommand);
569
+ const footerActions = definition.actions
570
+ .filter((command) => command.placement === "footer")
571
+ .map(renderCommand);
572
+ const parentSurface = invocation.stack.at(-1)?.surface;
573
+ const canGoBack =
574
+ parentSurface !== undefined &&
575
+ (invocation.surface === "page" || parentSurface !== "page");
576
+ const navigation =
577
+ !canGoBack ? undefined : (
578
+ <Button
579
+ size="icon-sm"
580
+ variant="ghost"
581
+ disabled={surfaceBlocked}
582
+ aria-label="Voltar"
583
+ title="Voltar"
584
+ onClick={() => applyEffects([{ effect: "back" }])}
585
+ >
586
+ <ArrowLeft aria-hidden />
587
+ </Button>
588
+ );
369
589
 
370
- interface SelectedPresentation {
371
- key: string;
372
- title: string;
373
- json: string;
590
+ return (
591
+ <PresentationFrame
592
+ surface={invocation.surface}
593
+ title={definition.title}
594
+ navigation={navigation}
595
+ headerActions={headerActions.length === 0 ? undefined : headerActions}
596
+ footerActions={footerActions.length === 0 ? undefined : footerActions}
597
+ open={open}
598
+ onOpenChange={(nextOpen) => {
599
+ if (!nextOpen && invocation.surface !== "page") {
600
+ closeSurface();
601
+ return;
602
+ }
603
+ onOpenChange?.(nextOpen);
604
+ }}
605
+ className={className}
606
+ bodyClassName={bodyClassNameProp}
607
+ hasBodyFooter={bodyAction.kind === "form"}
608
+ blocked={surfaceBlocked}
609
+ >
610
+ {renderBody}
611
+ </PresentationFrame>
612
+ );
374
613
  }
375
614
 
376
- export interface PresentationDevtoolsProps {
377
- /** Nome acessível e tooltip do launcher. */
615
+ export interface PresentationInspectorProps {
616
+ definition: PresentationDefinition | unknown;
617
+ invocation?: PresentationInvocation | unknown;
618
+ resolved?: unknown;
619
+ diagnostics?: readonly PresentationDiagnostic[];
378
620
  triggerLabel?: string;
621
+ title?: string;
379
622
  className?: string;
380
623
  }
381
624
 
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
- }
625
+ function PresentationInspectionDialog({
626
+ registration,
627
+ open,
628
+ onOpenChange,
629
+ title = "JSON da Presentation",
630
+ }: {
631
+ registration: Pick<
632
+ PresentationInspectorProps,
633
+ "definition" | "invocation" | "resolved" | "diagnostics"
634
+ >;
635
+ open: boolean;
636
+ onOpenChange: (open: boolean) => void;
637
+ title?: string;
638
+ }): ReactElement {
639
+ const snapshot = createPresentationInspectionSnapshot(registration);
640
+ const json = JSON.stringify(snapshot, null, 2) ?? "null";
412
641
 
413
642
  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}
643
+ <Dialog open={open} onOpenChange={onOpenChange}>
644
+ <DialogContent className="sm:max-w-3xl" aria-describedby={undefined}>
645
+ <DialogHeader>
646
+ <DialogTitle>{title}</DialogTitle>
647
+ </DialogHeader>
648
+ <DialogBody>
649
+ <pre className="max-h-[65vh] overflow-auto rounded-lg bg-muted p-4 text-xs leading-relaxed">
650
+ {json}
651
+ </pre>
652
+ </DialogBody>
653
+ <DialogFooter>
654
+ <Copyable
655
+ value={json}
656
+ className={buttonVariants({ variant: "outline" })}
425
657
  >
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
- </>
658
+ Copiar JSON
659
+ </Copyable>
660
+ </DialogFooter>
661
+ </DialogContent>
662
+ </Dialog>
471
663
  );
472
664
  }
473
665
 
@@ -479,61 +671,29 @@ export function PresentationInspector({
479
671
  diagnostics,
480
672
  triggerLabel = "Ver JSON da Presentation",
481
673
  title = "JSON da Presentation",
482
- floating = false,
483
674
  className,
484
675
  }: PresentationInspectorProps): ReactElement {
485
676
  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
677
 
504
678
  return (
505
679
  <>
506
680
  <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)}
681
+ size="icon-sm"
682
+ variant="ghost"
683
+ data-appearance="inline"
684
+ className={className}
511
685
  aria-label={triggerLabel}
512
686
  title={triggerLabel}
513
- onClick={inspect}
687
+ onClick={() => setOpen(true)}
514
688
  >
515
689
  <Braces aria-hidden />
516
690
  </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>
691
+ <PresentationInspectionDialog
692
+ registration={{ definition, invocation, resolved, diagnostics }}
693
+ open={open}
694
+ onOpenChange={setOpen}
695
+ title={title}
696
+ />
537
697
  </>
538
698
  );
539
699
  }