@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +44 -6
  4. package/bin/lib/cli-shared.mjs +30 -1
  5. package/bin/lib/copy.mjs +3 -0
  6. package/bin/lib/db.mjs +2 -0
  7. package/docs/adr/0004-page-content-state-is-composed.md +46 -5
  8. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
  9. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
  10. package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
  11. package/docs/data-layer.md +9 -0
  12. package/package.json +1 -1
  13. package/registry/skills/build-opus-ui/SKILL.md +3 -2
  14. package/registry/skills/build-opus-ui/references/ui-patterns.md +18 -6
  15. package/src/ui/components/patterns/list.tsx +67 -26
  16. package/src/ui/components/patterns/page-state.tsx +48 -6
  17. package/src/ui/components/patterns/page.tsx +224 -55
  18. package/src/ui/components/patterns/state-surface.tsx +137 -23
  19. package/src/ui/components/patterns/surface-header.tsx +110 -17
  20. package/src/ui/components/patterns/trigger.tsx +7 -6
  21. package/src/ui/components/primitives/button-group.tsx +34 -8
  22. package/src/ui/components/primitives/control.ts +12 -3
  23. package/src/ui/docs/content/action-list-dialog.md +1 -1
  24. package/src/ui/docs/content/action-list.md +34 -3
  25. package/src/ui/docs/content/action-trigger.md +4 -3
  26. package/src/ui/docs/content/alert.md +16 -4
  27. package/src/ui/docs/content/button.md +20 -5
  28. package/src/ui/docs/content/content.md +3 -3
  29. package/src/ui/docs/content/data-state.md +4 -4
  30. package/src/ui/docs/content/page.md +160 -44
  31. package/src/ui/meta.ts +6 -6
  32. 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
- /** Ações de apresentação do consumidor, renderizadas depois do refresh. */
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="outline"
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
- <Tooltip>
1066
- <TooltipTrigger asChild>
1067
- <Button
1068
- variant="outline"
1069
- size="icon"
1070
- className="shrink-0"
1071
- aria-label="Recarregar"
1072
- onClick={() => void onRefresh()}
1073
- disabled={refreshing}
1074
- >
1075
- <RefreshCw
1076
- className={cn(
1077
- "h-3.5 w-3.5",
1078
- refreshing && "animate-spin",
1079
- )}
1080
- />
1081
- </Button>
1082
- </TooltipTrigger>
1083
- <TooltipContent>Recarregar</TooltipContent>
1084
- </Tooltip>
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
- actions={
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="outline"
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 type { ReactNode } from "react";
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 data-slot="page-state" data-status="loading" className={className}>
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={cn("flex min-h-64 items-center justify-center", className)}
114
+ className={stateClassName}
77
115
  >
78
116
  <ErrorSurface
79
- className="max-w-xl"
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={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(false);
22
- const PageHeaderContext = createContext(false);
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
- <main data-slot="page" className="min-w-0 flex-1" {...props}>
114
- <div className={cn("mx-auto max-w-7xl space-y-6 px-8 py-8", className)}>
115
- {shorthand ? (
116
- <>
117
- <PageHeader>
118
- <PageTitle>{title}</PageTitle>
119
- {count !== undefined && <PageMeta>{count}</PageMeta>}
120
- {description !== undefined && (
121
- <PageDescription>{description}</PageDescription>
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
- {actions !== undefined && <PageActions>{actions}</PageActions>}
124
- </PageHeader>
125
- <PageBody>{children}</PageBody>
126
- </>
127
- ) : (
128
- children
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
- }: HTMLAttributes<HTMLDivElement>): ReactElement {
141
- requireParent(useContext(PageContext), "PageHeader", "Page");
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
- requireParent(useContext(PageHeaderContext), "PageTitle", "PageHeader");
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(surfaceHeaderClasses.page.title, className)}
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
- requireParent(useContext(PageHeaderContext), "PageDescription", "PageHeader");
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(surfaceHeaderClasses.page.description, className)}
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 PageMeta({
287
+ export function PageActions({
190
288
  className,
191
289
  ...props
192
- }: HTMLAttributes<HTMLSpanElement>): ReactElement {
193
- requireParent(useContext(PageHeaderContext), "PageMeta", "PageHeader");
194
- return (
195
- <span
196
- data-slot="page-meta"
197
- className={cn(surfaceHeaderClasses.page.count, className)}
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 function PageActions({
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(useContext(PageHeaderContext), "PageActions", "PageHeader");
208
- const target = useContext(PageActionsTargetContext);
209
- const actions = (
323
+ requireParent(
324
+ useContext(PageHeaderContext) !== null,
325
+ "PageNavigation",
326
+ "PageHeader",
327
+ );
328
+ return (
210
329
  <div
211
- data-slot="page-actions"
212
- className={cn(surfaceHeaderClasses.page.actions, className)}
330
+ data-slot="page-navigation-content"
331
+ className={cn("min-w-0", className)}
213
332
  {...props}
214
333
  />
215
334
  );
216
- return target === null ? actions : createPortal(actions, target);
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
- requireParent(useContext(PageContext), "PageBody", "Page");
224
- return <div data-slot="page-body" className={className} {...props} />;
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
  }