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