@softize/opus 15.2.2 → 16.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -18
- package/bin/lib/check.mjs +98 -15
- package/bin/lib/copy.mjs +811 -314
- package/bin/lib/gen-manifest.mjs +24 -23
- package/bin/lib/gen-runner.mjs +188 -148
- package/docs/adr/0010-page-header-owns-page-chrome.md +3 -4
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +5 -3
- package/docs/adr/0012-modal-header-only-names-the-surface.md +45 -0
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +92 -0
- package/docs/code-style.md +24 -19
- package/package.json +5 -1
- package/registry/skills/build-opus-ui/references/ui-patterns.md +43 -26
- package/src/core/presentation.ts +512 -0
- package/src/presentation/index.ts +1 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +26 -9
- package/src/ui/components/patterns/confirm.tsx +34 -29
- package/src/ui/components/patterns/form-dialog.tsx +20 -8
- package/src/ui/components/patterns/list.tsx +26 -9
- package/src/ui/components/patterns/page.tsx +99 -139
- package/src/ui/components/patterns/presentation.tsx +539 -0
- package/src/ui/components/patterns/sidebar.tsx +3 -3
- package/src/ui/components/patterns/trigger.tsx +38 -17
- package/src/ui/components/primitives/button-group.tsx +53 -43
- package/src/ui/components/primitives/command.tsx +30 -72
- package/src/ui/components/primitives/dialog.tsx +23 -89
- package/src/ui/components/primitives/drawer.tsx +8 -34
- package/src/ui/docs/content/action-form-dialog.md +12 -10
- package/src/ui/docs/content/action-list-dialog.md +22 -17
- package/src/ui/docs/content/button.md +52 -35
- package/src/ui/docs/content/communication.md +26 -26
- package/src/ui/docs/content/dialog.md +173 -154
- package/src/ui/docs/content/drawer.md +12 -11
- package/src/ui/docs/content/page.md +72 -91
- package/src/ui/docs/content/presentation.md +188 -0
- package/src/ui/docs/registry.tsx +6 -0
- package/src/ui/meta.ts +8 -2
- package/src/ui/react.tsx +16 -3
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type ReactElement,
|
|
11
|
+
type ReactNode,
|
|
12
|
+
} from "react";
|
|
13
|
+
import { Braces, X } from "lucide-react";
|
|
14
|
+
import type {
|
|
15
|
+
PresentationDiagnostic,
|
|
16
|
+
PresentationDefinition,
|
|
17
|
+
PresentationInvocation,
|
|
18
|
+
PresentationSurface,
|
|
19
|
+
} from "../../../core/presentation.ts";
|
|
20
|
+
import { createPresentationInspectionSnapshot } from "../../../core/presentation.ts";
|
|
21
|
+
import { cn } from "../../lib/cn.ts";
|
|
22
|
+
import { Button, buttonVariants } from "../primitives/button.tsx";
|
|
23
|
+
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
24
|
+
import { Copyable } from "../primitives/copyable.tsx";
|
|
25
|
+
import {
|
|
26
|
+
Dialog,
|
|
27
|
+
DialogBody,
|
|
28
|
+
DialogClose,
|
|
29
|
+
DialogContent,
|
|
30
|
+
DialogFooter,
|
|
31
|
+
DialogHeader,
|
|
32
|
+
DialogTitle,
|
|
33
|
+
} from "../primitives/dialog.tsx";
|
|
34
|
+
import {
|
|
35
|
+
Menu,
|
|
36
|
+
MenuContent,
|
|
37
|
+
MenuItem,
|
|
38
|
+
MenuLabel,
|
|
39
|
+
MenuTrigger,
|
|
40
|
+
} from "../primitives/menu.tsx";
|
|
41
|
+
import {
|
|
42
|
+
Drawer,
|
|
43
|
+
DrawerBody,
|
|
44
|
+
DrawerClose,
|
|
45
|
+
DrawerContent,
|
|
46
|
+
DrawerFooter,
|
|
47
|
+
DrawerHeader,
|
|
48
|
+
DrawerTitle,
|
|
49
|
+
} from "../primitives/drawer.tsx";
|
|
50
|
+
import {
|
|
51
|
+
Page,
|
|
52
|
+
PageActions,
|
|
53
|
+
PageBody,
|
|
54
|
+
PageFooter,
|
|
55
|
+
PageHeader,
|
|
56
|
+
PageIntro,
|
|
57
|
+
PageNavigation,
|
|
58
|
+
PageTitle,
|
|
59
|
+
useInsidePageShell,
|
|
60
|
+
} from "./page.tsx";
|
|
61
|
+
|
|
62
|
+
interface PresentationBaseProps {
|
|
63
|
+
surface: PresentationSurface;
|
|
64
|
+
title: ReactNode;
|
|
65
|
+
navigation?: ReactNode;
|
|
66
|
+
headerActions?: ReactNode;
|
|
67
|
+
footerActions?: ReactNode;
|
|
68
|
+
children: ReactNode;
|
|
69
|
+
className?: string;
|
|
70
|
+
bodyClassName?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type PresentationProps = PresentationBaseProps &
|
|
74
|
+
(
|
|
75
|
+
| { open?: undefined; onOpenChange?: (open: boolean) => void }
|
|
76
|
+
| { open: boolean; onOpenChange: (open: boolean) => void }
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const bodyClassName =
|
|
80
|
+
"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
|
+
|
|
82
|
+
function PresentationActions({
|
|
83
|
+
children,
|
|
84
|
+
equal = false,
|
|
85
|
+
}: {
|
|
86
|
+
children: ReactNode;
|
|
87
|
+
equal?: boolean;
|
|
88
|
+
}) {
|
|
89
|
+
return (
|
|
90
|
+
<ButtonGroup mode="spaced" distribution={equal ? "equal" : "content"}>
|
|
91
|
+
{children}
|
|
92
|
+
</ButtonGroup>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
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
|
+
/** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
|
|
114
|
+
export function Presentation({
|
|
115
|
+
surface,
|
|
116
|
+
title,
|
|
117
|
+
navigation,
|
|
118
|
+
headerActions,
|
|
119
|
+
footerActions,
|
|
120
|
+
children,
|
|
121
|
+
open,
|
|
122
|
+
onOpenChange,
|
|
123
|
+
className,
|
|
124
|
+
bodyClassName: bodyClassNameProp,
|
|
125
|
+
}: PresentationProps): ReactElement {
|
|
126
|
+
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
|
+
}
|
|
133
|
+
const body = (
|
|
134
|
+
<div
|
|
135
|
+
data-slot="presentation-body"
|
|
136
|
+
className={cn(bodyClassName, bodyClassNameProp)}
|
|
137
|
+
>
|
|
138
|
+
{children}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (surface === "page") {
|
|
143
|
+
if (insidePageShell) {
|
|
144
|
+
return (
|
|
145
|
+
<Page className={cn("flex min-h-full flex-col", className)}>
|
|
146
|
+
<PageIntro>
|
|
147
|
+
{navigation === undefined ? null : (
|
|
148
|
+
<PageNavigation>{navigation}</PageNavigation>
|
|
149
|
+
)}
|
|
150
|
+
<PageTitle>{title}</PageTitle>
|
|
151
|
+
{headerActions === undefined ? null : (
|
|
152
|
+
<PageActions>
|
|
153
|
+
<PresentationActions>{headerActions}</PresentationActions>
|
|
154
|
+
</PageActions>
|
|
155
|
+
)}
|
|
156
|
+
</PageIntro>
|
|
157
|
+
<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
|
+
)}
|
|
163
|
+
</Page>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return (
|
|
167
|
+
<Page
|
|
168
|
+
className={cn(
|
|
169
|
+
"flex min-h-full max-w-none flex-col space-y-0 px-0 py-0",
|
|
170
|
+
className,
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
<PageHeader className="border-b px-3 py-4">
|
|
174
|
+
{navigation === undefined ? null : (
|
|
175
|
+
<PageNavigation>{navigation}</PageNavigation>
|
|
176
|
+
)}
|
|
177
|
+
<PageTitle className="text-lg leading-none">{title}</PageTitle>
|
|
178
|
+
{headerActions === undefined ? null : (
|
|
179
|
+
<PageActions>
|
|
180
|
+
<PresentationActions>{headerActions}</PresentationActions>
|
|
181
|
+
</PageActions>
|
|
182
|
+
)}
|
|
183
|
+
</PageHeader>
|
|
184
|
+
<PageBody className="min-h-0 flex-1 overflow-y-auto px-8 py-8">
|
|
185
|
+
{body}
|
|
186
|
+
</PageBody>
|
|
187
|
+
{footerActions === undefined ? null : (
|
|
188
|
+
<PageFooter>
|
|
189
|
+
<PresentationActions>{footerActions}</PresentationActions>
|
|
190
|
+
</PageFooter>
|
|
191
|
+
)}
|
|
192
|
+
</Page>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (surface === "dialog") {
|
|
197
|
+
return (
|
|
198
|
+
<Dialog open={modalOpen} onOpenChange={handleOpenChange}>
|
|
199
|
+
<DialogContent
|
|
200
|
+
className={className}
|
|
201
|
+
showCloseButton={false}
|
|
202
|
+
aria-describedby={undefined}
|
|
203
|
+
>
|
|
204
|
+
<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>
|
|
215
|
+
</DialogHeader>
|
|
216
|
+
<DialogBody className={bodyClassNameProp}>{body}</DialogBody>
|
|
217
|
+
{footerActions === undefined ? null : (
|
|
218
|
+
<DialogFooter>
|
|
219
|
+
<PresentationActions equal>{footerActions}</PresentationActions>
|
|
220
|
+
</DialogFooter>
|
|
221
|
+
)}
|
|
222
|
+
</DialogContent>
|
|
223
|
+
</Dialog>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Drawer open={modalOpen} onOpenChange={handleOpenChange}>
|
|
229
|
+
<DrawerContent
|
|
230
|
+
className={className}
|
|
231
|
+
showCloseButton={false}
|
|
232
|
+
aria-describedby={undefined}
|
|
233
|
+
>
|
|
234
|
+
<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>
|
|
245
|
+
</DrawerHeader>
|
|
246
|
+
<DrawerBody className={bodyClassNameProp}>{body}</DrawerBody>
|
|
247
|
+
{footerActions === undefined ? null : (
|
|
248
|
+
<DrawerFooter>
|
|
249
|
+
<PresentationActions equal>{footerActions}</PresentationActions>
|
|
250
|
+
</DrawerFooter>
|
|
251
|
+
)}
|
|
252
|
+
</DrawerContent>
|
|
253
|
+
</Drawer>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
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
|
+
> {
|
|
274
|
+
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;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
interface PresentationRegistrationContextValue {
|
|
285
|
+
upsert: (key: string, registration: ActivePresentationRegistration) => void;
|
|
286
|
+
remove: (key: string) => void;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
interface PresentationDevtoolsContextValue {
|
|
290
|
+
keys: readonly string[];
|
|
291
|
+
read: (key: string) => ActivePresentationRegistration | undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const PresentationRegistrationContext =
|
|
295
|
+
createContext<PresentationRegistrationContextValue | null>(null);
|
|
296
|
+
const PresentationDevtoolsContext =
|
|
297
|
+
createContext<PresentationDevtoolsContextValue | null>(null);
|
|
298
|
+
|
|
299
|
+
export interface PresentationDevtoolsProviderProps {
|
|
300
|
+
children: ReactNode;
|
|
301
|
+
}
|
|
302
|
+
|
|
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]);
|
|
316
|
+
},
|
|
317
|
+
[],
|
|
318
|
+
);
|
|
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),
|
|
325
|
+
[],
|
|
326
|
+
);
|
|
327
|
+
const registration = useMemo(() => ({ upsert, remove }), [upsert, remove]);
|
|
328
|
+
const devtools = useMemo(() => ({ keys, read }), [keys, read]);
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<PresentationRegistrationContext.Provider value={registration}>
|
|
332
|
+
<PresentationDevtoolsContext.Provider value={devtools}>
|
|
333
|
+
{children}
|
|
334
|
+
</PresentationDevtoolsContext.Provider>
|
|
335
|
+
</PresentationRegistrationContext.Provider>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
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();
|
|
351
|
+
|
|
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;
|
|
358
|
+
}
|
|
359
|
+
upsert(key, { key, definition, invocation, resolved, diagnostics });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
useEffect(
|
|
363
|
+
() => () => {
|
|
364
|
+
remove?.(key);
|
|
365
|
+
},
|
|
366
|
+
[key, remove],
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface SelectedPresentation {
|
|
371
|
+
key: string;
|
|
372
|
+
title: string;
|
|
373
|
+
json: string;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export interface PresentationDevtoolsProps {
|
|
377
|
+
/** Nome acessível e tooltip do launcher. */
|
|
378
|
+
triggerLabel?: string;
|
|
379
|
+
className?: string;
|
|
380
|
+
}
|
|
381
|
+
|
|
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
|
+
}
|
|
412
|
+
|
|
413
|
+
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}
|
|
425
|
+
>
|
|
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
|
+
</>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Ferramenta de desenvolvimento para conferir e copiar o artefato consumido pelo renderer. */
|
|
475
|
+
export function PresentationInspector({
|
|
476
|
+
definition,
|
|
477
|
+
invocation,
|
|
478
|
+
resolved,
|
|
479
|
+
diagnostics,
|
|
480
|
+
triggerLabel = "Ver JSON da Presentation",
|
|
481
|
+
title = "JSON da Presentation",
|
|
482
|
+
floating = false,
|
|
483
|
+
className,
|
|
484
|
+
}: PresentationInspectorProps): ReactElement {
|
|
485
|
+
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
|
+
|
|
504
|
+
return (
|
|
505
|
+
<>
|
|
506
|
+
<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)}
|
|
511
|
+
aria-label={triggerLabel}
|
|
512
|
+
title={triggerLabel}
|
|
513
|
+
onClick={inspect}
|
|
514
|
+
>
|
|
515
|
+
<Braces aria-hidden />
|
|
516
|
+
</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>
|
|
537
|
+
</>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
@@ -48,7 +48,7 @@ export function Sidebar({
|
|
|
48
48
|
className={cn(
|
|
49
49
|
"group/sidebar flex h-full min-h-0 shrink-0 flex-col transition-[width] duration-200 ease-out",
|
|
50
50
|
divider && "border-r border-border",
|
|
51
|
-
collapsed ? "w-
|
|
51
|
+
collapsed ? "w-16" : "w-64",
|
|
52
52
|
className,
|
|
53
53
|
)}
|
|
54
54
|
>
|
|
@@ -182,7 +182,7 @@ export function SidebarItem({
|
|
|
182
182
|
aria-current={active ? "page" : undefined}
|
|
183
183
|
onClick={onClick}
|
|
184
184
|
className={cn(
|
|
185
|
-
"mx-auto grid size-
|
|
185
|
+
"mx-auto grid size-10 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40",
|
|
186
186
|
"outline-none",
|
|
187
187
|
focusRing,
|
|
188
188
|
active
|
|
@@ -423,7 +423,7 @@ export function SidebarNav({
|
|
|
423
423
|
<nav
|
|
424
424
|
aria-label={navLabel}
|
|
425
425
|
data-slot="sidebar-nav"
|
|
426
|
-
className={cn("space-y-4 p-
|
|
426
|
+
className={cn("space-y-4 p-3", className)}
|
|
427
427
|
>
|
|
428
428
|
{groups.map((group, gi) =>
|
|
429
429
|
!groupFilled(group) ? null : (
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ReactNode } from 'react'
|
|
16
|
-
import { useState } from 'react'
|
|
16
|
+
import { useId, useState } from 'react'
|
|
17
17
|
import type { SimpleContract } from '../../../core/index.ts'
|
|
18
18
|
import { useTriggerAction } from '../../drivers/react.tsx'
|
|
19
19
|
import { toast } from '../primitives/sonner.tsx'
|
|
@@ -23,9 +23,9 @@ import { Button, type ButtonProps } from '../primitives/button.tsx'
|
|
|
23
23
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip.tsx'
|
|
24
24
|
import {
|
|
25
25
|
Dialog,
|
|
26
|
+
DialogBody,
|
|
26
27
|
DialogClose,
|
|
27
28
|
DialogContent,
|
|
28
|
-
DialogDescription,
|
|
29
29
|
DialogFooter,
|
|
30
30
|
DialogHeader,
|
|
31
31
|
DialogTitle,
|
|
@@ -59,7 +59,8 @@ export interface ActionTriggerProps<TInput, TData> {
|
|
|
59
59
|
* contrato — declarativo). Sem nenhum dos dois, dispara direto. */
|
|
60
60
|
confirm?: {
|
|
61
61
|
title: string
|
|
62
|
-
|
|
62
|
+
/** Conteúdo relevante apresentado antes das ações. */
|
|
63
|
+
body?: ReactNode
|
|
63
64
|
actionLabel?: string
|
|
64
65
|
cancelLabel?: string
|
|
65
66
|
}
|
|
@@ -88,6 +89,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
88
89
|
itemLabel,
|
|
89
90
|
className,
|
|
90
91
|
}: ActionTriggerProps<TInput, TData>) {
|
|
92
|
+
const confirmBodyId = useId()
|
|
91
93
|
const [open, setOpen] = useState(false)
|
|
92
94
|
const { trigger, isLoading } = useTriggerAction(action, {
|
|
93
95
|
onSuccess: (data) => {
|
|
@@ -98,7 +100,9 @@ export function ActionTrigger<TInput, TData>({
|
|
|
98
100
|
onError: (err) => {
|
|
99
101
|
// A frase de negócio do servidor vence o rótulo do contrato (ver lib/action-errors.ts);
|
|
100
102
|
// fora da allowlist, vale o rótulo — nunca o texto técnico cru.
|
|
101
|
-
toast.error(
|
|
103
|
+
toast.error(
|
|
104
|
+
humanizeActionError(err, msgText(action.messages?.error, 'Não foi possível concluir.')),
|
|
105
|
+
)
|
|
102
106
|
},
|
|
103
107
|
})
|
|
104
108
|
|
|
@@ -111,9 +115,13 @@ export function ActionTrigger<TInput, TData>({
|
|
|
111
115
|
(spec !== undefined
|
|
112
116
|
? {
|
|
113
117
|
title: msgText(spec.title, 'Confirmar?'),
|
|
114
|
-
...(spec.message !== undefined ? {
|
|
115
|
-
...(spec.confirmLabel !== undefined
|
|
116
|
-
|
|
118
|
+
...(spec.message !== undefined ? { body: msgText(spec.message, '') } : {}),
|
|
119
|
+
...(spec.confirmLabel !== undefined
|
|
120
|
+
? { actionLabel: msgText(spec.confirmLabel, buttonLabel) }
|
|
121
|
+
: {}),
|
|
122
|
+
...(spec.cancelLabel !== undefined
|
|
123
|
+
? { cancelLabel: msgText(spec.cancelLabel, 'Cancelar') }
|
|
124
|
+
: {}),
|
|
117
125
|
}
|
|
118
126
|
: undefined)
|
|
119
127
|
const destructive = spec?.destructive === true
|
|
@@ -126,7 +134,8 @@ export function ActionTrigger<TInput, TData>({
|
|
|
126
134
|
* botão é sempre `solid` — não herda o `variant` do gatilho, que descreve o item, não a
|
|
127
135
|
* decisão.
|
|
128
136
|
*/
|
|
129
|
-
const triggerContext =
|
|
137
|
+
const triggerContext =
|
|
138
|
+
context ?? (destructive ? 'danger' : icon !== undefined ? 'neutral' : 'primary')
|
|
130
139
|
const triggerVariant = variant ?? (icon !== undefined ? 'ghost' : 'solid')
|
|
131
140
|
const confirmContext = destructive ? 'danger' : (context ?? 'primary')
|
|
132
141
|
const confirmVariant = 'solid'
|
|
@@ -173,24 +182,36 @@ export function ActionTrigger<TInput, TData>({
|
|
|
173
182
|
{renderButton(() => setOpen(true))}
|
|
174
183
|
|
|
175
184
|
<Dialog mode="alert" open={open} onOpenChange={setOpen}>
|
|
176
|
-
<DialogContent
|
|
185
|
+
<DialogContent
|
|
186
|
+
data-action={action.name}
|
|
187
|
+
aria-describedby={
|
|
188
|
+
itemLabel !== undefined || confirm.body !== undefined ? confirmBodyId : undefined
|
|
189
|
+
}
|
|
190
|
+
>
|
|
177
191
|
<DialogHeader>
|
|
178
192
|
<DialogTitle>{confirm.title}</DialogTitle>
|
|
179
|
-
{(itemLabel !== undefined || confirm.description !== undefined) && (
|
|
180
|
-
<DialogDescription>
|
|
181
|
-
{itemLabel !== undefined && <span className="font-medium text-foreground">“{itemLabel}”</span>}
|
|
182
|
-
{itemLabel !== undefined && confirm.description !== undefined ? ' — ' : ''}
|
|
183
|
-
{confirm.description}
|
|
184
|
-
</DialogDescription>
|
|
185
|
-
)}
|
|
186
193
|
</DialogHeader>
|
|
194
|
+
{(itemLabel !== undefined || confirm.body !== undefined) && (
|
|
195
|
+
<DialogBody id={confirmBodyId} className="text-sm text-muted-foreground">
|
|
196
|
+
{itemLabel !== undefined && (
|
|
197
|
+
<span className="font-medium text-foreground">“{itemLabel}”</span>
|
|
198
|
+
)}
|
|
199
|
+
{itemLabel !== undefined && confirm.body !== undefined ? ' — ' : ''}
|
|
200
|
+
{confirm.body}
|
|
201
|
+
</DialogBody>
|
|
202
|
+
)}
|
|
187
203
|
<DialogFooter>
|
|
188
204
|
<DialogClose initialFocus asChild>
|
|
189
205
|
<Button context="neutral" variant="ghost" disabled={isLoading}>
|
|
190
206
|
{confirm.cancelLabel ?? 'Cancelar'}
|
|
191
207
|
</Button>
|
|
192
208
|
</DialogClose>
|
|
193
|
-
<Button
|
|
209
|
+
<Button
|
|
210
|
+
context={confirmContext}
|
|
211
|
+
variant={confirmVariant}
|
|
212
|
+
busy={isLoading}
|
|
213
|
+
onClick={fire}
|
|
214
|
+
>
|
|
194
215
|
{confirm.actionLabel ?? buttonLabel}
|
|
195
216
|
</Button>
|
|
196
217
|
</DialogFooter>
|