@softize/opus 13.1.0 → 15.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 +97 -0
- package/bin/cli.mjs +2 -0
- package/bin/lib/check.mjs +33 -5
- package/bin/lib/cli-shared.mjs +30 -1
- package/bin/lib/copy.mjs +279 -6
- package/bin/lib/db.mjs +2 -0
- package/docs/adr/0004-page-content-state-is-composed.md +39 -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/code-style.md +4 -1
- package/docs/data-layer.md +9 -0
- package/package.json +1 -1
- package/registry/instructions/opus.md +3 -3
- package/registry/skills/build-opus-ui/SKILL.md +3 -2
- package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
- package/registry/templates/app/src/App.tsx +11 -6
- package/src/core/types.ts +3 -4
- package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
- package/src/ui/components/patterns/confirm.tsx +2 -31
- package/src/ui/components/patterns/content-header.tsx +42 -141
- package/src/ui/components/patterns/data-state.tsx +42 -68
- package/src/ui/components/patterns/form.tsx +15 -15
- package/src/ui/components/patterns/list.tsx +55 -34
- package/src/ui/components/patterns/page-state.tsx +81 -51
- package/src/ui/components/patterns/page.tsx +228 -97
- package/src/ui/components/patterns/state-surface.tsx +262 -0
- package/src/ui/components/patterns/surface-header.tsx +204 -0
- package/src/ui/components/patterns/trigger.tsx +9 -10
- package/src/ui/components/patterns/view.tsx +14 -16
- package/src/ui/components/primitives/alert.tsx +1 -33
- package/src/ui/components/primitives/avatar.tsx +15 -5
- package/src/ui/components/primitives/badge.tsx +2 -43
- package/src/ui/components/primitives/button-group.tsx +34 -8
- package/src/ui/components/primitives/button.tsx +31 -35
- package/src/ui/components/primitives/control.ts +69 -0
- package/src/ui/components/primitives/dot.tsx +1 -30
- package/src/ui/components/primitives/input-group.tsx +11 -8
- package/src/ui/components/primitives/item.tsx +3 -1
- package/src/ui/components/primitives/menu.tsx +1 -7
- package/src/ui/components/primitives/pagination.tsx +16 -8
- package/src/ui/components/primitives/select.tsx +2 -2
- package/src/ui/components/primitives/spinner.tsx +13 -16
- package/src/ui/components/primitives/switch.tsx +4 -1
- package/src/ui/components/primitives/tabs.tsx +5 -3
- package/src/ui/components/primitives/toggle.tsx +9 -4
- package/src/ui/docs/content/action-form.md +26 -0
- package/src/ui/docs/content/action-list-dialog.md +2 -2
- package/src/ui/docs/content/action-list.md +35 -2
- package/src/ui/docs/content/action-trigger.md +5 -4
- package/src/ui/docs/content/action-view.md +3 -2
- package/src/ui/docs/content/alert.md +16 -4
- package/src/ui/docs/content/avatar.md +7 -3
- package/src/ui/docs/content/button.md +48 -17
- package/src/ui/docs/content/communication.md +36 -0
- package/src/ui/docs/content/content.md +5 -4
- package/src/ui/docs/content/data-state.md +17 -13
- package/src/ui/docs/content/dialog.md +1 -4
- package/src/ui/docs/content/input.md +1 -1
- package/src/ui/docs/content/item.md +1 -1
- package/src/ui/docs/content/page.md +160 -37
- package/src/ui/docs/content/pagination.md +11 -9
- package/src/ui/docs/content/semantic-context.md +3 -2
- package/src/ui/docs/content/sidebar.md +2 -42
- package/src/ui/docs/content/spinner.md +9 -6
- package/src/ui/docs/content/switch.md +1 -1
- package/src/ui/docs/content/tabs.md +1 -1
- package/src/ui/docs/content/toggle.md +1 -1
- package/src/ui/drivers/react.tsx +1 -6
- package/src/ui/meta.ts +8 -8
- package/src/ui/react.tsx +16 -18
- package/src/ui/components/patterns/shell-nav.tsx +0 -154
|
@@ -9,16 +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";
|
|
31
|
+
import { SurfaceHeader, surfaceHeaderClasses } from "./surface-header.tsx";
|
|
32
|
+
import { PageStatePresenceProvider } from "./page-state.tsx";
|
|
33
|
+
|
|
34
|
+
export type PageHeaderVariant = "default" | "bar";
|
|
19
35
|
|
|
20
|
-
|
|
21
|
-
|
|
36
|
+
interface PageContextValue {
|
|
37
|
+
headerVariant: PageHeaderVariant;
|
|
38
|
+
containerClassName?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PageContext = createContext<PageContextValue | null>(null);
|
|
42
|
+
const PageHeaderContext = createContext<PageHeaderVariant | null>(null);
|
|
43
|
+
const PageIntegralStateContext = createContext(false);
|
|
22
44
|
const PageActionsTargetContext = createContext<HTMLElement | null>(null);
|
|
23
45
|
|
|
24
46
|
export function PageActionsTarget({
|
|
@@ -53,8 +75,6 @@ interface PageBaseProps extends Omit<
|
|
|
53
75
|
|
|
54
76
|
interface PageShorthandProps extends PageBaseProps {
|
|
55
77
|
title: ReactNode;
|
|
56
|
-
/** Total de itens ao lado do título. */
|
|
57
|
-
count?: number;
|
|
58
78
|
description?: ReactNode;
|
|
59
79
|
actions?: ReactNode;
|
|
60
80
|
children: ReactNode;
|
|
@@ -62,7 +82,6 @@ interface PageShorthandProps extends PageBaseProps {
|
|
|
62
82
|
|
|
63
83
|
interface PageComposedProps extends PageBaseProps {
|
|
64
84
|
title?: never;
|
|
65
|
-
count?: never;
|
|
66
85
|
description?: never;
|
|
67
86
|
actions?: never;
|
|
68
87
|
children: ReactNode;
|
|
@@ -72,7 +91,6 @@ export type PageProps = PageShorthandProps | PageComposedProps;
|
|
|
72
91
|
|
|
73
92
|
export function Page({
|
|
74
93
|
title,
|
|
75
|
-
count,
|
|
76
94
|
description,
|
|
77
95
|
actions,
|
|
78
96
|
className,
|
|
@@ -81,6 +99,25 @@ export function Page({
|
|
|
81
99
|
}: PageProps): ReactElement {
|
|
82
100
|
const shorthand = title !== undefined;
|
|
83
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
|
+
}, []);
|
|
84
121
|
if (
|
|
85
122
|
shorthand &&
|
|
86
123
|
nodes.some((node) => isValidElement(node) && node.type === PageHeader)
|
|
@@ -90,12 +127,6 @@ export function Page({
|
|
|
90
127
|
);
|
|
91
128
|
}
|
|
92
129
|
if (!shorthand) {
|
|
93
|
-
const headers = nodes.filter(
|
|
94
|
-
(node) => isValidElement(node) && node.type === PageHeader,
|
|
95
|
-
);
|
|
96
|
-
const bodies = nodes.filter(
|
|
97
|
-
(node) => isValidElement(node) && node.type === PageBody,
|
|
98
|
-
);
|
|
99
130
|
if (
|
|
100
131
|
headers.length !== 1 ||
|
|
101
132
|
bodies.length !== 1 ||
|
|
@@ -106,86 +137,106 @@ export function Page({
|
|
|
106
137
|
);
|
|
107
138
|
}
|
|
108
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
|
+
);
|
|
109
163
|
|
|
110
164
|
return (
|
|
111
|
-
<PageContext.Provider value>
|
|
112
|
-
<
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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" && "flex min-h-0 flex-col",
|
|
173
|
+
integralState && "flex min-h-full flex-col",
|
|
174
|
+
)}
|
|
175
|
+
{...props}
|
|
176
|
+
>
|
|
177
|
+
{headerVariant === "bar" ? (
|
|
178
|
+
content
|
|
179
|
+
) : (
|
|
180
|
+
<div
|
|
181
|
+
className={cn(
|
|
182
|
+
"mx-auto max-w-7xl space-y-6 px-8 py-8",
|
|
183
|
+
integralState &&
|
|
184
|
+
"flex min-h-full w-full flex-1 flex-col space-y-0",
|
|
185
|
+
className,
|
|
121
186
|
)}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
</div>
|
|
130
|
-
</main>
|
|
187
|
+
>
|
|
188
|
+
{content}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</main>
|
|
192
|
+
</PageStatePresenceProvider>
|
|
193
|
+
</PageIntegralStateContext.Provider>
|
|
131
194
|
</PageContext.Provider>
|
|
132
195
|
);
|
|
133
196
|
}
|
|
134
197
|
|
|
198
|
+
/** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
|
|
199
|
+
export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
200
|
+
/** `default` fica no container; `bar` cria uma faixa compacta no topo da Page. */
|
|
201
|
+
variant?: PageHeaderVariant;
|
|
202
|
+
}
|
|
203
|
+
|
|
135
204
|
export function PageHeader({
|
|
205
|
+
variant = "default",
|
|
136
206
|
className,
|
|
137
207
|
children,
|
|
138
208
|
...props
|
|
139
|
-
}:
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
);
|
|
145
|
-
const metas = nodes.filter(
|
|
146
|
-
(node) => isValidElement(node) && node.type === PageMeta,
|
|
147
|
-
);
|
|
148
|
-
const descriptions = nodes.filter(
|
|
149
|
-
(node) => isValidElement(node) && node.type === PageDescription,
|
|
150
|
-
);
|
|
151
|
-
const actionSlots = nodes.filter(
|
|
152
|
-
(node) => isValidElement(node) && node.type === PageActions,
|
|
153
|
-
);
|
|
154
|
-
const recognized =
|
|
155
|
-
titles.length + metas.length + descriptions.length + actionSlots.length;
|
|
156
|
-
if (
|
|
157
|
-
titles.length !== 1 ||
|
|
158
|
-
metas.length > 1 ||
|
|
159
|
-
descriptions.length > 1 ||
|
|
160
|
-
actionSlots.length > 1 ||
|
|
161
|
-
recognized !== nodes.length
|
|
162
|
-
) {
|
|
163
|
-
throw new Error(
|
|
164
|
-
"PageHeader exige um PageTitle e aceita no máximo um PageDescription, PageMeta e PageActions como filhos diretos.",
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
209
|
+
}: PageHeaderProps): ReactElement {
|
|
210
|
+
const page = useContext(PageContext);
|
|
211
|
+
const integralState = useContext(PageIntegralStateContext);
|
|
212
|
+
requireParent(page !== null, "PageHeader", "Page");
|
|
213
|
+
if (integralState) return <></>;
|
|
168
214
|
return (
|
|
169
|
-
<PageHeaderContext.Provider value>
|
|
170
|
-
<
|
|
171
|
-
|
|
215
|
+
<PageHeaderContext.Provider value={variant}>
|
|
216
|
+
<SurfaceHeader
|
|
217
|
+
name="PageHeader"
|
|
218
|
+
slot="page"
|
|
219
|
+
slots={{
|
|
220
|
+
leading: [PageBack, PageNavigation],
|
|
221
|
+
title: PageTitle,
|
|
222
|
+
description: PageDescription,
|
|
223
|
+
actions: PageActions,
|
|
224
|
+
}}
|
|
225
|
+
leadingPlacement={variant === "bar" ? "inline" : "above"}
|
|
226
|
+
contentClassName={
|
|
227
|
+
variant === "bar"
|
|
228
|
+
? cn("mx-auto w-full max-w-7xl px-8 py-2", page?.containerClassName)
|
|
229
|
+
: undefined
|
|
230
|
+
}
|
|
172
231
|
className={cn(
|
|
173
|
-
"
|
|
174
|
-
|
|
175
|
-
"flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
|
|
232
|
+
variant === "bar" &&
|
|
233
|
+
"shrink-0 border-b border-border bg-background text-foreground",
|
|
176
234
|
className,
|
|
177
235
|
)}
|
|
178
236
|
{...props}
|
|
179
237
|
>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
{titles}
|
|
183
|
-
{metas}
|
|
184
|
-
</div>
|
|
185
|
-
{descriptions}
|
|
186
|
-
</div>
|
|
187
|
-
{actionSlots}
|
|
188
|
-
</div>
|
|
238
|
+
{children}
|
|
239
|
+
</SurfaceHeader>
|
|
189
240
|
</PageHeaderContext.Provider>
|
|
190
241
|
);
|
|
191
242
|
}
|
|
@@ -194,11 +245,17 @@ export function PageTitle({
|
|
|
194
245
|
className,
|
|
195
246
|
...props
|
|
196
247
|
}: HTMLAttributes<HTMLHeadingElement>): ReactElement {
|
|
197
|
-
|
|
248
|
+
const variant = useContext(PageHeaderContext);
|
|
249
|
+
requireParent(variant !== null, "PageTitle", "PageHeader");
|
|
198
250
|
return (
|
|
199
251
|
<h1
|
|
200
252
|
data-slot="page-title"
|
|
201
|
-
className={cn(
|
|
253
|
+
className={cn(
|
|
254
|
+
variant === "bar"
|
|
255
|
+
? "truncate text-sm font-semibold"
|
|
256
|
+
: surfaceHeaderClasses.page.title,
|
|
257
|
+
className,
|
|
258
|
+
)}
|
|
202
259
|
{...props}
|
|
203
260
|
/>
|
|
204
261
|
);
|
|
@@ -208,25 +265,17 @@ export function PageDescription({
|
|
|
208
265
|
className,
|
|
209
266
|
...props
|
|
210
267
|
}: HTMLAttributes<HTMLParagraphElement>): ReactElement {
|
|
211
|
-
|
|
268
|
+
const variant = useContext(PageHeaderContext);
|
|
269
|
+
requireParent(variant !== null, "PageDescription", "PageHeader");
|
|
212
270
|
return (
|
|
213
271
|
<p
|
|
214
272
|
data-slot="page-description"
|
|
215
|
-
className={cn(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
export function PageMeta({
|
|
222
|
-
className,
|
|
223
|
-
...props
|
|
224
|
-
}: HTMLAttributes<HTMLSpanElement>): ReactElement {
|
|
225
|
-
requireParent(useContext(PageHeaderContext), "PageMeta", "PageHeader");
|
|
226
|
-
return (
|
|
227
|
-
<span
|
|
228
|
-
data-slot="page-meta"
|
|
229
|
-
className={cn("font-mono text-base text-muted-foreground/60", className)}
|
|
273
|
+
className={cn(
|
|
274
|
+
variant === "bar"
|
|
275
|
+
? "mt-0.5 truncate text-xs text-muted-foreground"
|
|
276
|
+
: surfaceHeaderClasses.page.description,
|
|
277
|
+
className,
|
|
278
|
+
)}
|
|
230
279
|
{...props}
|
|
231
280
|
/>
|
|
232
281
|
);
|
|
@@ -236,13 +285,16 @@ export function PageActions({
|
|
|
236
285
|
className,
|
|
237
286
|
...props
|
|
238
287
|
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
239
|
-
|
|
288
|
+
const variant = useContext(PageHeaderContext);
|
|
289
|
+
requireParent(variant !== null, "PageActions", "PageHeader");
|
|
240
290
|
const target = useContext(PageActionsTargetContext);
|
|
241
291
|
const actions = (
|
|
242
292
|
<div
|
|
243
293
|
data-slot="page-actions"
|
|
244
294
|
className={cn(
|
|
245
|
-
|
|
295
|
+
variant === "bar"
|
|
296
|
+
? "flex shrink-0 items-center gap-2"
|
|
297
|
+
: surfaceHeaderClasses.page.actions,
|
|
246
298
|
className,
|
|
247
299
|
)}
|
|
248
300
|
{...props}
|
|
@@ -251,10 +303,89 @@ export function PageActions({
|
|
|
251
303
|
return target === null ? actions : createPortal(actions, target);
|
|
252
304
|
}
|
|
253
305
|
|
|
306
|
+
export interface PageBackProps extends Omit<ComponentProps<"a">, "children" | "href"> {
|
|
307
|
+
/** Nome do destino pai. Na barra, também alimenta tooltip e nome acessível. */
|
|
308
|
+
children: ReactNode;
|
|
309
|
+
/** Destino explícito. Obrigatório: sem `href` o elemento não é tabulável, não expõe
|
|
310
|
+
* `role="link"` e o navegador descarta o nome acessível — na barra, onde não há texto
|
|
311
|
+
* visível, o retorno simplesmente desapareceria para tecnologia assistiva. */
|
|
312
|
+
href: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Região introdutória para uma trilha estrutural com mais de um ancestral relevante. */
|
|
316
|
+
export function PageNavigation({
|
|
317
|
+
className,
|
|
318
|
+
...props
|
|
319
|
+
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
320
|
+
requireParent(
|
|
321
|
+
useContext(PageHeaderContext) !== null,
|
|
322
|
+
"PageNavigation",
|
|
323
|
+
"PageHeader",
|
|
324
|
+
);
|
|
325
|
+
return (
|
|
326
|
+
<div
|
|
327
|
+
data-slot="page-navigation-content"
|
|
328
|
+
className={cn("min-w-0", className)}
|
|
329
|
+
{...props}
|
|
330
|
+
/>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function PageBack({
|
|
335
|
+
children,
|
|
336
|
+
className,
|
|
337
|
+
"aria-label": ariaLabel,
|
|
338
|
+
...props
|
|
339
|
+
}: PageBackProps): ReactElement {
|
|
340
|
+
const variant = useContext(PageHeaderContext);
|
|
341
|
+
requireParent(variant !== null, "PageBack", "PageHeader");
|
|
342
|
+
const destination =
|
|
343
|
+
typeof children === "string" ? children : "a página anterior";
|
|
344
|
+
const accessibleLabel = ariaLabel ?? `Voltar para ${destination}`;
|
|
345
|
+
const link = (
|
|
346
|
+
<Button asChild variant="ghost" size={variant === "bar" ? "icon-sm" : "sm"}>
|
|
347
|
+
<a
|
|
348
|
+
data-slot="page-back"
|
|
349
|
+
aria-label={accessibleLabel}
|
|
350
|
+
className={className}
|
|
351
|
+
{...props}
|
|
352
|
+
>
|
|
353
|
+
<ArrowLeft aria-hidden="true" />
|
|
354
|
+
{variant === "default" && <span>{children}</span>}
|
|
355
|
+
</a>
|
|
356
|
+
</Button>
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
return variant === "bar" ? (
|
|
360
|
+
<TooltipProvider delayDuration={200}>
|
|
361
|
+
<Tooltip>
|
|
362
|
+
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
|
363
|
+
<TooltipContent>{accessibleLabel}</TooltipContent>
|
|
364
|
+
</Tooltip>
|
|
365
|
+
</TooltipProvider>
|
|
366
|
+
) : (
|
|
367
|
+
link
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
254
371
|
export function PageBody({
|
|
255
372
|
className,
|
|
256
373
|
...props
|
|
257
374
|
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
258
|
-
|
|
259
|
-
|
|
375
|
+
const page = useContext(PageContext);
|
|
376
|
+
const integralState = useContext(PageIntegralStateContext);
|
|
377
|
+
requireParent(page !== null, "PageBody", "Page");
|
|
378
|
+
return (
|
|
379
|
+
<div
|
|
380
|
+
data-slot="page-body"
|
|
381
|
+
className={cn(
|
|
382
|
+
page?.headerVariant === "bar" &&
|
|
383
|
+
"mx-auto w-full max-w-7xl flex-1 px-8 py-8",
|
|
384
|
+
page?.headerVariant === "bar" && page.containerClassName,
|
|
385
|
+
integralState && "flex min-h-0 flex-1 flex-col",
|
|
386
|
+
className,
|
|
387
|
+
)}
|
|
388
|
+
{...props}
|
|
389
|
+
/>
|
|
390
|
+
);
|
|
260
391
|
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Superfícies de estado compartilhadas por DataState, PageState e ActionView (interno).
|
|
3
|
+
*
|
|
4
|
+
* Uma composição só para cada estado: carregar é um contêiner `role="status"` com o Spinner
|
|
5
|
+
* (decorativo — o status é um só); erro preserva `role="alert"` e escolhe entre aviso inline e
|
|
6
|
+
* estado central; vazio compõe `Empty*`. Os patterns públicos escolhem o enquadramento (bloco,
|
|
7
|
+
* linha de tabela, página inteira) e os textos. Não exportado no barrel: é anatomia, não API.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
11
|
+
import { RefreshCw, TriangleAlert } from 'lucide-react'
|
|
12
|
+
import { cn } from '../../lib/cn.ts'
|
|
13
|
+
import { Alert } from '../primitives/alert.tsx'
|
|
14
|
+
import { Button } from '../primitives/button.tsx'
|
|
15
|
+
import {
|
|
16
|
+
Empty,
|
|
17
|
+
EmptyActions,
|
|
18
|
+
EmptyDescription,
|
|
19
|
+
EmptyHeader,
|
|
20
|
+
EmptyMedia,
|
|
21
|
+
EmptyTitle,
|
|
22
|
+
} from '../primitives/empty.tsx'
|
|
23
|
+
import { Spinner } from '../primitives/spinner.tsx'
|
|
24
|
+
import {
|
|
25
|
+
Tooltip,
|
|
26
|
+
TooltipContent,
|
|
27
|
+
TooltipProvider,
|
|
28
|
+
TooltipTrigger,
|
|
29
|
+
} from '../primitives/tooltip.tsx'
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_EMPTY_MESSAGE = 'Nada por aqui.'
|
|
32
|
+
export const DEFAULT_ERROR_MESSAGE = 'Não foi possível carregar.'
|
|
33
|
+
export const DEFAULT_RETRY_LABEL = 'Tentar de novo'
|
|
34
|
+
|
|
35
|
+
export interface LoadingSurfaceProps {
|
|
36
|
+
/** Texto visível ao lado do Spinner; sem ele, só o contêiner nomeia a espera. */
|
|
37
|
+
title?: ReactNode
|
|
38
|
+
description?: ReactNode
|
|
39
|
+
/** Nível semântico do título quando a superfície representa uma região nomeada. */
|
|
40
|
+
titleLevel?: 1 | 2 | 3 | 4 | 5 | 6
|
|
41
|
+
className?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** O contêiner é o único `role="status"`; o Spinner dentro dele é decorativo. */
|
|
45
|
+
export function LoadingSurface({
|
|
46
|
+
title,
|
|
47
|
+
description,
|
|
48
|
+
titleLevel,
|
|
49
|
+
className,
|
|
50
|
+
}: LoadingSurfaceProps): ReactElement {
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
role="status"
|
|
54
|
+
aria-live="polite"
|
|
55
|
+
aria-label={title === undefined ? 'Carregando' : undefined}
|
|
56
|
+
className={cn(
|
|
57
|
+
'flex flex-col items-center justify-center gap-3 text-center text-muted-foreground',
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<Spinner />
|
|
62
|
+
{(title !== undefined || description !== undefined) && (
|
|
63
|
+
<div className="max-w-md space-y-1">
|
|
64
|
+
{title !== undefined && (
|
|
65
|
+
<p
|
|
66
|
+
data-slot="state-title"
|
|
67
|
+
role={titleLevel === undefined ? undefined : 'heading'}
|
|
68
|
+
aria-level={titleLevel}
|
|
69
|
+
className="text-sm font-medium text-foreground"
|
|
70
|
+
>
|
|
71
|
+
{title}
|
|
72
|
+
</p>
|
|
73
|
+
)}
|
|
74
|
+
{description !== undefined && (
|
|
75
|
+
<p
|
|
76
|
+
data-slot="state-description"
|
|
77
|
+
className="text-sm text-muted-foreground"
|
|
78
|
+
>
|
|
79
|
+
{description}
|
|
80
|
+
</p>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ErrorSurfaceProps {
|
|
89
|
+
/** A frase segura para a pessoa; o texto técnico fica no console e no `cause`. */
|
|
90
|
+
title: ReactNode
|
|
91
|
+
description?: ReactNode
|
|
92
|
+
icon?: ReactNode
|
|
93
|
+
/** Recuperação: com `onRetry`, uma ação somente com ícone entra nas ações do Alert. */
|
|
94
|
+
onRetry?: () => Promise<void> | void
|
|
95
|
+
retryLabel?: string
|
|
96
|
+
/** Outras ações do estado, ao lado do botão de recuperação. */
|
|
97
|
+
children?: ReactNode
|
|
98
|
+
/** `alert` preserva o aviso inline; `state` usa a composição central dos estados integrais. */
|
|
99
|
+
presentation?: 'alert' | 'state'
|
|
100
|
+
/** Nível semântico do título na apresentação de estado. */
|
|
101
|
+
titleLevel?: 1 | 2 | 3 | 4 | 5 | 6
|
|
102
|
+
className?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function ErrorSurface({
|
|
106
|
+
title,
|
|
107
|
+
description,
|
|
108
|
+
icon,
|
|
109
|
+
onRetry,
|
|
110
|
+
retryLabel = DEFAULT_RETRY_LABEL,
|
|
111
|
+
children,
|
|
112
|
+
presentation = 'alert',
|
|
113
|
+
titleLevel,
|
|
114
|
+
className,
|
|
115
|
+
}: ErrorSurfaceProps): ReactElement {
|
|
116
|
+
if (presentation === 'alert') {
|
|
117
|
+
return (
|
|
118
|
+
<Alert
|
|
119
|
+
context="danger"
|
|
120
|
+
icon={icon ?? <TriangleAlert />}
|
|
121
|
+
title={title}
|
|
122
|
+
description={description}
|
|
123
|
+
className={className}
|
|
124
|
+
>
|
|
125
|
+
{onRetry !== undefined && (
|
|
126
|
+
<TooltipProvider delayDuration={200}>
|
|
127
|
+
<Tooltip>
|
|
128
|
+
<TooltipTrigger asChild>
|
|
129
|
+
<Button
|
|
130
|
+
context="neutral"
|
|
131
|
+
variant="ghost"
|
|
132
|
+
size="icon"
|
|
133
|
+
aria-label={retryLabel}
|
|
134
|
+
onClick={() => void onRetry()}
|
|
135
|
+
>
|
|
136
|
+
<RefreshCw />
|
|
137
|
+
</Button>
|
|
138
|
+
</TooltipTrigger>
|
|
139
|
+
<TooltipContent>{retryLabel}</TooltipContent>
|
|
140
|
+
</Tooltip>
|
|
141
|
+
</TooltipProvider>
|
|
142
|
+
)}
|
|
143
|
+
{children}
|
|
144
|
+
</Alert>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div
|
|
150
|
+
data-slot="error-state"
|
|
151
|
+
role="alert"
|
|
152
|
+
className={cn(
|
|
153
|
+
'flex min-w-0 flex-col items-center justify-center gap-6 text-center text-balance',
|
|
154
|
+
className,
|
|
155
|
+
)}
|
|
156
|
+
>
|
|
157
|
+
<div
|
|
158
|
+
data-slot="state-header"
|
|
159
|
+
className="flex max-w-sm flex-col items-center gap-2 text-center"
|
|
160
|
+
>
|
|
161
|
+
<div
|
|
162
|
+
data-slot="state-icon"
|
|
163
|
+
aria-hidden="true"
|
|
164
|
+
className="mb-2 flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-context-danger-emphasis [&_svg]:size-6 [&_svg]:shrink-0"
|
|
165
|
+
>
|
|
166
|
+
{icon ?? <TriangleAlert />}
|
|
167
|
+
</div>
|
|
168
|
+
<div
|
|
169
|
+
data-slot="state-title"
|
|
170
|
+
role={titleLevel === undefined ? undefined : 'heading'}
|
|
171
|
+
aria-level={titleLevel}
|
|
172
|
+
className="text-lg font-medium tracking-tight"
|
|
173
|
+
>
|
|
174
|
+
{title}
|
|
175
|
+
</div>
|
|
176
|
+
{description !== undefined && (
|
|
177
|
+
<div
|
|
178
|
+
data-slot="state-description"
|
|
179
|
+
className="text-sm/relaxed text-muted-foreground"
|
|
180
|
+
>
|
|
181
|
+
{description}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
{(onRetry !== undefined ||
|
|
186
|
+
(children !== undefined && children !== null)) && (
|
|
187
|
+
<div
|
|
188
|
+
data-slot="state-actions"
|
|
189
|
+
className="flex w-full max-w-sm items-center justify-center gap-2 text-sm"
|
|
190
|
+
>
|
|
191
|
+
{onRetry !== undefined && (
|
|
192
|
+
<Button
|
|
193
|
+
context="neutral"
|
|
194
|
+
variant="outline"
|
|
195
|
+
onClick={() => void onRetry()}
|
|
196
|
+
>
|
|
197
|
+
{retryLabel}
|
|
198
|
+
</Button>
|
|
199
|
+
)}
|
|
200
|
+
{children}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface EmptySurfaceProps {
|
|
208
|
+
/** Situação reconhecível: a frase curta do vazio. */
|
|
209
|
+
title: ReactNode
|
|
210
|
+
description?: ReactNode
|
|
211
|
+
icon?: ReactNode
|
|
212
|
+
action?: ReactNode
|
|
213
|
+
/**
|
|
214
|
+
* `region`: a moldura tracejada de Empty (área disponível para criar ou vincular).
|
|
215
|
+
* `structural`: a moldura sólida de uma coleção sem registros; `bare`: sem moldura, quando a
|
|
216
|
+
* estrutura ao redor (tabela) já a fornece.
|
|
217
|
+
*/
|
|
218
|
+
frame?: 'region' | 'structural' | 'bare'
|
|
219
|
+
/** Título como frase discreta (seção) em vez do título grande (página). */
|
|
220
|
+
compact?: boolean
|
|
221
|
+
/** Nível semântico do título quando a superfície representa uma região nomeada. */
|
|
222
|
+
titleLevel?: 1 | 2 | 3 | 4 | 5 | 6
|
|
223
|
+
className?: string
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function EmptySurface({
|
|
227
|
+
title,
|
|
228
|
+
description,
|
|
229
|
+
icon,
|
|
230
|
+
action,
|
|
231
|
+
frame = 'region',
|
|
232
|
+
compact = false,
|
|
233
|
+
titleLevel,
|
|
234
|
+
className,
|
|
235
|
+
}: EmptySurfaceProps): ReactElement {
|
|
236
|
+
return (
|
|
237
|
+
<Empty
|
|
238
|
+
data-frame={frame}
|
|
239
|
+
className={cn(
|
|
240
|
+
frame === 'structural' && 'border-solid',
|
|
241
|
+
frame === 'bare' && 'border-0 p-0 md:p-0',
|
|
242
|
+
compact && 'gap-4 p-6 md:p-6',
|
|
243
|
+
className,
|
|
244
|
+
)}
|
|
245
|
+
>
|
|
246
|
+
<EmptyHeader>
|
|
247
|
+
{icon !== undefined && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
|
248
|
+
<EmptyTitle
|
|
249
|
+
role={titleLevel === undefined ? undefined : 'heading'}
|
|
250
|
+
aria-level={titleLevel}
|
|
251
|
+
className={cn(compact && 'text-sm font-normal text-muted-foreground')}
|
|
252
|
+
>
|
|
253
|
+
{title}
|
|
254
|
+
</EmptyTitle>
|
|
255
|
+
{description !== undefined && (
|
|
256
|
+
<EmptyDescription>{description}</EmptyDescription>
|
|
257
|
+
)}
|
|
258
|
+
</EmptyHeader>
|
|
259
|
+
{action !== undefined && <EmptyActions>{action}</EmptyActions>}
|
|
260
|
+
</Empty>
|
|
261
|
+
)
|
|
262
|
+
}
|