@softize/opus 16.0.0 → 17.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/bin/lib/check.mjs +9 -13
- package/bin/lib/copy.mjs +29 -4
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +6 -3
- package/docs/adr/0009-page-title-does-not-carry-a-counter.md +5 -2
- package/docs/adr/0011-page-shell-coordinates-persistent-page-chrome.md +12 -14
- package/docs/adr/0012-modal-header-only-names-the-surface.md +8 -4
- package/docs/adr/0013-presentation-is-a-portable-action-oriented-artifact.md +7 -4
- package/docs/adr/0014-structural-headers-do-not-carry-description.md +35 -0
- package/package.json +2 -2
- package/registry/skills/build-opus-ui/references/evaluations.md +1 -1
- package/registry/skills/build-opus-ui/references/ui-patterns.md +16 -17
- package/registry/templates/app/package.json +1 -1
- package/src/core/presentation.ts +142 -13
- package/src/ui/components/patterns/form.tsx +29 -10
- package/src/ui/components/patterns/page.tsx +93 -48
- package/src/ui/components/patterns/presentation.tsx +487 -134
- package/src/ui/components/patterns/surface-header.tsx +4 -3
- package/src/ui/components/patterns/trigger.tsx +8 -1
- package/src/ui/components/patterns/view.tsx +2 -2
- package/src/ui/components/primitives/command.tsx +82 -42
- package/src/ui/components/primitives/dialog.tsx +180 -97
- package/src/ui/components/primitives/drawer.tsx +63 -22
- package/src/ui/docs/content/command.md +3 -1
- package/src/ui/docs/content/content.md +1 -1
- package/src/ui/docs/content/dialog.md +9 -9
- package/src/ui/docs/content/drawer.md +4 -4
- package/src/ui/docs/content/page.md +14 -29
- package/src/ui/docs/content/presentation.md +54 -46
- package/src/ui/meta.ts +2 -2
- package/src/ui/react.tsx +1 -2
package/src/core/presentation.ts
CHANGED
|
@@ -94,6 +94,8 @@ const presentationBodySchema = z
|
|
|
94
94
|
.object({
|
|
95
95
|
action: z.string().min(1),
|
|
96
96
|
input: z.record(presentationBindingSchema).default({}),
|
|
97
|
+
onSuccess: z.array(presentationEffectSchema).default([]),
|
|
98
|
+
submitLabel: z.string().min(1).optional(),
|
|
97
99
|
fields: z.array(presentationFieldSchema).min(1).optional(),
|
|
98
100
|
open: z
|
|
99
101
|
.object({
|
|
@@ -153,13 +155,14 @@ type DefinedPresentationOpen<Open> = Open extends object
|
|
|
153
155
|
}
|
|
154
156
|
: never;
|
|
155
157
|
type DefinedPresentationBody<Body extends PresentationDefinitionInput["body"]> =
|
|
156
|
-
Omit<Body, "input" | "open"> &
|
|
157
|
-
Omit<PresentationDefinition["body"], "input" | "open"> & {
|
|
158
|
+
Omit<Body, "input" | "open" | "onSuccess"> &
|
|
159
|
+
Omit<PresentationDefinition["body"], "input" | "open" | "onSuccess"> & {
|
|
158
160
|
input: DefaultedProperty<
|
|
159
161
|
Body,
|
|
160
162
|
"input",
|
|
161
163
|
PresentationDefinition["body"]["input"]
|
|
162
164
|
>;
|
|
165
|
+
onSuccess: DefinedPresentationEffects<Body, "onSuccess">;
|
|
163
166
|
} & ("open" extends keyof Body
|
|
164
167
|
? undefined extends Body["open"]
|
|
165
168
|
? { open?: DefinedPresentationOpen<Exclude<Body["open"], undefined>> }
|
|
@@ -417,6 +420,12 @@ export function validatePresentations(
|
|
|
417
420
|
`A action simple “${bodyAction.name}” não pode ocupar o body.`,
|
|
418
421
|
);
|
|
419
422
|
} else {
|
|
423
|
+
validateActionBindings(
|
|
424
|
+
presentation.body.input,
|
|
425
|
+
bodyAction,
|
|
426
|
+
`${presentation.id}.body.input`,
|
|
427
|
+
problems,
|
|
428
|
+
);
|
|
420
429
|
if (
|
|
421
430
|
bodyAction.kind === "view" &&
|
|
422
431
|
presentation.body.fields === undefined
|
|
@@ -434,20 +443,34 @@ export function validatePresentations(
|
|
|
434
443
|
if (bodyAction.kind !== "list" && presentation.body.open !== undefined) {
|
|
435
444
|
problems.push(`Open só pode ser declarado para uma action list.`);
|
|
436
445
|
}
|
|
446
|
+
if (
|
|
447
|
+
bodyAction.kind !== "form" &&
|
|
448
|
+
presentation.body.submitLabel !== undefined
|
|
449
|
+
) {
|
|
450
|
+
problems.push(`SubmitLabel só pode ser declarado para uma action form.`);
|
|
451
|
+
}
|
|
437
452
|
}
|
|
438
453
|
|
|
439
454
|
for (const command of presentation.actions) {
|
|
440
455
|
const action = actions[command.action];
|
|
441
456
|
if (action === undefined) {
|
|
442
457
|
problems.push(`A action “${command.action}” não está disponível.`);
|
|
443
|
-
} else
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
`A action ${action.kind} “${command.action}” precisa abrir outra Presentation.`,
|
|
458
|
+
} else {
|
|
459
|
+
validateActionBindings(
|
|
460
|
+
command.input,
|
|
461
|
+
action,
|
|
462
|
+
`${presentation.id}.actions.${command.action}.input`,
|
|
463
|
+
problems,
|
|
450
464
|
);
|
|
465
|
+
if (action.kind === "simple" && command.target !== undefined) {
|
|
466
|
+
problems.push(
|
|
467
|
+
`A action simple “${command.action}” executa diretamente e não aceita target.`,
|
|
468
|
+
);
|
|
469
|
+
} else if (action.kind !== "simple" && command.target === undefined) {
|
|
470
|
+
problems.push(
|
|
471
|
+
`A action ${action.kind} “${command.action}” precisa abrir outra Presentation.`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
451
474
|
}
|
|
452
475
|
}
|
|
453
476
|
}
|
|
@@ -458,13 +481,57 @@ export function validatePresentations(
|
|
|
458
481
|
problems.push(
|
|
459
482
|
`A lista “${presentation.id}” abre a Presentation inexistente “${open.presentation}”.`,
|
|
460
483
|
);
|
|
484
|
+
} else if (open !== undefined) {
|
|
485
|
+
const target = byId.get(open.presentation);
|
|
486
|
+
const action =
|
|
487
|
+
target === undefined ? undefined : actions[target.body.action];
|
|
488
|
+
if (action !== undefined) {
|
|
489
|
+
validateActionBindings(
|
|
490
|
+
open.input,
|
|
491
|
+
action,
|
|
492
|
+
`${presentation.id}.body.open.input`,
|
|
493
|
+
problems,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
for (const effect of presentation.body.onSuccess) {
|
|
498
|
+
if (effect.effect !== "navigate") continue;
|
|
499
|
+
const target = byId.get(effect.presentation);
|
|
500
|
+
if (target === undefined) {
|
|
501
|
+
problems.push(
|
|
502
|
+
`O body de “${presentation.id}” navega para a Presentation inexistente “${effect.presentation}”.`,
|
|
503
|
+
);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const action = actions[target.body.action];
|
|
507
|
+
if (action !== undefined) {
|
|
508
|
+
validateActionBindings(
|
|
509
|
+
effect.input,
|
|
510
|
+
action,
|
|
511
|
+
`${presentation.id}.body.onSuccess.navigate.input`,
|
|
512
|
+
problems,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
461
515
|
}
|
|
462
516
|
for (const command of presentation.actions) {
|
|
463
517
|
for (const effect of command.onSuccess) {
|
|
464
|
-
if (effect.effect === "navigate"
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
518
|
+
if (effect.effect === "navigate") {
|
|
519
|
+
const target = byId.get(effect.presentation);
|
|
520
|
+
if (target === undefined) {
|
|
521
|
+
problems.push(
|
|
522
|
+
`A action “${command.action}” navega para a Presentation inexistente “${effect.presentation}”.`,
|
|
523
|
+
);
|
|
524
|
+
} else {
|
|
525
|
+
const action = actions[target.body.action];
|
|
526
|
+
if (action !== undefined) {
|
|
527
|
+
validateActionBindings(
|
|
528
|
+
effect.input,
|
|
529
|
+
action,
|
|
530
|
+
`${presentation.id}.actions.${command.action}.onSuccess.navigate.input`,
|
|
531
|
+
problems,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
468
535
|
}
|
|
469
536
|
}
|
|
470
537
|
if (command.target === undefined) continue;
|
|
@@ -485,6 +552,68 @@ export function validatePresentations(
|
|
|
485
552
|
return parsed;
|
|
486
553
|
}
|
|
487
554
|
|
|
555
|
+
function unwrapObjectSchema(schema: unknown): z.ZodObject<any> | null {
|
|
556
|
+
let current = schema;
|
|
557
|
+
const visited = new Set<unknown>();
|
|
558
|
+
while (current !== null && typeof current === "object" && !visited.has(current)) {
|
|
559
|
+
visited.add(current);
|
|
560
|
+
if (current instanceof z.ZodObject) return current;
|
|
561
|
+
const definition = (current as { _def?: Record<string, unknown> })._def;
|
|
562
|
+
current = definition?.innerType ?? definition?.schema ?? null;
|
|
563
|
+
}
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function validateActionBindings(
|
|
568
|
+
bindings: Readonly<Record<string, PresentationBinding>>,
|
|
569
|
+
action: ActionContract,
|
|
570
|
+
path: string,
|
|
571
|
+
problems: string[],
|
|
572
|
+
): void {
|
|
573
|
+
const schema = unwrapObjectSchema(action.input);
|
|
574
|
+
if (schema === null) return;
|
|
575
|
+
const shape = schema.shape as Record<string, z.ZodTypeAny>;
|
|
576
|
+
|
|
577
|
+
for (const key of Object.keys(bindings)) {
|
|
578
|
+
const field = shape[key];
|
|
579
|
+
if (field === undefined) {
|
|
580
|
+
problems.push(
|
|
581
|
+
`O binding “${path}.${key}” não existe no input da action “${action.name}”.`,
|
|
582
|
+
);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
const binding = bindings[key];
|
|
586
|
+
if (binding?.source === "fixed" && !field.safeParse(binding.value).success) {
|
|
587
|
+
problems.push(
|
|
588
|
+
`O valor fixo de “${path}.${key}” é incompatível com o input da action “${action.name}”.`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
if (
|
|
592
|
+
binding?.source === "now" &&
|
|
593
|
+
!field.safeParse(new Date(0).toISOString()).success
|
|
594
|
+
) {
|
|
595
|
+
problems.push(
|
|
596
|
+
`O binding temporal “${path}.${key}” é incompatível com o input da action “${action.name}”.`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
for (const [key, field] of Object.entries(shape)) {
|
|
602
|
+
const suppliedByForm =
|
|
603
|
+
action.kind === "form" &&
|
|
604
|
+
key in ((action.fields ?? {}) as Record<string, unknown>);
|
|
605
|
+
if (
|
|
606
|
+
bindings[key] === undefined &&
|
|
607
|
+
!suppliedByForm &&
|
|
608
|
+
!field.safeParse(undefined).success
|
|
609
|
+
) {
|
|
610
|
+
problems.push(
|
|
611
|
+
`O input obrigatório “${key}” da action “${action.name}” não possui binding em “${path}”.`,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
488
617
|
export function resolvePresentationBinding(
|
|
489
618
|
binding: PresentationBinding,
|
|
490
619
|
context: PresentationBindingContext,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* inline; toast em sucesso/erro. Emite `data-action="<action.name>"` na raiz (selector E2E).
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { createContext, useContext, useEffect } from 'react'
|
|
20
|
+
import { createContext, useContext, useEffect, useId } from 'react'
|
|
21
21
|
import type { UseFormReturn } from 'react-hook-form'
|
|
22
22
|
import {
|
|
23
23
|
getLogicalType,
|
|
@@ -616,6 +616,10 @@ export interface ActionFormProps<TInput extends Record<string, unknown>, TData>
|
|
|
616
616
|
submitLabel?: string
|
|
617
617
|
cancelLabel?: string
|
|
618
618
|
onCancel?: () => void
|
|
619
|
+
/** Bloqueia campos e ações sem desmontar o formulário. */
|
|
620
|
+
disabled?: boolean
|
|
621
|
+
/** Informa carregamento para superfícies que coordenam ações como um grupo. */
|
|
622
|
+
onLoadingChange?: (loading: boolean) => void
|
|
619
623
|
/** Opções por campo pra select/multiselect carregados em runtime (ex.: papéis/skills
|
|
620
624
|
* por id). Sobrepõe as opções estáticas inferidas do z.enum. Chave = nome do campo. */
|
|
621
625
|
fieldOptions?: Record<string, SelectOption[]>
|
|
@@ -640,12 +644,15 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
640
644
|
submitLabel = 'Salvar',
|
|
641
645
|
cancelLabel = 'Cancelar',
|
|
642
646
|
onCancel,
|
|
647
|
+
disabled = false,
|
|
648
|
+
onLoadingChange,
|
|
643
649
|
fieldOptions,
|
|
644
650
|
className,
|
|
645
651
|
body,
|
|
646
652
|
footer,
|
|
647
653
|
children,
|
|
648
654
|
}: ActionFormProps<TInput, TData>) {
|
|
655
|
+
const formId = useId()
|
|
649
656
|
const { form, submit, isLoading, error, isSuccess } = useFormAction<TInput, TData>(action, {
|
|
650
657
|
...(defaultValues !== undefined ? { defaultValues: defaultValues as never } : {}),
|
|
651
658
|
onSuccess: (data) => {
|
|
@@ -657,6 +664,11 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
657
664
|
},
|
|
658
665
|
})
|
|
659
666
|
|
|
667
|
+
useEffect(() => {
|
|
668
|
+
onLoadingChange?.(isLoading)
|
|
669
|
+
return () => onLoadingChange?.(false)
|
|
670
|
+
}, [isLoading, onLoadingChange])
|
|
671
|
+
|
|
660
672
|
const shape = (objectSchemaShape(action.input) ?? {}) as Record<string, ZodTypeAny | undefined>
|
|
661
673
|
const fields = action.fields as Record<string, FieldSpec>
|
|
662
674
|
|
|
@@ -692,26 +704,33 @@ export function ActionForm<TInput extends Record<string, unknown>, TData>({
|
|
|
692
704
|
fieldOptions,
|
|
693
705
|
}}
|
|
694
706
|
>
|
|
695
|
-
<form onSubmit={submit} className={cn('flex flex-col', className)} data-action={action.name}>
|
|
707
|
+
<form id={formId} onSubmit={submit} className={cn('flex flex-col', className)} data-action={action.name}>
|
|
696
708
|
{wrapBody(
|
|
697
|
-
<
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
709
|
+
<fieldset disabled={disabled || isLoading} className="contents">
|
|
710
|
+
<FieldGroup>
|
|
711
|
+
{children !== undefined
|
|
712
|
+
? children
|
|
713
|
+
: Object.keys(fields).map((name) => <ActionFormField key={name} name={name} />)}
|
|
714
|
+
{errorBanner}
|
|
715
|
+
</FieldGroup>
|
|
716
|
+
</fieldset>,
|
|
703
717
|
)}
|
|
704
718
|
|
|
705
719
|
{wrapFooter(
|
|
706
720
|
<>
|
|
707
721
|
{onCancel !== undefined && (
|
|
708
|
-
<Button type="button" variant="ghost" onClick={onCancel}>
|
|
722
|
+
<Button type="button" form={formId} variant="ghost" onClick={onCancel}>
|
|
709
723
|
{cancelLabel}
|
|
710
724
|
</Button>
|
|
711
725
|
)}
|
|
712
726
|
{/* Gated por dirty: só habilita com mudança real (igual aos forms antigos).
|
|
713
727
|
busy = a submissão em andamento (spinner + disable vêm do Button). */}
|
|
714
|
-
<Button
|
|
728
|
+
<Button
|
|
729
|
+
type="submit"
|
|
730
|
+
form={formId}
|
|
731
|
+
busy={isLoading}
|
|
732
|
+
disabled={disabled || !form.formState.isDirty}
|
|
733
|
+
>
|
|
715
734
|
{submitLabel}
|
|
716
735
|
</Button>
|
|
717
736
|
</>,
|
|
@@ -33,6 +33,8 @@ type PageHeadingRegion = "header" | "intro";
|
|
|
33
33
|
const PageHeaderContext = createContext<PageHeadingRegion | null>(null);
|
|
34
34
|
const PageIntegralStateContext = createContext(false);
|
|
35
35
|
const PageActionsTargetContext = createContext<HTMLElement | null>(null);
|
|
36
|
+
const PageNavigationTargetContext = createContext<HTMLElement | null>(null);
|
|
37
|
+
const PageTitleTargetContext = createContext<HTMLElement | null>(null);
|
|
36
38
|
const PageShellContext = createContext(false);
|
|
37
39
|
const pageBarClassName =
|
|
38
40
|
"flex h-12 shrink-0 items-center border-b border-border bg-background text-foreground";
|
|
@@ -44,13 +46,34 @@ export function useInsidePageShell(): boolean {
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
function PageShellNavigationSlot({
|
|
49
|
+
targetRef,
|
|
47
50
|
className,
|
|
48
51
|
...props
|
|
49
|
-
}: HTMLAttributes<HTMLDivElement>
|
|
52
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
53
|
+
targetRef?: (target: HTMLDivElement | null) => void;
|
|
54
|
+
}): ReactElement {
|
|
50
55
|
return (
|
|
51
56
|
<div
|
|
57
|
+
ref={targetRef}
|
|
52
58
|
data-slot="page-shell-navigation"
|
|
53
|
-
className={cn("min-w-0
|
|
59
|
+
className={cn("min-w-0 shrink-0 overflow-hidden", className)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function PageShellTitleSlot({
|
|
66
|
+
targetRef,
|
|
67
|
+
className,
|
|
68
|
+
...props
|
|
69
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
70
|
+
targetRef?: (target: HTMLDivElement | null) => void;
|
|
71
|
+
}): ReactElement {
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
ref={targetRef}
|
|
75
|
+
data-slot="page-shell-title"
|
|
76
|
+
className={cn("min-w-0 flex-1", className)}
|
|
54
77
|
{...props}
|
|
55
78
|
/>
|
|
56
79
|
);
|
|
@@ -99,6 +122,9 @@ export function PageShell({
|
|
|
99
122
|
const [actionsTarget, setActionsTarget] = useState<HTMLDivElement | null>(
|
|
100
123
|
null,
|
|
101
124
|
);
|
|
125
|
+
const [navigationTarget, setNavigationTarget] =
|
|
126
|
+
useState<HTMLDivElement | null>(null);
|
|
127
|
+
const [titleTarget, setTitleTarget] = useState<HTMLDivElement | null>(null);
|
|
102
128
|
|
|
103
129
|
return (
|
|
104
130
|
<div
|
|
@@ -114,8 +140,7 @@ export function PageShell({
|
|
|
114
140
|
slot="page"
|
|
115
141
|
slots={{
|
|
116
142
|
leading: PageShellNavigationSlot,
|
|
117
|
-
title:
|
|
118
|
-
description: PageDescription,
|
|
143
|
+
title: PageShellTitleSlot,
|
|
119
144
|
actions: PageShellActionsSlot,
|
|
120
145
|
}}
|
|
121
146
|
leadingPlacement="inline"
|
|
@@ -124,17 +149,24 @@ export function PageShell({
|
|
|
124
149
|
className={pageBarClassName}
|
|
125
150
|
data-variant="bar"
|
|
126
151
|
>
|
|
127
|
-
<PageShellNavigationSlot
|
|
152
|
+
<PageShellNavigationSlot targetRef={setNavigationTarget}>
|
|
153
|
+
{navigation}
|
|
154
|
+
</PageShellNavigationSlot>
|
|
155
|
+
<PageShellTitleSlot targetRef={setTitleTarget} />
|
|
128
156
|
<PageShellActionsSlot targetRef={setActionsTarget}>
|
|
129
157
|
{actions}
|
|
130
158
|
</PageShellActionsSlot>
|
|
131
159
|
</SurfaceHeader>
|
|
132
160
|
<PageShellContext.Provider value>
|
|
133
|
-
<
|
|
134
|
-
<
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
161
|
+
<PageNavigationTargetContext.Provider value={navigationTarget}>
|
|
162
|
+
<PageTitleTargetContext.Provider value={titleTarget}>
|
|
163
|
+
<PageActionsTarget target={actionsTarget}>
|
|
164
|
+
<div data-slot="page-shell-content" className="min-h-0 flex-1">
|
|
165
|
+
{children}
|
|
166
|
+
</div>
|
|
167
|
+
</PageActionsTarget>
|
|
168
|
+
</PageTitleTargetContext.Provider>
|
|
169
|
+
</PageNavigationTargetContext.Provider>
|
|
138
170
|
</PageShellContext.Provider>
|
|
139
171
|
</div>
|
|
140
172
|
);
|
|
@@ -172,7 +204,6 @@ interface PageBaseProps extends Omit<
|
|
|
172
204
|
|
|
173
205
|
interface PageShorthandProps extends PageBaseProps {
|
|
174
206
|
title: ReactNode;
|
|
175
|
-
description?: ReactNode;
|
|
176
207
|
actions?: ReactNode;
|
|
177
208
|
children: ReactNode;
|
|
178
209
|
}
|
|
@@ -188,7 +219,6 @@ export type PageProps = PageShorthandProps | PageComposedProps;
|
|
|
188
219
|
|
|
189
220
|
export function Page({
|
|
190
221
|
title,
|
|
191
|
-
description,
|
|
192
222
|
actions,
|
|
193
223
|
className,
|
|
194
224
|
children,
|
|
@@ -232,6 +262,7 @@ export function Page({
|
|
|
232
262
|
}
|
|
233
263
|
if (!shorthand) {
|
|
234
264
|
const validIntroComposition =
|
|
265
|
+
!insideShell &&
|
|
235
266
|
intros.length === 1 &&
|
|
236
267
|
headers.length === 0 &&
|
|
237
268
|
bodies.length === 1 &&
|
|
@@ -244,10 +275,22 @@ export function Page({
|
|
|
244
275
|
bodies.length === 1 &&
|
|
245
276
|
footers.length <= 1 &&
|
|
246
277
|
headers.length + bodies.length + footers.length === nodes.length;
|
|
247
|
-
|
|
278
|
+
const validShellComposition =
|
|
279
|
+
insideShell &&
|
|
280
|
+
headers.length === 1 &&
|
|
281
|
+
bodies.length === 1 &&
|
|
282
|
+
intros.length <= 1 &&
|
|
283
|
+
footers.length <= 1 &&
|
|
284
|
+
headers.length + intros.length + bodies.length + footers.length ===
|
|
285
|
+
nodes.length;
|
|
286
|
+
if (
|
|
287
|
+
!validIntroComposition &&
|
|
288
|
+
!validStandaloneComposition &&
|
|
289
|
+
!validShellComposition
|
|
290
|
+
) {
|
|
248
291
|
throw new Error(
|
|
249
292
|
insideShell
|
|
250
|
-
? "Page explícito dentro de PageShell exige um
|
|
293
|
+
? "Page explícito dentro de PageShell exige um PageHeader, um PageBody e aceita no máximo um PageIntro e um PageFooter como filhos diretos."
|
|
251
294
|
: "Page explícito exige um PageHeader ou PageIntro, um PageBody e no máximo um PageFooter como filhos diretos.",
|
|
252
295
|
);
|
|
253
296
|
}
|
|
@@ -255,22 +298,16 @@ export function Page({
|
|
|
255
298
|
const content =
|
|
256
299
|
shorthand && insideShell ? (
|
|
257
300
|
<>
|
|
258
|
-
<
|
|
301
|
+
<PageHeader>
|
|
259
302
|
<PageTitle>{title}</PageTitle>
|
|
260
|
-
{description !== undefined && (
|
|
261
|
-
<PageDescription>{description}</PageDescription>
|
|
262
|
-
)}
|
|
263
303
|
{actions !== undefined && <PageActions>{actions}</PageActions>}
|
|
264
|
-
</
|
|
304
|
+
</PageHeader>
|
|
265
305
|
<PageBody>{children}</PageBody>
|
|
266
306
|
</>
|
|
267
307
|
) : shorthand ? (
|
|
268
308
|
<>
|
|
269
309
|
<PageHeader>
|
|
270
310
|
<PageTitle>{title}</PageTitle>
|
|
271
|
-
{description !== undefined && (
|
|
272
|
-
<PageDescription>{description}</PageDescription>
|
|
273
|
-
)}
|
|
274
311
|
{actions !== undefined && <PageActions>{actions}</PageActions>}
|
|
275
312
|
</PageHeader>
|
|
276
313
|
<PageBody>{children}</PageBody>
|
|
@@ -337,12 +374,9 @@ export function PageIntro({
|
|
|
337
374
|
name="PageIntro"
|
|
338
375
|
slot="page-intro"
|
|
339
376
|
slots={{
|
|
340
|
-
leading: [PageBack, PageNavigation],
|
|
341
377
|
title: PageTitle,
|
|
342
|
-
description: PageDescription,
|
|
343
378
|
actions: PageActions,
|
|
344
379
|
}}
|
|
345
|
-
leadingPlacement="above"
|
|
346
380
|
className={className}
|
|
347
381
|
{...props}
|
|
348
382
|
>
|
|
@@ -361,9 +395,17 @@ export function PageHeader({
|
|
|
361
395
|
...props
|
|
362
396
|
}: PageHeaderProps): ReactElement {
|
|
363
397
|
const page = useContext(PageContext);
|
|
398
|
+
const insideShell = useContext(PageShellContext);
|
|
364
399
|
const integralState = useContext(PageIntegralStateContext);
|
|
365
400
|
requireParent(page !== null, "PageHeader", "Page");
|
|
366
|
-
if (integralState) return <></>;
|
|
401
|
+
if (integralState && !insideShell) return <></>;
|
|
402
|
+
if (insideShell) {
|
|
403
|
+
return (
|
|
404
|
+
<PageHeaderContext.Provider value="header">
|
|
405
|
+
{children}
|
|
406
|
+
</PageHeaderContext.Provider>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
367
409
|
return (
|
|
368
410
|
<PageHeaderContext.Provider value="header">
|
|
369
411
|
<SurfaceHeader
|
|
@@ -372,7 +414,6 @@ export function PageHeader({
|
|
|
372
414
|
slots={{
|
|
373
415
|
leading: [PageBack, PageNavigation],
|
|
374
416
|
title: PageTitle,
|
|
375
|
-
description: PageDescription,
|
|
376
417
|
actions: PageActions,
|
|
377
418
|
}}
|
|
378
419
|
leadingPlacement="inline"
|
|
@@ -390,8 +431,10 @@ export function PageTitle({
|
|
|
390
431
|
...props
|
|
391
432
|
}: HTMLAttributes<HTMLHeadingElement>): ReactElement {
|
|
392
433
|
const variant = useContext(PageHeaderContext);
|
|
434
|
+
const insideShell = useContext(PageShellContext);
|
|
435
|
+
const target = useContext(PageTitleTargetContext);
|
|
393
436
|
requireParent(variant !== null, "PageTitle", "PageHeader ou PageIntro");
|
|
394
|
-
|
|
437
|
+
const title = (
|
|
395
438
|
<h1
|
|
396
439
|
data-slot="page-title"
|
|
397
440
|
className={cn(
|
|
@@ -403,21 +446,10 @@ export function PageTitle({
|
|
|
403
446
|
{...props}
|
|
404
447
|
/>
|
|
405
448
|
);
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
...props
|
|
411
|
-
}: HTMLAttributes<HTMLParagraphElement>): ReactElement {
|
|
412
|
-
const variant = useContext(PageHeaderContext);
|
|
413
|
-
requireParent(variant !== null, "PageDescription", "PageHeader ou PageIntro");
|
|
414
|
-
return (
|
|
415
|
-
<p
|
|
416
|
-
data-slot="page-description"
|
|
417
|
-
className={cn(surfaceHeaderClasses.page.description, className)}
|
|
418
|
-
{...props}
|
|
419
|
-
/>
|
|
420
|
-
);
|
|
449
|
+
if (insideShell && variant === "header") {
|
|
450
|
+
return target === null ? <></> : createPortal(title, target);
|
|
451
|
+
}
|
|
452
|
+
return title;
|
|
421
453
|
}
|
|
422
454
|
|
|
423
455
|
export function PageActions({
|
|
@@ -460,18 +492,25 @@ export function PageNavigation({
|
|
|
460
492
|
className,
|
|
461
493
|
...props
|
|
462
494
|
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
495
|
+
const variant = useContext(PageHeaderContext);
|
|
496
|
+
const insideShell = useContext(PageShellContext);
|
|
497
|
+
const target = useContext(PageNavigationTargetContext);
|
|
463
498
|
requireParent(
|
|
464
|
-
|
|
499
|
+
variant !== null,
|
|
465
500
|
"PageNavigation",
|
|
466
|
-
"PageHeader
|
|
501
|
+
"PageHeader",
|
|
467
502
|
);
|
|
468
|
-
|
|
503
|
+
const navigation = (
|
|
469
504
|
<div
|
|
470
505
|
data-slot="page-navigation-content"
|
|
471
506
|
className={cn("min-w-0", className)}
|
|
472
507
|
{...props}
|
|
473
508
|
/>
|
|
474
509
|
);
|
|
510
|
+
if (insideShell && variant === "header") {
|
|
511
|
+
return target === null ? <></> : createPortal(navigation, target);
|
|
512
|
+
}
|
|
513
|
+
return navigation;
|
|
475
514
|
}
|
|
476
515
|
|
|
477
516
|
export function PageBack({
|
|
@@ -481,11 +520,13 @@ export function PageBack({
|
|
|
481
520
|
...props
|
|
482
521
|
}: PageBackProps): ReactElement {
|
|
483
522
|
const variant = useContext(PageHeaderContext);
|
|
484
|
-
|
|
523
|
+
const insideShell = useContext(PageShellContext);
|
|
524
|
+
const target = useContext(PageNavigationTargetContext);
|
|
525
|
+
requireParent(variant !== null, "PageBack", "PageHeader");
|
|
485
526
|
const destination =
|
|
486
527
|
typeof children === "string" ? children : "a página anterior";
|
|
487
528
|
const accessibleLabel = ariaLabel ?? `Voltar para ${destination}`;
|
|
488
|
-
|
|
529
|
+
const back = (
|
|
489
530
|
<Button asChild variant="ghost" size="icon-sm">
|
|
490
531
|
<a
|
|
491
532
|
data-slot="page-back"
|
|
@@ -499,6 +540,10 @@ export function PageBack({
|
|
|
499
540
|
</a>
|
|
500
541
|
</Button>
|
|
501
542
|
);
|
|
543
|
+
if (insideShell && variant === "header") {
|
|
544
|
+
return target === null ? <></> : createPortal(back, target);
|
|
545
|
+
}
|
|
546
|
+
return back;
|
|
502
547
|
}
|
|
503
548
|
|
|
504
549
|
export function PageBody({
|