@softize/opus 14.0.0 → 15.0.1
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 +95 -0
- package/bin/cli.mjs +2 -0
- package/bin/lib/check.mjs +44 -6
- package/bin/lib/cli-shared.mjs +30 -1
- package/bin/lib/copy.mjs +3 -0
- package/bin/lib/db.mjs +2 -0
- package/docs/adr/0004-page-content-state-is-composed.md +46 -5
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
- package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
- package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
- package/docs/data-layer.md +9 -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 +18 -6
- package/src/ui/components/patterns/list.tsx +67 -26
- package/src/ui/components/patterns/page-state.tsx +48 -6
- package/src/ui/components/patterns/page.tsx +224 -55
- package/src/ui/components/patterns/state-surface.tsx +137 -23
- package/src/ui/components/patterns/surface-header.tsx +110 -17
- package/src/ui/components/patterns/trigger.tsx +7 -6
- package/src/ui/components/primitives/button-group.tsx +34 -8
- package/src/ui/components/primitives/control.ts +12 -3
- package/src/ui/docs/content/action-list-dialog.md +1 -1
- package/src/ui/docs/content/action-list.md +34 -3
- package/src/ui/docs/content/action-trigger.md +4 -3
- package/src/ui/docs/content/alert.md +16 -4
- package/src/ui/docs/content/button.md +20 -5
- package/src/ui/docs/content/content.md +3 -3
- package/src/ui/docs/content/data-state.md +4 -4
- package/src/ui/docs/content/page.md +160 -44
- package/src/ui/meta.ts +6 -6
- package/src/ui/react.tsx +8 -2
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import {
|
|
23
|
+
Children,
|
|
24
|
+
Fragment,
|
|
25
|
+
isValidElement,
|
|
23
26
|
useEffect,
|
|
24
27
|
useLayoutEffect,
|
|
25
28
|
useMemo,
|
|
@@ -74,6 +77,7 @@ import {
|
|
|
74
77
|
PaginationPrevious,
|
|
75
78
|
} from "../primitives/pagination.tsx";
|
|
76
79
|
import { Button } from "../primitives/button.tsx";
|
|
80
|
+
import { ButtonGroup } from "../primitives/button-group.tsx";
|
|
77
81
|
import { Calendar } from "../primitives/calendar.tsx";
|
|
78
82
|
import { Checkbox } from "../primitives/checkbox.tsx";
|
|
79
83
|
import {
|
|
@@ -262,7 +266,9 @@ export interface ActionFilterBarProps {
|
|
|
262
266
|
filterOptions?: Record<string, SelectOption[]>;
|
|
263
267
|
onRefresh?: () => Promise<void> | void;
|
|
264
268
|
refreshing?: boolean;
|
|
265
|
-
/**
|
|
269
|
+
/** Controles auxiliares, agrupados com o refresh em um ButtonGroup espaçado. */
|
|
270
|
+
controls?: ReactNode;
|
|
271
|
+
/** Ações do consumidor, separadas do grupo de controles. */
|
|
266
272
|
actions?: ReactNode;
|
|
267
273
|
}
|
|
268
274
|
|
|
@@ -293,6 +299,8 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
293
299
|
action: ListActionLike;
|
|
294
300
|
/** Input BASE (escopo fixo, ex.: { workspaceId }) — a toolbar soma por cima. */
|
|
295
301
|
input: TInput;
|
|
302
|
+
/** Ações do consumidor no fim da barra de filtros, depois dos controles de exibição. */
|
|
303
|
+
toolbarActions?: ReactNode;
|
|
296
304
|
/** Tabela explícita — sobrepõe as colunas do contrato. */
|
|
297
305
|
columns?: ActionListColumn<TItem>[];
|
|
298
306
|
/** Células custom POR CIMA das colunas do contrato (chave = column.key). */
|
|
@@ -904,6 +912,7 @@ export function ActionFilterBar({
|
|
|
904
912
|
filterOptions,
|
|
905
913
|
onRefresh,
|
|
906
914
|
refreshing = false,
|
|
915
|
+
controls,
|
|
907
916
|
actions,
|
|
908
917
|
}: ActionFilterBarProps): React.ReactElement {
|
|
909
918
|
const dicts = useDicts();
|
|
@@ -1047,7 +1056,7 @@ export function ActionFilterBar({
|
|
|
1047
1056
|
<Tooltip>
|
|
1048
1057
|
<TooltipTrigger asChild>
|
|
1049
1058
|
<Button
|
|
1050
|
-
variant="
|
|
1059
|
+
variant="ghost"
|
|
1051
1060
|
size="icon"
|
|
1052
1061
|
className="shrink-0"
|
|
1053
1062
|
aria-label="Filtros"
|
|
@@ -1061,27 +1070,32 @@ export function ActionFilterBar({
|
|
|
1061
1070
|
)}
|
|
1062
1071
|
<div className="ml-auto flex min-w-0 items-center gap-2">
|
|
1063
1072
|
{!searchFirst && searchBox}
|
|
1064
|
-
{onRefresh !== undefined && (
|
|
1065
|
-
<
|
|
1066
|
-
|
|
1067
|
-
<
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1073
|
+
{(onRefresh !== undefined || controls !== undefined) && (
|
|
1074
|
+
<ButtonGroup mode="spaced">
|
|
1075
|
+
{onRefresh !== undefined && (
|
|
1076
|
+
<Tooltip>
|
|
1077
|
+
<TooltipTrigger asChild>
|
|
1078
|
+
<Button
|
|
1079
|
+
variant="ghost"
|
|
1080
|
+
size="icon"
|
|
1081
|
+
className="shrink-0"
|
|
1082
|
+
aria-label="Recarregar"
|
|
1083
|
+
onClick={() => void onRefresh()}
|
|
1084
|
+
disabled={refreshing}
|
|
1085
|
+
>
|
|
1086
|
+
<RefreshCw
|
|
1087
|
+
className={cn(
|
|
1088
|
+
"h-3.5 w-3.5",
|
|
1089
|
+
refreshing && "animate-spin",
|
|
1090
|
+
)}
|
|
1091
|
+
/>
|
|
1092
|
+
</Button>
|
|
1093
|
+
</TooltipTrigger>
|
|
1094
|
+
<TooltipContent>Recarregar</TooltipContent>
|
|
1095
|
+
</Tooltip>
|
|
1096
|
+
)}
|
|
1097
|
+
{controls}
|
|
1098
|
+
</ButtonGroup>
|
|
1085
1099
|
)}
|
|
1086
1100
|
{actions}
|
|
1087
1101
|
</div>
|
|
@@ -1131,9 +1145,35 @@ export function ActionFilterBar({
|
|
|
1131
1145
|
// ActionList
|
|
1132
1146
|
// =============================================================================
|
|
1133
1147
|
|
|
1148
|
+
/**
|
|
1149
|
+
* Ações de uma linha da tabela. Com duas ou mais, agrupa e espaça; com uma só, não
|
|
1150
|
+
* materializa o `ButtonGroup` — um `role="group"` sem nome por linha enche a árvore de
|
|
1151
|
+
* acessibilidade sem informar nada, e `mode="spaced"` não teria o que espaçar.
|
|
1152
|
+
*/
|
|
1153
|
+
function RowActions({ children }: { children: ReactNode }) {
|
|
1154
|
+
// O consumidor costuma devolver as ações dentro de um fragment, que conta como UM filho —
|
|
1155
|
+
// e às vezes dentro de fragments aninhados. Expandir recursivamente, e contar só elementos,
|
|
1156
|
+
// evita tanto tratar duas ações como uma quanto deixar um espaço em branco virar ação.
|
|
1157
|
+
const expand = (node: ReactNode): ReactNode[] =>
|
|
1158
|
+
Children.toArray(node).flatMap((child) =>
|
|
1159
|
+
isValidElement(child) && child.type === Fragment
|
|
1160
|
+
? expand((child.props as { children?: ReactNode }).children)
|
|
1161
|
+
: [child],
|
|
1162
|
+
);
|
|
1163
|
+
const alone = expand(children).filter(isValidElement).length < 2;
|
|
1164
|
+
return alone ? (
|
|
1165
|
+
<div className="flex justify-end">{children}</div>
|
|
1166
|
+
) : (
|
|
1167
|
+
<ButtonGroup mode="spaced" className="ml-auto">
|
|
1168
|
+
{children}
|
|
1169
|
+
</ButtonGroup>
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1134
1173
|
export function ActionList<TInput, TItem>({
|
|
1135
1174
|
action,
|
|
1136
1175
|
input,
|
|
1176
|
+
toolbarActions,
|
|
1137
1177
|
columns,
|
|
1138
1178
|
cells,
|
|
1139
1179
|
filterOptions,
|
|
@@ -1394,7 +1434,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1394
1434
|
filterOptions={filterOptions}
|
|
1395
1435
|
onRefresh={() => refetch()}
|
|
1396
1436
|
refreshing={isLoading}
|
|
1397
|
-
|
|
1437
|
+
controls={
|
|
1398
1438
|
<>
|
|
1399
1439
|
{hasSegment && (
|
|
1400
1440
|
<ToggleGroup
|
|
@@ -1433,7 +1473,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1433
1473
|
<TooltipTrigger asChild>
|
|
1434
1474
|
<PopoverTrigger asChild>
|
|
1435
1475
|
<Button
|
|
1436
|
-
variant="
|
|
1476
|
+
variant="ghost"
|
|
1437
1477
|
size="icon"
|
|
1438
1478
|
className="shrink-0"
|
|
1439
1479
|
aria-label="Exibição"
|
|
@@ -1517,6 +1557,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1517
1557
|
</Popover>
|
|
1518
1558
|
</>
|
|
1519
1559
|
}
|
|
1560
|
+
actions={toolbarActions}
|
|
1520
1561
|
/>
|
|
1521
1562
|
</div>
|
|
1522
1563
|
) : null;
|
|
@@ -1621,7 +1662,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1621
1662
|
className="w-px whitespace-nowrap text-right"
|
|
1622
1663
|
onClick={(e) => e.stopPropagation()}
|
|
1623
1664
|
>
|
|
1624
|
-
{rowActions(item)}
|
|
1665
|
+
<RowActions>{rowActions(item)}</RowActions>
|
|
1625
1666
|
</TableCell>
|
|
1626
1667
|
)}
|
|
1627
1668
|
</TableRow>
|
|
@@ -7,7 +7,12 @@
|
|
|
7
7
|
* as superfícies são as mesmas (state-surface.tsx), aqui na escala da página.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
10
|
+
import {
|
|
11
|
+
createContext,
|
|
12
|
+
useContext,
|
|
13
|
+
useLayoutEffect,
|
|
14
|
+
type ReactNode,
|
|
15
|
+
} from "react";
|
|
11
16
|
import { cn } from "../../lib/cn.ts";
|
|
12
17
|
import {
|
|
13
18
|
DEFAULT_RETRY_LABEL,
|
|
@@ -18,6 +23,23 @@ import {
|
|
|
18
23
|
|
|
19
24
|
export type PageStateStatus = "loading" | "error" | "empty" | "ready";
|
|
20
25
|
|
|
26
|
+
const PageStatePresenceContext = createContext<(() => () => void) | null>(null);
|
|
27
|
+
|
|
28
|
+
/** Integração interna: permite que Page reconheça um PageState mesmo dentro de outro componente. */
|
|
29
|
+
export function PageStatePresenceProvider({
|
|
30
|
+
register,
|
|
31
|
+
children,
|
|
32
|
+
}: {
|
|
33
|
+
register: () => () => void;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}): React.ReactElement {
|
|
36
|
+
return (
|
|
37
|
+
<PageStatePresenceContext.Provider value={register}>
|
|
38
|
+
{children}
|
|
39
|
+
</PageStatePresenceContext.Provider>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
export interface PageStateProps {
|
|
22
44
|
status: PageStateStatus;
|
|
23
45
|
/** Título específico do estado. Defaults seguros existem para loading, error e empty. */
|
|
@@ -54,15 +76,31 @@ export function PageState({
|
|
|
54
76
|
children,
|
|
55
77
|
className,
|
|
56
78
|
}: PageStateProps): React.ReactElement {
|
|
79
|
+
const registerPresence = useContext(PageStatePresenceContext);
|
|
80
|
+
useLayoutEffect(() => {
|
|
81
|
+
if (status === "ready" || registerPresence === null) return;
|
|
82
|
+
return registerPresence();
|
|
83
|
+
}, [registerPresence, status]);
|
|
84
|
+
|
|
57
85
|
if (status === "ready") return <>{children}</>;
|
|
58
86
|
|
|
87
|
+
const stateClassName = cn(
|
|
88
|
+
"flex min-h-64 flex-1 items-center justify-center",
|
|
89
|
+
className,
|
|
90
|
+
);
|
|
91
|
+
|
|
59
92
|
if (status === "loading") {
|
|
60
93
|
return (
|
|
61
|
-
<div
|
|
94
|
+
<div
|
|
95
|
+
data-slot="page-state"
|
|
96
|
+
data-status="loading"
|
|
97
|
+
className={stateClassName}
|
|
98
|
+
>
|
|
62
99
|
<LoadingSurface
|
|
63
|
-
className="min-h-64"
|
|
100
|
+
className="w-full min-h-64"
|
|
64
101
|
title={title ?? "Carregando…"}
|
|
65
102
|
description={description}
|
|
103
|
+
titleLevel={1}
|
|
66
104
|
/>
|
|
67
105
|
</div>
|
|
68
106
|
);
|
|
@@ -73,10 +111,12 @@ export function PageState({
|
|
|
73
111
|
<div
|
|
74
112
|
data-slot="page-state"
|
|
75
113
|
data-status="error"
|
|
76
|
-
className={
|
|
114
|
+
className={stateClassName}
|
|
77
115
|
>
|
|
78
116
|
<ErrorSurface
|
|
79
|
-
|
|
117
|
+
presentation="state"
|
|
118
|
+
className="w-full max-w-xl"
|
|
119
|
+
titleLevel={1}
|
|
80
120
|
icon={icon}
|
|
81
121
|
title={title ?? errorMessage}
|
|
82
122
|
description={description}
|
|
@@ -90,9 +130,11 @@ export function PageState({
|
|
|
90
130
|
}
|
|
91
131
|
|
|
92
132
|
return (
|
|
93
|
-
<div data-slot="page-state" data-status="empty" className={
|
|
133
|
+
<div data-slot="page-state" data-status="empty" className={stateClassName}>
|
|
94
134
|
<EmptySurface
|
|
135
|
+
frame="bare"
|
|
95
136
|
className="min-h-64"
|
|
137
|
+
titleLevel={1}
|
|
96
138
|
icon={icon}
|
|
97
139
|
title={title ?? emptyMessage}
|
|
98
140
|
description={description}
|
|
@@ -9,17 +9,38 @@ import {
|
|
|
9
9
|
Children,
|
|
10
10
|
createContext,
|
|
11
11
|
isValidElement,
|
|
12
|
+
useCallback,
|
|
12
13
|
useContext,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
13
16
|
type HTMLAttributes,
|
|
17
|
+
type ComponentProps,
|
|
14
18
|
type ReactElement,
|
|
15
19
|
type ReactNode,
|
|
16
20
|
} from "react";
|
|
17
21
|
import { createPortal } from "react-dom";
|
|
22
|
+
import { ArrowLeft } from "lucide-react";
|
|
18
23
|
import { cn } from "../../lib/cn.ts";
|
|
24
|
+
import { Button } from "../primitives/button.tsx";
|
|
25
|
+
import {
|
|
26
|
+
Tooltip,
|
|
27
|
+
TooltipContent,
|
|
28
|
+
TooltipProvider,
|
|
29
|
+
TooltipTrigger,
|
|
30
|
+
} from "../primitives/tooltip.tsx";
|
|
19
31
|
import { SurfaceHeader, surfaceHeaderClasses } from "./surface-header.tsx";
|
|
32
|
+
import { PageStatePresenceProvider } from "./page-state.tsx";
|
|
33
|
+
|
|
34
|
+
export type PageHeaderVariant = "default" | "bar";
|
|
35
|
+
|
|
36
|
+
interface PageContextValue {
|
|
37
|
+
headerVariant: PageHeaderVariant;
|
|
38
|
+
containerClassName?: string;
|
|
39
|
+
}
|
|
20
40
|
|
|
21
|
-
const PageContext = createContext(
|
|
22
|
-
const PageHeaderContext = createContext(
|
|
41
|
+
const PageContext = createContext<PageContextValue | null>(null);
|
|
42
|
+
const PageHeaderContext = createContext<PageHeaderVariant | null>(null);
|
|
43
|
+
const PageIntegralStateContext = createContext(false);
|
|
23
44
|
const PageActionsTargetContext = createContext<HTMLElement | null>(null);
|
|
24
45
|
|
|
25
46
|
export function PageActionsTarget({
|
|
@@ -54,8 +75,6 @@ interface PageBaseProps extends Omit<
|
|
|
54
75
|
|
|
55
76
|
interface PageShorthandProps extends PageBaseProps {
|
|
56
77
|
title: ReactNode;
|
|
57
|
-
/** Total de itens ao lado do título. */
|
|
58
|
-
count?: number;
|
|
59
78
|
description?: ReactNode;
|
|
60
79
|
actions?: ReactNode;
|
|
61
80
|
children: ReactNode;
|
|
@@ -63,7 +82,6 @@ interface PageShorthandProps extends PageBaseProps {
|
|
|
63
82
|
|
|
64
83
|
interface PageComposedProps extends PageBaseProps {
|
|
65
84
|
title?: never;
|
|
66
|
-
count?: never;
|
|
67
85
|
description?: never;
|
|
68
86
|
actions?: never;
|
|
69
87
|
children: ReactNode;
|
|
@@ -73,7 +91,6 @@ export type PageProps = PageShorthandProps | PageComposedProps;
|
|
|
73
91
|
|
|
74
92
|
export function Page({
|
|
75
93
|
title,
|
|
76
|
-
count,
|
|
77
94
|
description,
|
|
78
95
|
actions,
|
|
79
96
|
className,
|
|
@@ -82,6 +99,25 @@ export function Page({
|
|
|
82
99
|
}: PageProps): ReactElement {
|
|
83
100
|
const shorthand = title !== undefined;
|
|
84
101
|
const nodes = Children.toArray(children);
|
|
102
|
+
const headers = nodes.filter(
|
|
103
|
+
(node) => isValidElement(node) && node.type === PageHeader,
|
|
104
|
+
);
|
|
105
|
+
const bodies = nodes.filter(
|
|
106
|
+
(node) => isValidElement(node) && node.type === PageBody,
|
|
107
|
+
);
|
|
108
|
+
const activeStateCount = useRef(0);
|
|
109
|
+
const [integralState, setIntegralState] = useState(false);
|
|
110
|
+
const registerIntegralState = useCallback(() => {
|
|
111
|
+
activeStateCount.current += 1;
|
|
112
|
+
setIntegralState(true);
|
|
113
|
+
let registered = true;
|
|
114
|
+
return () => {
|
|
115
|
+
if (!registered) return;
|
|
116
|
+
registered = false;
|
|
117
|
+
activeStateCount.current = Math.max(0, activeStateCount.current - 1);
|
|
118
|
+
setIntegralState(activeStateCount.current > 0);
|
|
119
|
+
};
|
|
120
|
+
}, []);
|
|
85
121
|
if (
|
|
86
122
|
shorthand &&
|
|
87
123
|
nodes.some((node) => isValidElement(node) && node.type === PageHeader)
|
|
@@ -91,12 +127,6 @@ export function Page({
|
|
|
91
127
|
);
|
|
92
128
|
}
|
|
93
129
|
if (!shorthand) {
|
|
94
|
-
const headers = nodes.filter(
|
|
95
|
-
(node) => isValidElement(node) && node.type === PageHeader,
|
|
96
|
-
);
|
|
97
|
-
const bodies = nodes.filter(
|
|
98
|
-
(node) => isValidElement(node) && node.type === PageBody,
|
|
99
|
-
);
|
|
100
130
|
if (
|
|
101
131
|
headers.length !== 1 ||
|
|
102
132
|
bodies.length !== 1 ||
|
|
@@ -107,49 +137,105 @@ export function Page({
|
|
|
107
137
|
);
|
|
108
138
|
}
|
|
109
139
|
}
|
|
140
|
+
const headerVariant: PageHeaderVariant = shorthand
|
|
141
|
+
? "default"
|
|
142
|
+
: ((headers[0] as ReactElement<PageHeaderProps> | undefined)?.props
|
|
143
|
+
.variant ?? "default");
|
|
144
|
+
const pageContext: PageContextValue = {
|
|
145
|
+
headerVariant,
|
|
146
|
+
containerClassName: className,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const content = shorthand ? (
|
|
150
|
+
<>
|
|
151
|
+
<PageHeader>
|
|
152
|
+
<PageTitle>{title}</PageTitle>
|
|
153
|
+
{description !== undefined && (
|
|
154
|
+
<PageDescription>{description}</PageDescription>
|
|
155
|
+
)}
|
|
156
|
+
{actions !== undefined && <PageActions>{actions}</PageActions>}
|
|
157
|
+
</PageHeader>
|
|
158
|
+
<PageBody>{children}</PageBody>
|
|
159
|
+
</>
|
|
160
|
+
) : (
|
|
161
|
+
children
|
|
162
|
+
);
|
|
110
163
|
|
|
111
164
|
return (
|
|
112
|
-
<PageContext.Provider value>
|
|
113
|
-
<
|
|
114
|
-
<
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
165
|
+
<PageContext.Provider value={pageContext}>
|
|
166
|
+
<PageIntegralStateContext.Provider value={integralState}>
|
|
167
|
+
<PageStatePresenceProvider register={registerIntegralState}>
|
|
168
|
+
<main
|
|
169
|
+
data-slot="page"
|
|
170
|
+
className={cn(
|
|
171
|
+
"min-w-0 flex-1",
|
|
172
|
+
(headerVariant === "bar" || integralState) && "flex flex-col",
|
|
173
|
+
// A barra contém a rolagem (`min-h-0`); o estado integral ocupa a altura
|
|
174
|
+
// disponível (`min-h-full`). Emitir os dois juntos deixava o resultado por
|
|
175
|
+
// conta da ordem do CSS gerado — no estado integral, quem manda é ele.
|
|
176
|
+
integralState ? "min-h-full" : headerVariant === "bar" && "min-h-0",
|
|
177
|
+
)}
|
|
178
|
+
{...props}
|
|
179
|
+
>
|
|
180
|
+
{headerVariant === "bar" ? (
|
|
181
|
+
content
|
|
182
|
+
) : (
|
|
183
|
+
<div
|
|
184
|
+
className={cn(
|
|
185
|
+
"mx-auto max-w-7xl space-y-6 px-8 py-8",
|
|
186
|
+
integralState &&
|
|
187
|
+
"flex min-h-full w-full flex-1 flex-col space-y-0",
|
|
188
|
+
className,
|
|
122
189
|
)}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
</div>
|
|
131
|
-
</main>
|
|
190
|
+
>
|
|
191
|
+
{content}
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</main>
|
|
195
|
+
</PageStatePresenceProvider>
|
|
196
|
+
</PageIntegralStateContext.Provider>
|
|
132
197
|
</PageContext.Provider>
|
|
133
198
|
);
|
|
134
199
|
}
|
|
135
200
|
|
|
136
201
|
/** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
|
|
202
|
+
export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
203
|
+
/** `default` fica no container; `bar` cria uma faixa compacta no topo da Page. */
|
|
204
|
+
variant?: PageHeaderVariant;
|
|
205
|
+
}
|
|
206
|
+
|
|
137
207
|
export function PageHeader({
|
|
208
|
+
variant = "default",
|
|
209
|
+
className,
|
|
138
210
|
children,
|
|
139
211
|
...props
|
|
140
|
-
}:
|
|
141
|
-
|
|
212
|
+
}: PageHeaderProps): ReactElement {
|
|
213
|
+
const page = useContext(PageContext);
|
|
214
|
+
const integralState = useContext(PageIntegralStateContext);
|
|
215
|
+
requireParent(page !== null, "PageHeader", "Page");
|
|
216
|
+
if (integralState) return <></>;
|
|
142
217
|
return (
|
|
143
|
-
<PageHeaderContext.Provider value>
|
|
218
|
+
<PageHeaderContext.Provider value={variant}>
|
|
144
219
|
<SurfaceHeader
|
|
145
220
|
name="PageHeader"
|
|
146
221
|
slot="page"
|
|
147
222
|
slots={{
|
|
223
|
+
leading: [PageBack, PageNavigation],
|
|
148
224
|
title: PageTitle,
|
|
149
|
-
count: PageMeta,
|
|
150
225
|
description: PageDescription,
|
|
151
226
|
actions: PageActions,
|
|
152
227
|
}}
|
|
228
|
+
leadingPlacement={variant === "bar" ? "inline" : "above"}
|
|
229
|
+
contentClassName={
|
|
230
|
+
variant === "bar"
|
|
231
|
+
? cn("mx-auto w-full max-w-7xl px-8 py-2", page?.containerClassName)
|
|
232
|
+
: undefined
|
|
233
|
+
}
|
|
234
|
+
className={cn(
|
|
235
|
+
variant === "bar" &&
|
|
236
|
+
"shrink-0 border-b border-border bg-background text-foreground",
|
|
237
|
+
className,
|
|
238
|
+
)}
|
|
153
239
|
{...props}
|
|
154
240
|
>
|
|
155
241
|
{children}
|
|
@@ -162,11 +248,17 @@ export function PageTitle({
|
|
|
162
248
|
className,
|
|
163
249
|
...props
|
|
164
250
|
}: HTMLAttributes<HTMLHeadingElement>): ReactElement {
|
|
165
|
-
|
|
251
|
+
const variant = useContext(PageHeaderContext);
|
|
252
|
+
requireParent(variant !== null, "PageTitle", "PageHeader");
|
|
166
253
|
return (
|
|
167
254
|
<h1
|
|
168
255
|
data-slot="page-title"
|
|
169
|
-
className={cn(
|
|
256
|
+
className={cn(
|
|
257
|
+
variant === "bar"
|
|
258
|
+
? "truncate text-sm font-semibold"
|
|
259
|
+
: surfaceHeaderClasses.page.title,
|
|
260
|
+
className,
|
|
261
|
+
)}
|
|
170
262
|
{...props}
|
|
171
263
|
/>
|
|
172
264
|
);
|
|
@@ -176,50 +268,127 @@ export function PageDescription({
|
|
|
176
268
|
className,
|
|
177
269
|
...props
|
|
178
270
|
}: HTMLAttributes<HTMLParagraphElement>): ReactElement {
|
|
179
|
-
|
|
271
|
+
const variant = useContext(PageHeaderContext);
|
|
272
|
+
requireParent(variant !== null, "PageDescription", "PageHeader");
|
|
180
273
|
return (
|
|
181
274
|
<p
|
|
182
275
|
data-slot="page-description"
|
|
183
|
-
className={cn(
|
|
276
|
+
className={cn(
|
|
277
|
+
variant === "bar"
|
|
278
|
+
? "mt-0.5 truncate text-xs text-muted-foreground"
|
|
279
|
+
: surfaceHeaderClasses.page.description,
|
|
280
|
+
className,
|
|
281
|
+
)}
|
|
184
282
|
{...props}
|
|
185
283
|
/>
|
|
186
284
|
);
|
|
187
285
|
}
|
|
188
286
|
|
|
189
|
-
export function
|
|
287
|
+
export function PageActions({
|
|
190
288
|
className,
|
|
191
289
|
...props
|
|
192
|
-
}: HTMLAttributes<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
290
|
+
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
291
|
+
const variant = useContext(PageHeaderContext);
|
|
292
|
+
requireParent(variant !== null, "PageActions", "PageHeader");
|
|
293
|
+
const target = useContext(PageActionsTargetContext);
|
|
294
|
+
const actions = (
|
|
295
|
+
<div
|
|
296
|
+
data-slot="page-actions"
|
|
297
|
+
className={cn(
|
|
298
|
+
variant === "bar"
|
|
299
|
+
? "flex shrink-0 items-center gap-2"
|
|
300
|
+
: surfaceHeaderClasses.page.actions,
|
|
301
|
+
className,
|
|
302
|
+
)}
|
|
198
303
|
{...props}
|
|
199
304
|
/>
|
|
200
305
|
);
|
|
306
|
+
return target === null ? actions : createPortal(actions, target);
|
|
201
307
|
}
|
|
202
308
|
|
|
203
|
-
export
|
|
309
|
+
export interface PageBackProps extends Omit<ComponentProps<"a">, "children" | "href"> {
|
|
310
|
+
/** Nome do destino pai. Na barra, também alimenta tooltip e nome acessível. */
|
|
311
|
+
children: ReactNode;
|
|
312
|
+
/** Destino explícito. Obrigatório: sem `href` o elemento não é tabulável, não expõe
|
|
313
|
+
* `role="link"` e o navegador descarta o nome acessível — na barra, onde não há texto
|
|
314
|
+
* visível, o retorno simplesmente desapareceria para tecnologia assistiva. */
|
|
315
|
+
href: string;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Região introdutória para uma trilha estrutural com mais de um ancestral relevante. */
|
|
319
|
+
export function PageNavigation({
|
|
204
320
|
className,
|
|
205
321
|
...props
|
|
206
322
|
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
207
|
-
requireParent(
|
|
208
|
-
|
|
209
|
-
|
|
323
|
+
requireParent(
|
|
324
|
+
useContext(PageHeaderContext) !== null,
|
|
325
|
+
"PageNavigation",
|
|
326
|
+
"PageHeader",
|
|
327
|
+
);
|
|
328
|
+
return (
|
|
210
329
|
<div
|
|
211
|
-
data-slot="page-
|
|
212
|
-
className={cn(
|
|
330
|
+
data-slot="page-navigation-content"
|
|
331
|
+
className={cn("min-w-0", className)}
|
|
213
332
|
{...props}
|
|
214
333
|
/>
|
|
215
334
|
);
|
|
216
|
-
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function PageBack({
|
|
338
|
+
children,
|
|
339
|
+
className,
|
|
340
|
+
"aria-label": ariaLabel,
|
|
341
|
+
...props
|
|
342
|
+
}: PageBackProps): ReactElement {
|
|
343
|
+
const variant = useContext(PageHeaderContext);
|
|
344
|
+
requireParent(variant !== null, "PageBack", "PageHeader");
|
|
345
|
+
const destination =
|
|
346
|
+
typeof children === "string" ? children : "a página anterior";
|
|
347
|
+
const accessibleLabel = ariaLabel ?? `Voltar para ${destination}`;
|
|
348
|
+
const link = (
|
|
349
|
+
<Button asChild variant="ghost" size={variant === "bar" ? "icon-sm" : "sm"}>
|
|
350
|
+
<a
|
|
351
|
+
data-slot="page-back"
|
|
352
|
+
aria-label={accessibleLabel}
|
|
353
|
+
className={className}
|
|
354
|
+
{...props}
|
|
355
|
+
>
|
|
356
|
+
<ArrowLeft aria-hidden="true" />
|
|
357
|
+
{variant === "default" && <span>{children}</span>}
|
|
358
|
+
</a>
|
|
359
|
+
</Button>
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
return variant === "bar" ? (
|
|
363
|
+
<TooltipProvider delayDuration={200}>
|
|
364
|
+
<Tooltip>
|
|
365
|
+
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
|
366
|
+
<TooltipContent>{accessibleLabel}</TooltipContent>
|
|
367
|
+
</Tooltip>
|
|
368
|
+
</TooltipProvider>
|
|
369
|
+
) : (
|
|
370
|
+
link
|
|
371
|
+
);
|
|
217
372
|
}
|
|
218
373
|
|
|
219
374
|
export function PageBody({
|
|
220
375
|
className,
|
|
221
376
|
...props
|
|
222
377
|
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
223
|
-
|
|
224
|
-
|
|
378
|
+
const page = useContext(PageContext);
|
|
379
|
+
const integralState = useContext(PageIntegralStateContext);
|
|
380
|
+
requireParent(page !== null, "PageBody", "Page");
|
|
381
|
+
return (
|
|
382
|
+
<div
|
|
383
|
+
data-slot="page-body"
|
|
384
|
+
className={cn(
|
|
385
|
+
page?.headerVariant === "bar" &&
|
|
386
|
+
"mx-auto w-full max-w-7xl flex-1 px-8 py-8",
|
|
387
|
+
page?.headerVariant === "bar" && page.containerClassName,
|
|
388
|
+
integralState && "flex min-h-0 flex-1 flex-col",
|
|
389
|
+
className,
|
|
390
|
+
)}
|
|
391
|
+
{...props}
|
|
392
|
+
/>
|
|
393
|
+
);
|
|
225
394
|
}
|