@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.
- package/CHANGELOG.md +41 -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 +20 -11
- package/docs/adr/0014-structural-headers-do-not-carry-description.md +35 -0
- package/docs/adr/0015-action-size-follows-interaction-density.md +64 -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 +19 -18
- package/registry/templates/app/package.json +1 -1
- package/src/core/presentation.ts +142 -13
- package/src/ui/components/patterns/form.tsx +35 -10
- package/src/ui/components/patterns/page.tsx +93 -48
- package/src/ui/components/patterns/presentation.tsx +489 -329
- 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/button.md +8 -2
- 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 +16 -31
- package/src/ui/docs/content/presentation.md +73 -80
- package/src/ui/meta.ts +2 -2
- 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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
105
|
+
function PresentationFrame({
|
|
115
106
|
surface,
|
|
116
107
|
title,
|
|
117
108
|
navigation,
|
|
118
109
|
headerActions,
|
|
119
110
|
footerActions,
|
|
120
111
|
children,
|
|
121
|
-
|
|
112
|
+
hasBodyFooter = false,
|
|
113
|
+
blocked = false,
|
|
114
|
+
open = true,
|
|
122
115
|
onOpenChange,
|
|
123
116
|
className,
|
|
124
117
|
bodyClassName: bodyClassNameProp,
|
|
125
|
-
}:
|
|
118
|
+
}: PresentationFrameProps): ReactElement {
|
|
126
119
|
const insidePageShell = useInsidePageShell();
|
|
127
|
-
const [
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
<
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
217
|
-
{
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
247
|
-
{
|
|
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
|
|
258
|
-
|
|
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
|
-
/**
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
/**
|
|
304
|
-
export function
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
377
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
<
|
|
416
|
-
<
|
|
417
|
-
<
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
427
|
-
</
|
|
428
|
-
</
|
|
429
|
-
|
|
430
|
-
|
|
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=
|
|
508
|
-
variant=
|
|
509
|
-
data-appearance=
|
|
510
|
-
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={
|
|
687
|
+
onClick={() => setOpen(true)}
|
|
514
688
|
>
|
|
515
689
|
<Braces aria-hidden />
|
|
516
690
|
</Button>
|
|
517
|
-
<
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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
|
}
|