@softize/opus 18.1.1 → 18.3.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 +35 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +3 -2
- package/registry/skills/build-opus-ui/references/ui-patterns.md +4 -3
- package/src/core/errors.ts +0 -1
- package/src/core/presentation.ts +143 -58
- package/src/core/types.ts +4 -0
- package/src/ui/components/patterns/data-state.tsx +1 -1
- package/src/ui/components/patterns/list.tsx +606 -251
- package/src/ui/components/patterns/page.tsx +16 -5
- package/src/ui/components/patterns/presentation.tsx +228 -63
- package/src/ui/components/patterns/split.tsx +5 -2
- package/src/ui/components/patterns/surface-assistant.tsx +74 -0
- package/src/ui/components/primitives/field.tsx +23 -2
- package/src/ui/components/primitives/metric-card.tsx +64 -5
- package/src/ui/components/primitives/select.tsx +27 -23
- package/src/ui/components/primitives/table.tsx +4 -1
- package/src/ui/docs/content/action-list.md +84 -46
- package/src/ui/docs/content/field.md +6 -1
- package/src/ui/docs/content/metric-card.md +43 -0
- package/src/ui/docs/content/presentation.md +40 -14
- package/src/ui/docs/content/select.md +4 -0
- package/src/ui/docs/content/split.md +80 -19
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +6 -0
|
@@ -164,7 +164,10 @@ export function PageShell({
|
|
|
164
164
|
<PageShellContext.Provider value>
|
|
165
165
|
<PageNavigationTargetContext.Provider value={navigationTarget}>
|
|
166
166
|
<PageActionsTarget target={actionsTarget}>
|
|
167
|
-
<div
|
|
167
|
+
<div
|
|
168
|
+
data-slot="page-shell-content"
|
|
169
|
+
className="min-h-0 min-w-0 flex-1 overflow-auto [&>*]:min-w-[64rem]"
|
|
170
|
+
>
|
|
168
171
|
{children}
|
|
169
172
|
</div>
|
|
170
173
|
</PageActionsTarget>
|
|
@@ -202,6 +205,8 @@ interface PageBaseProps extends Omit<
|
|
|
202
205
|
> {
|
|
203
206
|
/** Classes do container. Default: centralizado com `max-w-7xl` (80rem). */
|
|
204
207
|
className?: string;
|
|
208
|
+
/** `fill` limita a página à altura do shell para que regiões internas assumam a rolagem. */
|
|
209
|
+
layout?: "flow" | "fill";
|
|
205
210
|
}
|
|
206
211
|
|
|
207
212
|
interface PageShorthandProps extends PageBaseProps {
|
|
@@ -252,8 +257,7 @@ function countPageBodyHeadings(node: ReactNode): {
|
|
|
252
257
|
total.content +
|
|
253
258
|
(child.type === Content && props.level === 1 ? 1 : 0) +
|
|
254
259
|
nested.content,
|
|
255
|
-
state:
|
|
256
|
-
total.state + nested.state,
|
|
260
|
+
state: total.state + nested.state,
|
|
257
261
|
};
|
|
258
262
|
},
|
|
259
263
|
{ content: 0, state: 0 },
|
|
@@ -275,6 +279,7 @@ function hasOpaquePageBodyContent(node: ReactNode): boolean {
|
|
|
275
279
|
export function Page({
|
|
276
280
|
title,
|
|
277
281
|
actions,
|
|
282
|
+
layout = "flow",
|
|
278
283
|
className,
|
|
279
284
|
children,
|
|
280
285
|
...props
|
|
@@ -372,7 +377,8 @@ export function Page({
|
|
|
372
377
|
(total, intro) =>
|
|
373
378
|
total + countDirectChildrenOfType(intro as ReactElement, PageTitle),
|
|
374
379
|
0,
|
|
375
|
-
) +
|
|
380
|
+
) +
|
|
381
|
+
bodyHeadingSources.content;
|
|
376
382
|
const headingSources =
|
|
377
383
|
bodyHeadingSources.state > 0
|
|
378
384
|
? bodyHeadingSources.state + bodyHeadingSources.content
|
|
@@ -429,9 +435,12 @@ export function Page({
|
|
|
429
435
|
<PageHeadingPresenceProvider register={registerHeading}>
|
|
430
436
|
<main
|
|
431
437
|
data-slot="page"
|
|
438
|
+
data-layout={layout}
|
|
432
439
|
className={cn(
|
|
433
|
-
"min-w-
|
|
440
|
+
"min-w-[64rem] flex-1",
|
|
434
441
|
insideShell && "min-h-full",
|
|
442
|
+
layout === "fill" &&
|
|
443
|
+
"flex h-full min-h-0 flex-col overflow-hidden",
|
|
435
444
|
integralState && "flex min-h-full flex-col",
|
|
436
445
|
)}
|
|
437
446
|
{...props}
|
|
@@ -439,6 +448,8 @@ export function Page({
|
|
|
439
448
|
<div
|
|
440
449
|
className={cn(
|
|
441
450
|
"mx-auto max-w-7xl space-y-6 px-8 py-8",
|
|
451
|
+
layout === "fill" &&
|
|
452
|
+
"flex h-full min-h-0 w-full flex-1 flex-col",
|
|
442
453
|
integralState &&
|
|
443
454
|
"flex min-h-full w-full flex-1 flex-col space-y-0",
|
|
444
455
|
className,
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
useCallback,
|
|
3
|
+
useEffect,
|
|
3
4
|
useState,
|
|
4
5
|
type ReactElement,
|
|
5
6
|
type ReactNode,
|
|
6
7
|
} from "react";
|
|
7
8
|
import { createPortal } from "react-dom";
|
|
8
|
-
import { ArrowLeft, Braces } from "lucide-react";
|
|
9
|
+
import { ArrowLeft, Braces, MessageCircle } from "lucide-react";
|
|
9
10
|
import type {
|
|
10
11
|
PresentationActionRegistry,
|
|
12
|
+
PresentationActionBody,
|
|
11
13
|
PresentationBindingContext,
|
|
12
14
|
PresentationCommand,
|
|
13
15
|
PresentationDiagnostic,
|
|
@@ -19,6 +21,7 @@ import type {
|
|
|
19
21
|
import {
|
|
20
22
|
applyPresentationEffects,
|
|
21
23
|
createPresentationInspectionSnapshot,
|
|
24
|
+
isPresentationActionBody,
|
|
22
25
|
openPresentation,
|
|
23
26
|
resolvePresentationBindings,
|
|
24
27
|
} from "../../../core/presentation.ts";
|
|
@@ -75,9 +78,11 @@ import {
|
|
|
75
78
|
ContentHeader,
|
|
76
79
|
ContentTitle,
|
|
77
80
|
} from "./content-header.tsx";
|
|
81
|
+
import { SurfaceAssistant } from "./surface-assistant.tsx";
|
|
78
82
|
|
|
79
83
|
interface PresentationFrameProps {
|
|
80
84
|
surface: PresentationSurface;
|
|
85
|
+
layout?: "flow" | "fill";
|
|
81
86
|
title: ReactNode;
|
|
82
87
|
navigation?: ReactNode;
|
|
83
88
|
headerActions?: ReactNode;
|
|
@@ -90,6 +95,11 @@ interface PresentationFrameProps {
|
|
|
90
95
|
className?: string;
|
|
91
96
|
bodyClassName?: string;
|
|
92
97
|
listBody?: boolean;
|
|
98
|
+
contentOwnsHeading?: boolean;
|
|
99
|
+
assistantOpen?: boolean;
|
|
100
|
+
assistantLabel?: string;
|
|
101
|
+
assistantAction?: ReactNode;
|
|
102
|
+
assistantPanel?: ReactNode;
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
const bodyClassName =
|
|
@@ -112,6 +122,7 @@ function PresentationActions({
|
|
|
112
122
|
/** A mesma Presentation em Page, Dialog ou Drawer; a superfície não redefine o recurso. */
|
|
113
123
|
function PresentationFrame({
|
|
114
124
|
surface,
|
|
125
|
+
layout = "flow",
|
|
115
126
|
title,
|
|
116
127
|
navigation,
|
|
117
128
|
headerActions,
|
|
@@ -124,6 +135,11 @@ function PresentationFrame({
|
|
|
124
135
|
className,
|
|
125
136
|
bodyClassName: bodyClassNameProp,
|
|
126
137
|
listBody = false,
|
|
138
|
+
contentOwnsHeading = false,
|
|
139
|
+
assistantOpen = false,
|
|
140
|
+
assistantLabel,
|
|
141
|
+
assistantAction,
|
|
142
|
+
assistantPanel,
|
|
127
143
|
}: PresentationFrameProps): ReactElement {
|
|
128
144
|
const insidePageShell = useInsidePageShell();
|
|
129
145
|
const [footerTarget, setFooterTarget] = useState<HTMLElement | null>(null);
|
|
@@ -148,11 +164,29 @@ function PresentationFrame({
|
|
|
148
164
|
const body = (
|
|
149
165
|
<div
|
|
150
166
|
data-slot="presentation-body"
|
|
151
|
-
className={cn(
|
|
167
|
+
className={cn(
|
|
168
|
+
bodyClassName,
|
|
169
|
+
layout === "fill" && "flex min-h-0 flex-1 flex-col",
|
|
170
|
+
bodyClassNameProp,
|
|
171
|
+
)}
|
|
152
172
|
>
|
|
153
173
|
{typeof children === "function" ? children(footerTarget) : children}
|
|
154
174
|
</div>
|
|
155
175
|
);
|
|
176
|
+
const withAssistant = (content: ReactElement): ReactElement =>
|
|
177
|
+
assistantLabel === undefined || assistantPanel === undefined ? (
|
|
178
|
+
content
|
|
179
|
+
) : (
|
|
180
|
+
<SurfaceAssistant
|
|
181
|
+
open={assistantOpen}
|
|
182
|
+
assistantLabel={assistantLabel}
|
|
183
|
+
assistant={assistantPanel}
|
|
184
|
+
className="min-h-0 flex-1"
|
|
185
|
+
contentClassName="flex min-h-0 flex-col"
|
|
186
|
+
>
|
|
187
|
+
{content}
|
|
188
|
+
</SurfaceAssistant>
|
|
189
|
+
);
|
|
156
190
|
|
|
157
191
|
if (surface === "page") {
|
|
158
192
|
if (listBody) {
|
|
@@ -170,14 +204,26 @@ function PresentationFrame({
|
|
|
170
204
|
</Content>
|
|
171
205
|
);
|
|
172
206
|
return (
|
|
173
|
-
<Page
|
|
174
|
-
{
|
|
207
|
+
<Page
|
|
208
|
+
layout={layout}
|
|
209
|
+
className={cn("flex min-h-full flex-col", className)}
|
|
210
|
+
>
|
|
211
|
+
{navigation === undefined && assistantAction === undefined ? null : (
|
|
175
212
|
<PageHeader>
|
|
176
|
-
|
|
213
|
+
{navigation === undefined ? null : (
|
|
214
|
+
<PageNavigation>{navigation}</PageNavigation>
|
|
215
|
+
)}
|
|
216
|
+
{assistantAction === undefined ? null : (
|
|
217
|
+
<PageActions>{assistantAction}</PageActions>
|
|
218
|
+
)}
|
|
177
219
|
</PageHeader>
|
|
178
220
|
)}
|
|
179
|
-
<PageBody className="min-h-0 flex-1 overflow-
|
|
180
|
-
{
|
|
221
|
+
<PageBody className="min-h-0 flex-1 overflow-hidden">
|
|
222
|
+
{withAssistant(
|
|
223
|
+
<div className="h-full overflow-x-hidden overflow-y-auto">
|
|
224
|
+
{content}
|
|
225
|
+
</div>,
|
|
226
|
+
)}
|
|
181
227
|
</PageBody>
|
|
182
228
|
{hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
|
|
183
229
|
</Page>
|
|
@@ -185,29 +231,52 @@ function PresentationFrame({
|
|
|
185
231
|
}
|
|
186
232
|
if (insidePageShell) {
|
|
187
233
|
return (
|
|
188
|
-
<Page
|
|
189
|
-
{
|
|
234
|
+
<Page
|
|
235
|
+
layout={layout}
|
|
236
|
+
className={cn("flex min-h-full flex-col", className)}
|
|
237
|
+
>
|
|
238
|
+
{navigation === undefined &&
|
|
239
|
+
headerActions === undefined &&
|
|
240
|
+
assistantAction === undefined ? null : (
|
|
190
241
|
<PageHeader>
|
|
191
242
|
{navigation === undefined ? null : (
|
|
192
243
|
<PageNavigation>{navigation}</PageNavigation>
|
|
193
244
|
)}
|
|
194
|
-
{headerActions === undefined
|
|
245
|
+
{headerActions === undefined &&
|
|
246
|
+
assistantAction === undefined ? null : (
|
|
195
247
|
<PageActions>
|
|
196
|
-
<PresentationActions>
|
|
248
|
+
<PresentationActions>
|
|
249
|
+
{headerActions}
|
|
250
|
+
{assistantAction}
|
|
251
|
+
</PresentationActions>
|
|
197
252
|
</PageActions>
|
|
198
253
|
)}
|
|
199
254
|
</PageHeader>
|
|
200
255
|
)}
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
256
|
+
<PageBody className="min-h-0 flex-1 overflow-hidden">
|
|
257
|
+
{withAssistant(
|
|
258
|
+
<div
|
|
259
|
+
className={cn(
|
|
260
|
+
"flex h-full min-h-0 flex-col overflow-x-hidden",
|
|
261
|
+
layout === "fill" ? "overflow-y-hidden" : "overflow-y-auto",
|
|
262
|
+
)}
|
|
263
|
+
>
|
|
264
|
+
{contentOwnsHeading ? null : (
|
|
265
|
+
<PageIntro>
|
|
266
|
+
<PageTitle>{title}</PageTitle>
|
|
267
|
+
</PageIntro>
|
|
268
|
+
)}
|
|
269
|
+
{body}
|
|
270
|
+
</div>,
|
|
271
|
+
)}
|
|
272
|
+
</PageBody>
|
|
205
273
|
{hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
|
|
206
274
|
</Page>
|
|
207
275
|
);
|
|
208
276
|
}
|
|
209
277
|
return (
|
|
210
278
|
<Page
|
|
279
|
+
layout={layout}
|
|
211
280
|
className={cn(
|
|
212
281
|
"flex min-h-full max-w-none flex-col space-y-0 px-0 py-0",
|
|
213
282
|
className,
|
|
@@ -218,14 +287,22 @@ function PresentationFrame({
|
|
|
218
287
|
<PageNavigation>{navigation}</PageNavigation>
|
|
219
288
|
)}
|
|
220
289
|
<PageTitle className="text-lg leading-none">{title}</PageTitle>
|
|
221
|
-
{headerActions === undefined
|
|
290
|
+
{headerActions === undefined &&
|
|
291
|
+
assistantAction === undefined ? null : (
|
|
222
292
|
<PageActions>
|
|
223
|
-
<PresentationActions>
|
|
293
|
+
<PresentationActions>
|
|
294
|
+
{headerActions}
|
|
295
|
+
{assistantAction}
|
|
296
|
+
</PresentationActions>
|
|
224
297
|
</PageActions>
|
|
225
298
|
)}
|
|
226
299
|
</PageHeader>
|
|
227
|
-
<PageBody className="min-h-0 flex-1 overflow-
|
|
228
|
-
{
|
|
300
|
+
<PageBody className="min-h-0 flex-1 overflow-hidden">
|
|
301
|
+
{withAssistant(
|
|
302
|
+
<div className="h-full overflow-x-hidden overflow-y-auto px-8 py-8">
|
|
303
|
+
{body}
|
|
304
|
+
</div>,
|
|
305
|
+
)}
|
|
229
306
|
</PageBody>
|
|
230
307
|
{hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
|
|
231
308
|
</Page>
|
|
@@ -242,7 +319,10 @@ function PresentationFrame({
|
|
|
242
319
|
}}
|
|
243
320
|
>
|
|
244
321
|
<DialogContent
|
|
245
|
-
className={
|
|
322
|
+
className={cn(
|
|
323
|
+
assistantOpen ? "h-[min(48rem,85vh)] max-w-5xl" : undefined,
|
|
324
|
+
className,
|
|
325
|
+
)}
|
|
246
326
|
aria-describedby={undefined}
|
|
247
327
|
closeDisabled={blocked}
|
|
248
328
|
onEscapeKeyDown={(event) => {
|
|
@@ -252,17 +332,23 @@ function PresentationFrame({
|
|
|
252
332
|
if (blocked) event.preventDefault();
|
|
253
333
|
}}
|
|
254
334
|
>
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
335
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
336
|
+
<DialogHeader>
|
|
337
|
+
{navigation}
|
|
338
|
+
<DialogTitle className="truncate">{title}</DialogTitle>
|
|
339
|
+
{headerActions === undefined &&
|
|
340
|
+
assistantAction === undefined ? null : (
|
|
341
|
+
<PresentationActions>
|
|
342
|
+
{headerActions}
|
|
343
|
+
{assistantAction}
|
|
344
|
+
</PresentationActions>
|
|
345
|
+
)}
|
|
346
|
+
</DialogHeader>
|
|
347
|
+
{withAssistant(<DialogBody>{body}</DialogBody>)}
|
|
348
|
+
{hasFooter ? (
|
|
349
|
+
<DialogFooter>{renderFooterActions()}</DialogFooter>
|
|
350
|
+
) : null}
|
|
351
|
+
</div>
|
|
266
352
|
</DialogContent>
|
|
267
353
|
</Dialog>
|
|
268
354
|
);
|
|
@@ -277,7 +363,12 @@ function PresentationFrame({
|
|
|
277
363
|
}}
|
|
278
364
|
>
|
|
279
365
|
<DrawerContent
|
|
280
|
-
className={
|
|
366
|
+
className={cn(
|
|
367
|
+
assistantOpen
|
|
368
|
+
? "w-[min(80rem,calc(100%-2rem))] max-w-none"
|
|
369
|
+
: undefined,
|
|
370
|
+
className,
|
|
371
|
+
)}
|
|
281
372
|
aria-describedby={undefined}
|
|
282
373
|
closeDisabled={blocked}
|
|
283
374
|
onEscapeKeyDown={(event) => {
|
|
@@ -287,22 +378,33 @@ function PresentationFrame({
|
|
|
287
378
|
if (blocked) event.preventDefault();
|
|
288
379
|
}}
|
|
289
380
|
>
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
381
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
382
|
+
<DrawerHeader>
|
|
383
|
+
{navigation}
|
|
384
|
+
<DrawerTitle className="truncate">{title}</DrawerTitle>
|
|
385
|
+
{headerActions === undefined &&
|
|
386
|
+
assistantAction === undefined ? null : (
|
|
387
|
+
<PresentationActions>
|
|
388
|
+
{headerActions}
|
|
389
|
+
{assistantAction}
|
|
390
|
+
</PresentationActions>
|
|
391
|
+
)}
|
|
392
|
+
</DrawerHeader>
|
|
393
|
+
{withAssistant(<DrawerBody>{body}</DrawerBody>)}
|
|
394
|
+
{hasFooter ? (
|
|
395
|
+
<DrawerFooter>{renderFooterActions()}</DrawerFooter>
|
|
396
|
+
) : null}
|
|
397
|
+
</div>
|
|
301
398
|
</DrawerContent>
|
|
302
399
|
</Drawer>
|
|
303
400
|
);
|
|
304
401
|
}
|
|
305
402
|
|
|
403
|
+
export interface PresentationAssistantRuntime {
|
|
404
|
+
icon?: ReactNode;
|
|
405
|
+
render: (controls: { close: () => void }) => ReactNode;
|
|
406
|
+
}
|
|
407
|
+
|
|
306
408
|
export interface PresentationProps {
|
|
307
409
|
/** Definição estática registrada e publicada no manifest. */
|
|
308
410
|
definition: PresentationDefinition;
|
|
@@ -325,6 +427,12 @@ export interface PresentationProps {
|
|
|
325
427
|
onOpenChange?: (open: boolean) => void;
|
|
326
428
|
className?: string;
|
|
327
429
|
bodyClassName?: string;
|
|
430
|
+
/** `fill` limita a página ao shell e delega a rolagem ao conteúdo React. */
|
|
431
|
+
layout?: "flow" | "fill";
|
|
432
|
+
/** Conteúdo React da boundary declarada por `body.component`. */
|
|
433
|
+
children?: ReactNode;
|
|
434
|
+
/** Painel runtime; a página fecha sobre os dados do recurso que ela carregou. */
|
|
435
|
+
assistant?: PresentationAssistantRuntime;
|
|
328
436
|
}
|
|
329
437
|
|
|
330
438
|
function actionLabel(action: { name: string; label?: unknown }): string {
|
|
@@ -394,6 +502,9 @@ export function Presentation({
|
|
|
394
502
|
onOpenChange,
|
|
395
503
|
className,
|
|
396
504
|
bodyClassName: bodyClassNameProp,
|
|
505
|
+
layout = "flow",
|
|
506
|
+
children,
|
|
507
|
+
assistant,
|
|
397
508
|
}: PresentationProps): ReactElement {
|
|
398
509
|
if (definition.id !== invocation.presentationId) {
|
|
399
510
|
throw new Error(
|
|
@@ -404,16 +515,17 @@ export function Presentation({
|
|
|
404
515
|
ReadonlySet<PresentationCommand>
|
|
405
516
|
>(() => new Set());
|
|
406
517
|
const [bodyLoading, setBodyLoading] = useState(false);
|
|
518
|
+
const [assistantOpen, setAssistantOpen] = useState(false);
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
setAssistantOpen(false);
|
|
521
|
+
}, [definition.id, invocation.surface]);
|
|
407
522
|
const context: PresentationBindingContext = {
|
|
408
523
|
...bindingContext,
|
|
409
524
|
route: bindingContext?.route ?? invocation.input,
|
|
410
525
|
};
|
|
411
526
|
|
|
412
527
|
const applyEffects = useCallback(
|
|
413
|
-
(
|
|
414
|
-
effects: PresentationDefinition["body"]["onSuccess"],
|
|
415
|
-
result?: unknown,
|
|
416
|
-
): void => {
|
|
528
|
+
(effects: PresentationActionBody["onSuccess"], result?: unknown): void => {
|
|
417
529
|
const next = applyPresentationEffects(
|
|
418
530
|
invocation,
|
|
419
531
|
effects,
|
|
@@ -541,23 +653,51 @@ export function Presentation({
|
|
|
541
653
|
);
|
|
542
654
|
};
|
|
543
655
|
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
656
|
+
const actionBody = isPresentationActionBody(definition.body)
|
|
657
|
+
? definition.body
|
|
658
|
+
: null;
|
|
659
|
+
const bodyAction = actionBody === null ? null : actions[actionBody.action];
|
|
660
|
+
if (actionBody !== null && bodyAction === undefined) {
|
|
661
|
+
throw new Error(`Action “${actionBody.action}” não registrada.`);
|
|
662
|
+
}
|
|
663
|
+
if (actionBody === null && children === undefined) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`A Presentation “${definition.id}” declara body.component e exige children.`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (actionBody !== null && children !== undefined) {
|
|
669
|
+
throw new Error(
|
|
670
|
+
`A Presentation “${definition.id}” usa body.action e não aceita children.`,
|
|
671
|
+
);
|
|
547
672
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
673
|
+
if (definition.assistant !== undefined && assistant === undefined) {
|
|
674
|
+
throw new Error(
|
|
675
|
+
`A Presentation “${definition.id}” declara assistência e exige a prop assistant.`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
if (definition.assistant === undefined && assistant !== undefined) {
|
|
679
|
+
throw new Error(
|
|
680
|
+
`A Presentation “${definition.id}” não declara assistência e não aceita a prop assistant.`,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
const bodyInput =
|
|
684
|
+
actionBody === null
|
|
685
|
+
? {}
|
|
686
|
+
: (resolvePresentationBindings(actionBody.input, context) as Record<
|
|
687
|
+
string,
|
|
688
|
+
unknown
|
|
689
|
+
>);
|
|
552
690
|
const renderBody = (footerTarget: HTMLElement | null): ReactNode => {
|
|
691
|
+
if (actionBody === null) return children;
|
|
692
|
+
if (bodyAction === null) return null;
|
|
553
693
|
if (bodyAction.kind === "form") {
|
|
554
694
|
return (
|
|
555
695
|
<ActionForm
|
|
556
696
|
action={bodyAction as FormContract<Record<string, unknown>, unknown>}
|
|
557
697
|
defaultValues={bodyInput}
|
|
558
|
-
submitLabel={
|
|
698
|
+
submitLabel={actionBody.submitLabel}
|
|
559
699
|
onCancel={invocation.surface === "page" ? undefined : closeSurface}
|
|
560
|
-
onSuccess={(data) => applyEffects(
|
|
700
|
+
onSuccess={(data) => applyEffects(actionBody.onSuccess, data)}
|
|
561
701
|
disabled={hasSurfaceBlock}
|
|
562
702
|
onLoadingChange={setBodyLoading}
|
|
563
703
|
footer={(actions) =>
|
|
@@ -578,14 +718,14 @@ export function Presentation({
|
|
|
578
718
|
input={bodyInput}
|
|
579
719
|
state={listState}
|
|
580
720
|
onStateChange={onListStateChange}
|
|
581
|
-
{...(
|
|
721
|
+
{...(actionBody.open === undefined
|
|
582
722
|
? {}
|
|
583
723
|
: {
|
|
584
724
|
onRowClick: (item: Record<string, unknown>) =>
|
|
585
725
|
openTarget(
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
726
|
+
actionBody.open!.presentation,
|
|
727
|
+
actionBody.open!.surface,
|
|
728
|
+
actionBody.open!.input,
|
|
589
729
|
{ ...context, item },
|
|
590
730
|
),
|
|
591
731
|
})}
|
|
@@ -593,7 +733,7 @@ export function Presentation({
|
|
|
593
733
|
);
|
|
594
734
|
}
|
|
595
735
|
if (bodyAction.kind === "view") {
|
|
596
|
-
const fields =
|
|
736
|
+
const fields = actionBody.fields ?? [];
|
|
597
737
|
return (
|
|
598
738
|
<ActionView
|
|
599
739
|
action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
|
|
@@ -641,10 +781,27 @@ export function Presentation({
|
|
|
641
781
|
<ArrowLeft aria-hidden />
|
|
642
782
|
</Button>
|
|
643
783
|
);
|
|
784
|
+
const assistantAction =
|
|
785
|
+
definition.assistant === undefined || assistantOpen ? undefined : (
|
|
786
|
+
<Button
|
|
787
|
+
size="icon"
|
|
788
|
+
variant="ghost"
|
|
789
|
+
aria-label={definition.assistant.triggerLabel}
|
|
790
|
+
title={definition.assistant.triggerLabel}
|
|
791
|
+
onClick={() => setAssistantOpen(true)}
|
|
792
|
+
>
|
|
793
|
+
{assistant?.icon ?? <MessageCircle aria-hidden />}
|
|
794
|
+
</Button>
|
|
795
|
+
);
|
|
796
|
+
const assistantPanel =
|
|
797
|
+
definition.assistant === undefined || assistant === undefined
|
|
798
|
+
? undefined
|
|
799
|
+
: assistant.render({ close: () => setAssistantOpen(false) });
|
|
644
800
|
|
|
645
801
|
return (
|
|
646
802
|
<PresentationFrame
|
|
647
803
|
surface={invocation.surface}
|
|
804
|
+
layout={layout}
|
|
648
805
|
title={definition.title}
|
|
649
806
|
navigation={navigation}
|
|
650
807
|
headerActions={headerActions.length === 0 ? undefined : headerActions}
|
|
@@ -659,9 +816,17 @@ export function Presentation({
|
|
|
659
816
|
}}
|
|
660
817
|
className={className}
|
|
661
818
|
bodyClassName={bodyClassNameProp}
|
|
662
|
-
hasBodyFooter={bodyAction
|
|
819
|
+
hasBodyFooter={bodyAction?.kind === "form"}
|
|
663
820
|
blocked={surfaceBlocked}
|
|
664
|
-
listBody={bodyAction
|
|
821
|
+
listBody={bodyAction?.kind === "list"}
|
|
822
|
+
contentOwnsHeading={
|
|
823
|
+
!isPresentationActionBody(definition.body) &&
|
|
824
|
+
definition.body.heading === "content"
|
|
825
|
+
}
|
|
826
|
+
assistantOpen={assistantOpen}
|
|
827
|
+
assistantLabel={definition.assistant?.triggerLabel}
|
|
828
|
+
assistantAction={assistantAction}
|
|
829
|
+
assistantPanel={assistantPanel}
|
|
665
830
|
>
|
|
666
831
|
{renderBody}
|
|
667
832
|
</PresentationFrame>
|
|
@@ -48,6 +48,8 @@ export interface SplitProps {
|
|
|
48
48
|
/** Chamado ao concluir uma mudança de layout; pode persistir o resultado em localStorage. */
|
|
49
49
|
onLayoutChanged?: (layout: SplitLayout, meta: SplitLayoutChange) => void
|
|
50
50
|
className?: string
|
|
51
|
+
/** Identificador semântico opcional para testes e composição de patterns. */
|
|
52
|
+
'data-slot'?: string
|
|
51
53
|
children: ReactNode
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -86,6 +88,7 @@ export function Split({
|
|
|
86
88
|
defaultLayout,
|
|
87
89
|
onLayoutChanged,
|
|
88
90
|
className,
|
|
91
|
+
'data-slot': dataSlot = 'split',
|
|
89
92
|
children,
|
|
90
93
|
}: SplitProps): React.ReactElement {
|
|
91
94
|
const panes = panesOf(children)
|
|
@@ -93,7 +96,7 @@ export function Split({
|
|
|
93
96
|
|
|
94
97
|
if (!resizable) {
|
|
95
98
|
return (
|
|
96
|
-
<div id={id === undefined ? undefined : String(id)} data-slot=
|
|
99
|
+
<div id={id === undefined ? undefined : String(id)} data-slot={dataSlot} data-direction={direction} className={cn('flex min-h-0 min-w-0 flex-1', vertical && 'flex-col', className)}>
|
|
97
100
|
{panes}
|
|
98
101
|
</div>
|
|
99
102
|
)
|
|
@@ -111,7 +114,7 @@ export function Split({
|
|
|
111
114
|
orientation={direction}
|
|
112
115
|
defaultLayout={defaultLayout}
|
|
113
116
|
onLayoutChanged={onLayoutChanged}
|
|
114
|
-
data-slot=
|
|
117
|
+
data-slot={dataSlot}
|
|
115
118
|
className={cn('min-h-0 min-w-0 flex-1', className)}
|
|
116
119
|
>
|
|
117
120
|
{panes.flatMap((pane, index) => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn.ts";
|
|
3
|
+
import { Pane, type PaneSize, Split } from "./split.tsx";
|
|
4
|
+
|
|
5
|
+
export interface SurfaceAssistantProps {
|
|
6
|
+
/** Controla somente a presença visual do painel; o conteúdo pertence ao consumidor. */
|
|
7
|
+
open: boolean;
|
|
8
|
+
/** Nome acessível da região complementar. */
|
|
9
|
+
assistantLabel: string;
|
|
10
|
+
/** Conteúdo principal que permanece visível à esquerda. */
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
/** Painel fornecido pela superfície que conhece o contexto do recurso. */
|
|
13
|
+
assistant: ReactNode;
|
|
14
|
+
initialSize?: PaneSize;
|
|
15
|
+
minSize?: PaneSize;
|
|
16
|
+
maxSize?: PaneSize;
|
|
17
|
+
className?: string;
|
|
18
|
+
contentClassName?: string;
|
|
19
|
+
assistantClassName?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mantém conteúdo e assistência contextual na mesma superfície. O host decide abertura,
|
|
24
|
+
* trigger e dados; o pattern preserva a relação espacial e o redimensionamento.
|
|
25
|
+
*/
|
|
26
|
+
export function SurfaceAssistant({
|
|
27
|
+
open,
|
|
28
|
+
assistantLabel,
|
|
29
|
+
children,
|
|
30
|
+
assistant,
|
|
31
|
+
initialSize = "28rem",
|
|
32
|
+
minSize = "20rem",
|
|
33
|
+
maxSize = "40rem",
|
|
34
|
+
className,
|
|
35
|
+
contentClassName,
|
|
36
|
+
assistantClassName,
|
|
37
|
+
}: SurfaceAssistantProps): React.ReactElement {
|
|
38
|
+
return (
|
|
39
|
+
<Split
|
|
40
|
+
resizable
|
|
41
|
+
data-slot="surface-assistant"
|
|
42
|
+
className={cn("h-full", className)}
|
|
43
|
+
>
|
|
44
|
+
<Pane
|
|
45
|
+
key="surface-content"
|
|
46
|
+
id="surface-content"
|
|
47
|
+
grow
|
|
48
|
+
inset="none"
|
|
49
|
+
className={cn("h-full", contentClassName)}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</Pane>
|
|
53
|
+
{open ? (
|
|
54
|
+
<Pane
|
|
55
|
+
key="surface-assistant"
|
|
56
|
+
id="surface-assistant"
|
|
57
|
+
initialSize={initialSize}
|
|
58
|
+
minSize={minSize}
|
|
59
|
+
maxSize={maxSize}
|
|
60
|
+
inset="none"
|
|
61
|
+
className={cn("h-full bg-background", assistantClassName)}
|
|
62
|
+
>
|
|
63
|
+
<aside
|
|
64
|
+
data-slot="surface-assistant-panel"
|
|
65
|
+
aria-label={assistantLabel}
|
|
66
|
+
className="h-full min-w-0 overflow-hidden"
|
|
67
|
+
>
|
|
68
|
+
{assistant}
|
|
69
|
+
</aside>
|
|
70
|
+
</Pane>
|
|
71
|
+
) : null}
|
|
72
|
+
</Split>
|
|
73
|
+
);
|
|
74
|
+
}
|