@softize/opus 18.1.0 → 18.2.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 +75 -0
- package/PROMOTED.md +4 -5
- package/README.md +5 -4
- package/bin/cli.mjs +4 -0
- package/docs/adr/0004-page-content-state-is-composed.md +3 -0
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +6 -2
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +6 -1
- package/docs/adr/0014-structural-headers-do-not-carry-description.md +2 -2
- package/docs/adr/0015-action-size-follows-interaction-density.md +4 -3
- package/docs/adr/{0012-modal-header-only-names-the-surface.md → 0018-modal-header-only-names-the-surface.md} +4 -1
- package/docs/adr/{0016-productive-surfaces-use-compact-density.md → 0019-productive-surfaces-use-compact-density.md} +4 -1
- package/docs/code-style.md +2 -2
- package/docs/consumer-upgrade-propagation.md +1 -1
- package/docs/data-products.md +5 -3
- package/docs/protocol.md +6 -6
- package/docs/releasing.md +28 -4
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/references/ui-patterns.md +11 -1
- package/src/auth/drivers/jwt.ts +2 -1
- package/src/core/presentation.ts +143 -58
- package/src/core/runtime.ts +25 -4
- package/src/core/types.ts +4 -4
- package/src/mcp/index.ts +9 -0
- package/src/ui/components/patterns/content-header.tsx +1 -1
- package/src/ui/components/patterns/form.tsx +1 -1
- package/src/ui/components/patterns/presentation.tsx +199 -60
- package/src/ui/components/patterns/sidebar.tsx +1 -1
- package/src/ui/components/patterns/split.tsx +5 -2
- package/src/ui/components/patterns/surface-assistant.tsx +74 -0
- package/src/ui/components/primitives/card.tsx +1 -1
- package/src/ui/components/primitives/detail.tsx +7 -7
- package/src/ui/components/primitives/radio-group.tsx +1 -1
- package/src/ui/components/primitives/select.tsx +1 -1
- package/src/ui/docs/content/action-form-dialog.md +11 -4
- package/src/ui/docs/content/action-form.md +7 -16
- package/src/ui/docs/content/action-list-dialog.md +4 -6
- package/src/ui/docs/content/action-list.md +46 -4
- package/src/ui/docs/content/action-trigger.md +9 -5
- package/src/ui/docs/content/action-view.md +12 -8
- package/src/ui/docs/content/actions.md +36 -13
- package/src/ui/docs/content/ai.md +26 -7
- package/src/ui/docs/content/alert.md +4 -3
- package/src/ui/docs/content/aspect-ratio.md +2 -2
- package/src/ui/docs/content/auth.md +25 -10
- package/src/ui/docs/content/avatar.md +1 -1
- package/src/ui/docs/content/badge.md +2 -2
- package/src/ui/docs/content/breadcrumb.md +3 -2
- package/src/ui/docs/content/button.md +31 -7
- package/src/ui/docs/content/calendar.md +1 -1
- package/src/ui/docs/content/card.md +1 -1
- package/src/ui/docs/content/carousel.md +14 -3
- package/src/ui/docs/content/chat.md +1 -1
- package/src/ui/docs/content/cli.md +13 -7
- package/src/ui/docs/content/command.md +34 -2
- package/src/ui/docs/content/composer.md +1 -1
- package/src/ui/docs/content/content.md +5 -4
- package/src/ui/docs/content/customization.md +1 -1
- package/src/ui/docs/content/cycle.md +7 -5
- package/src/ui/docs/content/data-state.md +6 -5
- package/src/ui/docs/content/data.md +3 -3
- package/src/ui/docs/content/detail.md +3 -2
- package/src/ui/docs/content/dialog.md +2 -2
- package/src/ui/docs/content/dictionary-value.md +1 -1
- package/src/ui/docs/content/dock.md +23 -2
- package/src/ui/docs/content/dot.md +0 -2
- package/src/ui/docs/content/drawer.md +1 -1
- package/src/ui/docs/content/empty.md +1 -4
- package/src/ui/docs/content/events.md +1 -1
- package/src/ui/docs/content/field.md +20 -11
- package/src/ui/docs/content/getting-started.md +4 -2
- package/src/ui/docs/content/icon-picker.md +2 -2
- package/src/ui/docs/content/input-otp.md +2 -0
- package/src/ui/docs/content/input.md +2 -3
- package/src/ui/docs/content/item.md +5 -4
- package/src/ui/docs/content/kbd.md +2 -1
- package/src/ui/docs/content/mcp.md +10 -4
- package/src/ui/docs/content/menu.md +27 -0
- package/src/ui/docs/content/page.md +19 -5
- package/src/ui/docs/content/pagination.md +9 -2
- package/src/ui/docs/content/popover.md +2 -2
- package/src/ui/docs/content/presentation.md +85 -58
- package/src/ui/docs/content/progress.md +2 -6
- package/src/ui/docs/content/runtime.md +8 -5
- package/src/ui/docs/content/scheduler.md +1 -1
- package/src/ui/docs/content/select.md +13 -8
- package/src/ui/docs/content/sidebar.md +3 -2
- package/src/ui/docs/content/skeleton.md +1 -1
- package/src/ui/docs/content/slider.md +4 -4
- package/src/ui/docs/content/spinner.md +3 -3
- package/src/ui/docs/content/split.md +80 -19
- package/src/ui/docs/content/tabs.md +6 -6
- package/src/ui/docs/content/testing.md +4 -2
- package/src/ui/docs/content/toast.md +3 -5
- package/src/ui/docs/content/toggle.md +37 -0
- package/src/ui/docs/content/tooltip.md +4 -3
- package/src/ui/docs/content/truncate.md +3 -2
- package/src/ui/docs/content/ui.md +3 -1
- package/src/ui/docs/content/upgrading.md +43 -13
- package/src/ui/docs/doc-client.tsx +1 -1
- package/src/ui/docs/registry.tsx +30 -5
- package/src/ui/meta.ts +5 -5
- package/src/ui/react.tsx +5 -0
|
@@ -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,6 +78,7 @@ 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;
|
|
@@ -90,6 +94,11 @@ interface PresentationFrameProps {
|
|
|
90
94
|
className?: string;
|
|
91
95
|
bodyClassName?: string;
|
|
92
96
|
listBody?: boolean;
|
|
97
|
+
contentOwnsHeading?: boolean;
|
|
98
|
+
assistantOpen?: boolean;
|
|
99
|
+
assistantLabel?: string;
|
|
100
|
+
assistantAction?: ReactNode;
|
|
101
|
+
assistantPanel?: ReactNode;
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
const bodyClassName =
|
|
@@ -124,6 +133,11 @@ function PresentationFrame({
|
|
|
124
133
|
className,
|
|
125
134
|
bodyClassName: bodyClassNameProp,
|
|
126
135
|
listBody = false,
|
|
136
|
+
contentOwnsHeading = false,
|
|
137
|
+
assistantOpen = false,
|
|
138
|
+
assistantLabel,
|
|
139
|
+
assistantAction,
|
|
140
|
+
assistantPanel,
|
|
127
141
|
}: PresentationFrameProps): ReactElement {
|
|
128
142
|
const insidePageShell = useInsidePageShell();
|
|
129
143
|
const [footerTarget, setFooterTarget] = useState<HTMLElement | null>(null);
|
|
@@ -153,6 +167,20 @@ function PresentationFrame({
|
|
|
153
167
|
{typeof children === "function" ? children(footerTarget) : children}
|
|
154
168
|
</div>
|
|
155
169
|
);
|
|
170
|
+
const withAssistant = (content: ReactElement): ReactElement =>
|
|
171
|
+
assistantLabel === undefined || assistantPanel === undefined ? (
|
|
172
|
+
content
|
|
173
|
+
) : (
|
|
174
|
+
<SurfaceAssistant
|
|
175
|
+
open={assistantOpen}
|
|
176
|
+
assistantLabel={assistantLabel}
|
|
177
|
+
assistant={assistantPanel}
|
|
178
|
+
className="min-h-0 flex-1"
|
|
179
|
+
contentClassName="flex min-h-0 flex-col"
|
|
180
|
+
>
|
|
181
|
+
{content}
|
|
182
|
+
</SurfaceAssistant>
|
|
183
|
+
);
|
|
156
184
|
|
|
157
185
|
if (surface === "page") {
|
|
158
186
|
if (listBody) {
|
|
@@ -171,13 +199,20 @@ function PresentationFrame({
|
|
|
171
199
|
);
|
|
172
200
|
return (
|
|
173
201
|
<Page className={cn("flex min-h-full flex-col", className)}>
|
|
174
|
-
{navigation === undefined ? null : (
|
|
202
|
+
{navigation === undefined && assistantAction === undefined ? null : (
|
|
175
203
|
<PageHeader>
|
|
176
|
-
|
|
204
|
+
{navigation === undefined ? null : (
|
|
205
|
+
<PageNavigation>{navigation}</PageNavigation>
|
|
206
|
+
)}
|
|
207
|
+
{assistantAction === undefined ? null : (
|
|
208
|
+
<PageActions>{assistantAction}</PageActions>
|
|
209
|
+
)}
|
|
177
210
|
</PageHeader>
|
|
178
211
|
)}
|
|
179
|
-
<PageBody className="min-h-0 flex-1 overflow-
|
|
180
|
-
{
|
|
212
|
+
<PageBody className="min-h-0 flex-1 overflow-hidden">
|
|
213
|
+
{withAssistant(
|
|
214
|
+
<div className="h-full overflow-y-auto">{content}</div>,
|
|
215
|
+
)}
|
|
181
216
|
</PageBody>
|
|
182
217
|
{hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
|
|
183
218
|
</Page>
|
|
@@ -186,22 +221,36 @@ function PresentationFrame({
|
|
|
186
221
|
if (insidePageShell) {
|
|
187
222
|
return (
|
|
188
223
|
<Page className={cn("flex min-h-full flex-col", className)}>
|
|
189
|
-
{navigation === undefined &&
|
|
224
|
+
{navigation === undefined &&
|
|
225
|
+
headerActions === undefined &&
|
|
226
|
+
assistantAction === undefined ? null : (
|
|
190
227
|
<PageHeader>
|
|
191
228
|
{navigation === undefined ? null : (
|
|
192
229
|
<PageNavigation>{navigation}</PageNavigation>
|
|
193
230
|
)}
|
|
194
|
-
{headerActions === undefined
|
|
231
|
+
{headerActions === undefined &&
|
|
232
|
+
assistantAction === undefined ? null : (
|
|
195
233
|
<PageActions>
|
|
196
|
-
<PresentationActions>
|
|
234
|
+
<PresentationActions>
|
|
235
|
+
{headerActions}
|
|
236
|
+
{assistantAction}
|
|
237
|
+
</PresentationActions>
|
|
197
238
|
</PageActions>
|
|
198
239
|
)}
|
|
199
240
|
</PageHeader>
|
|
200
241
|
)}
|
|
201
|
-
<
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
242
|
+
<PageBody className="min-h-0 flex-1 overflow-hidden">
|
|
243
|
+
{withAssistant(
|
|
244
|
+
<div className="flex h-full min-h-0 flex-col overflow-y-auto">
|
|
245
|
+
{contentOwnsHeading ? null : (
|
|
246
|
+
<PageIntro>
|
|
247
|
+
<PageTitle>{title}</PageTitle>
|
|
248
|
+
</PageIntro>
|
|
249
|
+
)}
|
|
250
|
+
{body}
|
|
251
|
+
</div>,
|
|
252
|
+
)}
|
|
253
|
+
</PageBody>
|
|
205
254
|
{hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
|
|
206
255
|
</Page>
|
|
207
256
|
);
|
|
@@ -218,14 +267,20 @@ function PresentationFrame({
|
|
|
218
267
|
<PageNavigation>{navigation}</PageNavigation>
|
|
219
268
|
)}
|
|
220
269
|
<PageTitle className="text-lg leading-none">{title}</PageTitle>
|
|
221
|
-
{headerActions === undefined
|
|
270
|
+
{headerActions === undefined &&
|
|
271
|
+
assistantAction === undefined ? null : (
|
|
222
272
|
<PageActions>
|
|
223
|
-
<PresentationActions>
|
|
273
|
+
<PresentationActions>
|
|
274
|
+
{headerActions}
|
|
275
|
+
{assistantAction}
|
|
276
|
+
</PresentationActions>
|
|
224
277
|
</PageActions>
|
|
225
278
|
)}
|
|
226
279
|
</PageHeader>
|
|
227
|
-
<PageBody className="min-h-0 flex-1 overflow-
|
|
228
|
-
{
|
|
280
|
+
<PageBody className="min-h-0 flex-1 overflow-hidden">
|
|
281
|
+
{withAssistant(
|
|
282
|
+
<div className="h-full overflow-y-auto px-8 py-8">{body}</div>,
|
|
283
|
+
)}
|
|
229
284
|
</PageBody>
|
|
230
285
|
{hasFooter ? <PageFooter>{renderFooterActions()}</PageFooter> : null}
|
|
231
286
|
</Page>
|
|
@@ -242,7 +297,10 @@ function PresentationFrame({
|
|
|
242
297
|
}}
|
|
243
298
|
>
|
|
244
299
|
<DialogContent
|
|
245
|
-
className={
|
|
300
|
+
className={cn(
|
|
301
|
+
assistantOpen ? "h-[min(48rem,85vh)] max-w-5xl" : undefined,
|
|
302
|
+
className,
|
|
303
|
+
)}
|
|
246
304
|
aria-describedby={undefined}
|
|
247
305
|
closeDisabled={blocked}
|
|
248
306
|
onEscapeKeyDown={(event) => {
|
|
@@ -252,17 +310,23 @@ function PresentationFrame({
|
|
|
252
310
|
if (blocked) event.preventDefault();
|
|
253
311
|
}}
|
|
254
312
|
>
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
313
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
314
|
+
<DialogHeader>
|
|
315
|
+
{navigation}
|
|
316
|
+
<DialogTitle className="truncate">{title}</DialogTitle>
|
|
317
|
+
{headerActions === undefined &&
|
|
318
|
+
assistantAction === undefined ? null : (
|
|
319
|
+
<PresentationActions>
|
|
320
|
+
{headerActions}
|
|
321
|
+
{assistantAction}
|
|
322
|
+
</PresentationActions>
|
|
323
|
+
)}
|
|
324
|
+
</DialogHeader>
|
|
325
|
+
{withAssistant(<DialogBody>{body}</DialogBody>)}
|
|
326
|
+
{hasFooter ? (
|
|
327
|
+
<DialogFooter>{renderFooterActions()}</DialogFooter>
|
|
328
|
+
) : null}
|
|
329
|
+
</div>
|
|
266
330
|
</DialogContent>
|
|
267
331
|
</Dialog>
|
|
268
332
|
);
|
|
@@ -277,7 +341,12 @@ function PresentationFrame({
|
|
|
277
341
|
}}
|
|
278
342
|
>
|
|
279
343
|
<DrawerContent
|
|
280
|
-
className={
|
|
344
|
+
className={cn(
|
|
345
|
+
assistantOpen
|
|
346
|
+
? "w-[min(80rem,calc(100%-2rem))] max-w-none"
|
|
347
|
+
: undefined,
|
|
348
|
+
className,
|
|
349
|
+
)}
|
|
281
350
|
aria-describedby={undefined}
|
|
282
351
|
closeDisabled={blocked}
|
|
283
352
|
onEscapeKeyDown={(event) => {
|
|
@@ -287,22 +356,33 @@ function PresentationFrame({
|
|
|
287
356
|
if (blocked) event.preventDefault();
|
|
288
357
|
}}
|
|
289
358
|
>
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
359
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
360
|
+
<DrawerHeader>
|
|
361
|
+
{navigation}
|
|
362
|
+
<DrawerTitle className="truncate">{title}</DrawerTitle>
|
|
363
|
+
{headerActions === undefined &&
|
|
364
|
+
assistantAction === undefined ? null : (
|
|
365
|
+
<PresentationActions>
|
|
366
|
+
{headerActions}
|
|
367
|
+
{assistantAction}
|
|
368
|
+
</PresentationActions>
|
|
369
|
+
)}
|
|
370
|
+
</DrawerHeader>
|
|
371
|
+
{withAssistant(<DrawerBody>{body}</DrawerBody>)}
|
|
372
|
+
{hasFooter ? (
|
|
373
|
+
<DrawerFooter>{renderFooterActions()}</DrawerFooter>
|
|
374
|
+
) : null}
|
|
375
|
+
</div>
|
|
301
376
|
</DrawerContent>
|
|
302
377
|
</Drawer>
|
|
303
378
|
);
|
|
304
379
|
}
|
|
305
380
|
|
|
381
|
+
export interface PresentationAssistantRuntime {
|
|
382
|
+
icon?: ReactNode;
|
|
383
|
+
render: (controls: { close: () => void }) => ReactNode;
|
|
384
|
+
}
|
|
385
|
+
|
|
306
386
|
export interface PresentationProps {
|
|
307
387
|
/** Definição estática registrada e publicada no manifest. */
|
|
308
388
|
definition: PresentationDefinition;
|
|
@@ -325,6 +405,10 @@ export interface PresentationProps {
|
|
|
325
405
|
onOpenChange?: (open: boolean) => void;
|
|
326
406
|
className?: string;
|
|
327
407
|
bodyClassName?: string;
|
|
408
|
+
/** Conteúdo React da boundary declarada por `body.component`. */
|
|
409
|
+
children?: ReactNode;
|
|
410
|
+
/** Painel runtime; a página fecha sobre os dados do recurso que ela carregou. */
|
|
411
|
+
assistant?: PresentationAssistantRuntime;
|
|
328
412
|
}
|
|
329
413
|
|
|
330
414
|
function actionLabel(action: { name: string; label?: unknown }): string {
|
|
@@ -394,6 +478,8 @@ export function Presentation({
|
|
|
394
478
|
onOpenChange,
|
|
395
479
|
className,
|
|
396
480
|
bodyClassName: bodyClassNameProp,
|
|
481
|
+
children,
|
|
482
|
+
assistant,
|
|
397
483
|
}: PresentationProps): ReactElement {
|
|
398
484
|
if (definition.id !== invocation.presentationId) {
|
|
399
485
|
throw new Error(
|
|
@@ -404,16 +490,17 @@ export function Presentation({
|
|
|
404
490
|
ReadonlySet<PresentationCommand>
|
|
405
491
|
>(() => new Set());
|
|
406
492
|
const [bodyLoading, setBodyLoading] = useState(false);
|
|
493
|
+
const [assistantOpen, setAssistantOpen] = useState(false);
|
|
494
|
+
useEffect(() => {
|
|
495
|
+
setAssistantOpen(false);
|
|
496
|
+
}, [definition.id, invocation.surface]);
|
|
407
497
|
const context: PresentationBindingContext = {
|
|
408
498
|
...bindingContext,
|
|
409
499
|
route: bindingContext?.route ?? invocation.input,
|
|
410
500
|
};
|
|
411
501
|
|
|
412
502
|
const applyEffects = useCallback(
|
|
413
|
-
(
|
|
414
|
-
effects: PresentationDefinition["body"]["onSuccess"],
|
|
415
|
-
result?: unknown,
|
|
416
|
-
): void => {
|
|
503
|
+
(effects: PresentationActionBody["onSuccess"], result?: unknown): void => {
|
|
417
504
|
const next = applyPresentationEffects(
|
|
418
505
|
invocation,
|
|
419
506
|
effects,
|
|
@@ -541,23 +628,51 @@ export function Presentation({
|
|
|
541
628
|
);
|
|
542
629
|
};
|
|
543
630
|
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
631
|
+
const actionBody = isPresentationActionBody(definition.body)
|
|
632
|
+
? definition.body
|
|
633
|
+
: null;
|
|
634
|
+
const bodyAction = actionBody === null ? null : actions[actionBody.action];
|
|
635
|
+
if (actionBody !== null && bodyAction === undefined) {
|
|
636
|
+
throw new Error(`Action “${actionBody.action}” não registrada.`);
|
|
547
637
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
638
|
+
if (actionBody === null && children === undefined) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`A Presentation “${definition.id}” declara body.component e exige children.`,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
if (actionBody !== null && children !== undefined) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`A Presentation “${definition.id}” usa body.action e não aceita children.`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
if (definition.assistant !== undefined && assistant === undefined) {
|
|
649
|
+
throw new Error(
|
|
650
|
+
`A Presentation “${definition.id}” declara assistência e exige a prop assistant.`,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
if (definition.assistant === undefined && assistant !== undefined) {
|
|
654
|
+
throw new Error(
|
|
655
|
+
`A Presentation “${definition.id}” não declara assistência e não aceita a prop assistant.`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
const bodyInput =
|
|
659
|
+
actionBody === null
|
|
660
|
+
? {}
|
|
661
|
+
: (resolvePresentationBindings(actionBody.input, context) as Record<
|
|
662
|
+
string,
|
|
663
|
+
unknown
|
|
664
|
+
>);
|
|
552
665
|
const renderBody = (footerTarget: HTMLElement | null): ReactNode => {
|
|
666
|
+
if (actionBody === null) return children;
|
|
667
|
+
if (bodyAction === null) return null;
|
|
553
668
|
if (bodyAction.kind === "form") {
|
|
554
669
|
return (
|
|
555
670
|
<ActionForm
|
|
556
671
|
action={bodyAction as FormContract<Record<string, unknown>, unknown>}
|
|
557
672
|
defaultValues={bodyInput}
|
|
558
|
-
submitLabel={
|
|
673
|
+
submitLabel={actionBody.submitLabel}
|
|
559
674
|
onCancel={invocation.surface === "page" ? undefined : closeSurface}
|
|
560
|
-
onSuccess={(data) => applyEffects(
|
|
675
|
+
onSuccess={(data) => applyEffects(actionBody.onSuccess, data)}
|
|
561
676
|
disabled={hasSurfaceBlock}
|
|
562
677
|
onLoadingChange={setBodyLoading}
|
|
563
678
|
footer={(actions) =>
|
|
@@ -578,14 +693,14 @@ export function Presentation({
|
|
|
578
693
|
input={bodyInput}
|
|
579
694
|
state={listState}
|
|
580
695
|
onStateChange={onListStateChange}
|
|
581
|
-
{...(
|
|
696
|
+
{...(actionBody.open === undefined
|
|
582
697
|
? {}
|
|
583
698
|
: {
|
|
584
699
|
onRowClick: (item: Record<string, unknown>) =>
|
|
585
700
|
openTarget(
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
701
|
+
actionBody.open!.presentation,
|
|
702
|
+
actionBody.open!.surface,
|
|
703
|
+
actionBody.open!.input,
|
|
589
704
|
{ ...context, item },
|
|
590
705
|
),
|
|
591
706
|
})}
|
|
@@ -593,7 +708,7 @@ export function Presentation({
|
|
|
593
708
|
);
|
|
594
709
|
}
|
|
595
710
|
if (bodyAction.kind === "view") {
|
|
596
|
-
const fields =
|
|
711
|
+
const fields = actionBody.fields ?? [];
|
|
597
712
|
return (
|
|
598
713
|
<ActionView
|
|
599
714
|
action={bodyAction as ViewContract<Record<string, unknown>, unknown>}
|
|
@@ -641,6 +756,22 @@ export function Presentation({
|
|
|
641
756
|
<ArrowLeft aria-hidden />
|
|
642
757
|
</Button>
|
|
643
758
|
);
|
|
759
|
+
const assistantAction =
|
|
760
|
+
definition.assistant === undefined || assistantOpen ? undefined : (
|
|
761
|
+
<Button
|
|
762
|
+
size="icon"
|
|
763
|
+
variant="ghost"
|
|
764
|
+
aria-label={definition.assistant.triggerLabel}
|
|
765
|
+
title={definition.assistant.triggerLabel}
|
|
766
|
+
onClick={() => setAssistantOpen(true)}
|
|
767
|
+
>
|
|
768
|
+
{assistant?.icon ?? <MessageCircle aria-hidden />}
|
|
769
|
+
</Button>
|
|
770
|
+
);
|
|
771
|
+
const assistantPanel =
|
|
772
|
+
definition.assistant === undefined || assistant === undefined
|
|
773
|
+
? undefined
|
|
774
|
+
: assistant.render({ close: () => setAssistantOpen(false) });
|
|
644
775
|
|
|
645
776
|
return (
|
|
646
777
|
<PresentationFrame
|
|
@@ -659,9 +790,17 @@ export function Presentation({
|
|
|
659
790
|
}}
|
|
660
791
|
className={className}
|
|
661
792
|
bodyClassName={bodyClassNameProp}
|
|
662
|
-
hasBodyFooter={bodyAction
|
|
793
|
+
hasBodyFooter={bodyAction?.kind === "form"}
|
|
663
794
|
blocked={surfaceBlocked}
|
|
664
|
-
listBody={bodyAction
|
|
795
|
+
listBody={bodyAction?.kind === "list"}
|
|
796
|
+
contentOwnsHeading={
|
|
797
|
+
!isPresentationActionBody(definition.body) &&
|
|
798
|
+
definition.body.heading === "content"
|
|
799
|
+
}
|
|
800
|
+
assistantOpen={assistantOpen}
|
|
801
|
+
assistantLabel={definition.assistant?.triggerLabel}
|
|
802
|
+
assistantAction={assistantAction}
|
|
803
|
+
assistantPanel={assistantPanel}
|
|
665
804
|
>
|
|
666
805
|
{renderBody}
|
|
667
806
|
</PresentationFrame>
|
|
@@ -94,7 +94,7 @@ export function PaneBody({
|
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/** @deprecated Use PaneBody.
|
|
97
|
+
/** @deprecated Use PaneBody. Alias mantido para migração; sai numa próxima versão major. */
|
|
98
98
|
export function PaneContent({
|
|
99
99
|
className,
|
|
100
100
|
children,
|
|
@@ -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
|
+
}
|
|
@@ -101,7 +101,7 @@ export function CardBody({
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
/** @deprecated Use CardBody.
|
|
104
|
+
/** @deprecated Use CardBody. Alias mantido para migração; sai numa próxima versão major. */
|
|
105
105
|
export function CardContent({
|
|
106
106
|
className,
|
|
107
107
|
...props
|
|
@@ -8,11 +8,11 @@ export type DetailGroupOrientation = 'vertical' | 'horizontal'
|
|
|
8
8
|
export type DetailGroupColumns = 1 | 2 | 3 | 4 | 'auto'
|
|
9
9
|
|
|
10
10
|
export interface DetailGroupProps extends React.ComponentProps<'dl'> {
|
|
11
|
-
/** `framed` aplica a
|
|
11
|
+
/** `framed` aplica a moldura canônica ao conjunto; na orientação horizontal, a coluna de rótulos ganha fundo sutil. */
|
|
12
12
|
variant?: DetailGroupVariant
|
|
13
13
|
/** Exibe hairlines somente entre os campos, sem exigir moldura externa. */
|
|
14
14
|
dividers?: boolean
|
|
15
|
-
/** Número
|
|
15
|
+
/** Número fixo de colunas ou distribuição automática por largura mínima. */
|
|
16
16
|
columns?: DetailGroupColumns
|
|
17
17
|
/** Organiza a chave sobre o valor ou ao lado dele em cada campo. */
|
|
18
18
|
orientation?: DetailGroupOrientation
|
|
@@ -59,13 +59,13 @@ function DetailGroup({
|
|
|
59
59
|
'[&>[data-slot=detail-field]]:p-3',
|
|
60
60
|
variant === 'framed' &&
|
|
61
61
|
'overflow-hidden rounded-lg border border-border bg-transparent text-foreground',
|
|
62
|
+
// Default da coluna de rótulos como CLASSE, antes do `className`: assim uma classe do
|
|
63
|
+
// consumidor (`[--detail-label-width:9rem]`) a substitui. Em `style` inline, o default
|
|
64
|
+
// vencia qualquer classe e só `style` conseguia ajustar a largura.
|
|
65
|
+
splitHorizontalField && '[--detail-label-width:8.5rem]',
|
|
62
66
|
className,
|
|
63
67
|
)}
|
|
64
|
-
style={
|
|
65
|
-
splitHorizontalField
|
|
66
|
-
? ({ '--detail-label-width': '8.5rem', ...style } as React.CSSProperties)
|
|
67
|
-
: style
|
|
68
|
-
}
|
|
68
|
+
style={style}
|
|
69
69
|
{...props}
|
|
70
70
|
>
|
|
71
71
|
{children}
|
|
@@ -25,7 +25,7 @@ function RadioGroupItem({
|
|
|
25
25
|
<RadioGroupPrimitive.Item
|
|
26
26
|
data-slot="radio-group-item"
|
|
27
27
|
className={cn(
|
|
28
|
-
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 dark:bg-input/30 dark:aria-invalid:ring-context-danger/40",
|
|
28
|
+
"peer aspect-square size-4 shrink-0 rounded-full border border-input text-primary transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-context-danger aria-invalid:ring-context-danger/20 dark:bg-input/30 dark:aria-invalid:ring-context-danger/40",
|
|
29
29
|
className
|
|
30
30
|
)}
|
|
31
31
|
{...props}
|
|
@@ -92,7 +92,7 @@ interface SelectCustomBase extends SelectBaseProps {
|
|
|
92
92
|
clearable?: boolean
|
|
93
93
|
/** `outline`: compacto com borda; `ghost`: compacto sem borda nem fundo. */
|
|
94
94
|
variant?: 'default' | 'outline' | 'ghost'
|
|
95
|
-
/** Placeholder
|
|
95
|
+
/** Placeholder da linha de busca dentro da lista. Default: `'Buscar…'`. */
|
|
96
96
|
searchPlaceholder?: string
|
|
97
97
|
emptyText?: string
|
|
98
98
|
/** Ação custom no FIM do campo, dentro do controle (antes do chevron) — ex.: um botão que
|
|
@@ -3,8 +3,11 @@
|
|
|
3
3
|
Use `ActionFormDialog` quando o formulário precisar interromper o fluxo atual sem levar a pessoa
|
|
4
4
|
para outra página. O consumidor controla `open`; depois de uma execução bem-sucedida, o componente
|
|
5
5
|
fecha o modal. O cabeçalho e o rodapé permanecem visíveis enquanto os campos podem rolar.
|
|
6
|
-
|
|
7
|
-
a ação principal permanece `solid`, ambas no tamanho normal de uma decisão
|
|
6
|
+
O rodapé agrupa as decisões em um `ButtonGroup` cuja largura acompanha o conteúdo: `Cancelar`
|
|
7
|
+
aparece em `ghost` e a ação principal permanece `solid`, ambas no tamanho normal de uma decisão
|
|
8
|
+
modal. `Cancelar` está sempre presente; sem `onCancel` próprio, ele fecha o modal.
|
|
9
|
+
Com `footerDistribution="equal"`, as duas decisões dividem a faixa e `Cancelar` passa a `outline`,
|
|
10
|
+
salvo escolha explícita em `cancelVariant`.
|
|
8
11
|
|
|
9
12
|
```tsx preview
|
|
10
13
|
const [open, setOpen] = useState(false);
|
|
@@ -26,8 +29,12 @@ render(
|
|
|
26
29
|
|
|
27
30
|
| Propriedade | Tipo | Padrão | Descrição |
|
|
28
31
|
| --------------------- | ------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
29
|
-
| `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e ao cancelar. |
|
|
32
|
+
| `open / onOpenChange` | `boolean / (open: boolean) => void` | | Estado controlado do modal. `onOpenChange(false)` é chamado no sucesso e, quando não há `onCancel` próprio, ao cancelar. |
|
|
30
33
|
| `title` | `string` | | Nome da tarefa exibido no cabeçalho. |
|
|
31
34
|
| `intro` | `ReactNode` | | Contexto relevante no início do corpo, como texto ou `Alert`. |
|
|
32
35
|
| `submitLabel` | `string` | `'Salvar'` | Resultado da ação principal. Preserve o padrão em criação e edição comuns; sobrescreva apenas quando a operação tiver outro efeito, como `Renomear` ou `Criar nova versão`. |
|
|
33
|
-
|
|
|
36
|
+
| `footerDistribution` | `'content' \| 'equal'` | `'content'` | Mantém a largura das ações pelo conteúdo ou divide a faixa igualmente. |
|
|
37
|
+
| `cancelVariant` | `'ghost' \| 'outline'` | `'ghost'`; `'outline'` com `equal` | Tratamento do `Cancelar`. O valor explícito vence o default derivado de `footerDistribution`. |
|
|
38
|
+
| `onCancel` | `() => void` | fecha o modal | Substitui o fechamento padrão do `Cancelar`; nesse caso, o consumidor decide quando chamar `onOpenChange(false)`. |
|
|
39
|
+
| `onSuccess` | `(data: TData) => void` | | Executado depois do sucesso, antes de o componente fechar o modal. |
|
|
40
|
+
| `…ActionFormProps` | `action, defaultValues, fieldOptions…` | | Demais propriedades repassadas ao `ActionForm` interno. `className`, `body` e `footer` não são repassadas: o wrapper as substitui para montar o corpo rolável e o footer do modal. |
|