@softize/opus 13.1.0 → 14.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 +34 -0
- package/bin/lib/copy.mjs +276 -6
- package/docs/code-style.md +4 -1
- package/package.json +1 -1
- package/registry/instructions/opus.md +3 -3
- 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 +14 -8
- package/src/ui/components/patterns/page-state.tsx +39 -51
- package/src/ui/components/patterns/page.tsx +18 -53
- package/src/ui/components/patterns/state-surface.tsx +148 -0
- package/src/ui/components/patterns/surface-header.tsx +119 -0
- package/src/ui/components/patterns/trigger.tsx +7 -9
- 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.tsx +31 -35
- package/src/ui/components/primitives/control.ts +60 -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 +3 -1
- package/src/ui/docs/content/action-trigger.md +4 -4
- package/src/ui/docs/content/action-view.md +3 -2
- package/src/ui/docs/content/avatar.md +7 -3
- package/src/ui/docs/content/button.md +30 -14
- 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 +12 -4
- 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 +5 -5
- package/src/ui/react.tsx +8 -16
- package/src/ui/components/patterns/shell-nav.tsx +0 -154
|
@@ -3,22 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Page continua responsável somente pelo esqueleto. Na forma curta, PageState pode ser escrito
|
|
5
5
|
* como filho direto porque Page cria PageBody; na composição explícita, ele fica dentro de
|
|
6
|
-
* PageBody. Estados de uma seção ou coleção continuam em DataState, ActionView e ActionList
|
|
6
|
+
* PageBody. Estados de uma seção ou coleção continuam em DataState, ActionView e ActionList —
|
|
7
|
+
* as superfícies são as mesmas (state-surface.tsx), aqui na escala da página.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { ReactNode } from "react";
|
|
10
|
-
import { TriangleAlert } from "lucide-react";
|
|
11
11
|
import { cn } from "../../lib/cn.ts";
|
|
12
|
-
import { Alert } from "../primitives/alert.tsx";
|
|
13
12
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
EmptyTitle,
|
|
20
|
-
} from "../primitives/empty.tsx";
|
|
21
|
-
import { Spinner } from "../primitives/spinner.tsx";
|
|
13
|
+
DEFAULT_RETRY_LABEL,
|
|
14
|
+
EmptySurface,
|
|
15
|
+
ErrorSurface,
|
|
16
|
+
LoadingSurface,
|
|
17
|
+
} from "./state-surface.tsx";
|
|
22
18
|
|
|
23
19
|
export type PageStateStatus = "loading" | "error" | "empty" | "ready";
|
|
24
20
|
|
|
@@ -30,8 +26,16 @@ export interface PageStateProps {
|
|
|
30
26
|
description?: ReactNode;
|
|
31
27
|
/** Ícone decorativo. Error usa TriangleAlert por default; empty não inventa um ícone. */
|
|
32
28
|
icon?: ReactNode;
|
|
33
|
-
/** Ação aplicável ao estado, como
|
|
29
|
+
/** Ação aplicável ao estado, como escolher ou criar. */
|
|
34
30
|
action?: ReactNode;
|
|
31
|
+
/** Título do vazio quando `title` não for informado. Default: "Nada por aqui". */
|
|
32
|
+
emptyMessage?: string;
|
|
33
|
+
/** Título do erro quando `title` não for informado. Default: "Não foi possível carregar esta página". */
|
|
34
|
+
errorMessage?: string;
|
|
35
|
+
/** Recuperação do erro: com `onRetry`, o botão de tentar de novo entra ao lado de `action`. */
|
|
36
|
+
onRetry?: () => Promise<void> | void;
|
|
37
|
+
/** Rótulo do botão de recuperação. Default: "Tentar de novo". */
|
|
38
|
+
retryLabel?: string;
|
|
35
39
|
/** Conteúdo normal da página, renderizado somente em ready. */
|
|
36
40
|
children?: ReactNode;
|
|
37
41
|
className?: string;
|
|
@@ -43,6 +47,10 @@ export function PageState({
|
|
|
43
47
|
description,
|
|
44
48
|
icon,
|
|
45
49
|
action,
|
|
50
|
+
emptyMessage = "Nada por aqui",
|
|
51
|
+
errorMessage = "Não foi possível carregar esta página",
|
|
52
|
+
onRetry,
|
|
53
|
+
retryLabel = DEFAULT_RETRY_LABEL,
|
|
46
54
|
children,
|
|
47
55
|
className,
|
|
48
56
|
}: PageStateProps): React.ReactElement {
|
|
@@ -50,30 +58,12 @@ export function PageState({
|
|
|
50
58
|
|
|
51
59
|
if (status === "loading") {
|
|
52
60
|
return (
|
|
53
|
-
<div
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"flex min-h-64 flex-col items-center justify-center gap-3 text-center",
|
|
60
|
-
className,
|
|
61
|
-
)}
|
|
62
|
-
>
|
|
63
|
-
<Spinner aria-hidden="true" aria-label={undefined} role="presentation" />
|
|
64
|
-
<div className="max-w-md space-y-1">
|
|
65
|
-
<p data-slot="page-state-title" className="text-sm font-medium">
|
|
66
|
-
{title ?? "Carregando…"}
|
|
67
|
-
</p>
|
|
68
|
-
{description !== undefined && (
|
|
69
|
-
<p
|
|
70
|
-
data-slot="page-state-description"
|
|
71
|
-
className="text-sm text-muted-foreground"
|
|
72
|
-
>
|
|
73
|
-
{description}
|
|
74
|
-
</p>
|
|
75
|
-
)}
|
|
76
|
-
</div>
|
|
61
|
+
<div data-slot="page-state" data-status="loading" className={className}>
|
|
62
|
+
<LoadingSurface
|
|
63
|
+
className="min-h-64"
|
|
64
|
+
title={title ?? "Carregando…"}
|
|
65
|
+
description={description}
|
|
66
|
+
/>
|
|
77
67
|
</div>
|
|
78
68
|
);
|
|
79
69
|
}
|
|
@@ -85,31 +75,29 @@ export function PageState({
|
|
|
85
75
|
data-status="error"
|
|
86
76
|
className={cn("flex min-h-64 items-center justify-center", className)}
|
|
87
77
|
>
|
|
88
|
-
<
|
|
78
|
+
<ErrorSurface
|
|
89
79
|
className="max-w-xl"
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
title={title ?? "Não foi possível carregar esta página"}
|
|
80
|
+
icon={icon}
|
|
81
|
+
title={title ?? errorMessage}
|
|
93
82
|
description={description}
|
|
83
|
+
{...(onRetry !== undefined ? { onRetry } : {})}
|
|
84
|
+
retryLabel={retryLabel}
|
|
94
85
|
>
|
|
95
86
|
{action}
|
|
96
|
-
</
|
|
87
|
+
</ErrorSurface>
|
|
97
88
|
</div>
|
|
98
89
|
);
|
|
99
90
|
}
|
|
100
91
|
|
|
101
92
|
return (
|
|
102
93
|
<div data-slot="page-state" data-status="empty" className={className}>
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
</EmptyHeader>
|
|
111
|
-
{action !== undefined && <EmptyActions>{action}</EmptyActions>}
|
|
112
|
-
</Empty>
|
|
94
|
+
<EmptySurface
|
|
95
|
+
className="min-h-64"
|
|
96
|
+
icon={icon}
|
|
97
|
+
title={title ?? emptyMessage}
|
|
98
|
+
description={description}
|
|
99
|
+
action={action}
|
|
100
|
+
/>
|
|
113
101
|
</div>
|
|
114
102
|
);
|
|
115
103
|
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
} from "react";
|
|
17
17
|
import { createPortal } from "react-dom";
|
|
18
18
|
import { cn } from "../../lib/cn.ts";
|
|
19
|
+
import { SurfaceHeader, surfaceHeaderClasses } from "./surface-header.tsx";
|
|
19
20
|
|
|
20
21
|
const PageContext = createContext(false);
|
|
21
22
|
const PageHeaderContext = createContext(false);
|
|
@@ -110,7 +111,7 @@ export function Page({
|
|
|
110
111
|
return (
|
|
111
112
|
<PageContext.Provider value>
|
|
112
113
|
<main data-slot="page" className="min-w-0 flex-1" {...props}>
|
|
113
|
-
<div className={cn("mx-auto max-w-7xl px-8 py-8", className)}>
|
|
114
|
+
<div className={cn("mx-auto max-w-7xl space-y-6 px-8 py-8", className)}>
|
|
114
115
|
{shorthand ? (
|
|
115
116
|
<>
|
|
116
117
|
<PageHeader>
|
|
@@ -132,60 +133,27 @@ export function Page({
|
|
|
132
133
|
);
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
|
|
135
137
|
export function PageHeader({
|
|
136
|
-
className,
|
|
137
138
|
children,
|
|
138
139
|
...props
|
|
139
140
|
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
140
141
|
requireParent(useContext(PageContext), "PageHeader", "Page");
|
|
141
|
-
const nodes = Children.toArray(children);
|
|
142
|
-
const titles = nodes.filter(
|
|
143
|
-
(node) => isValidElement(node) && node.type === PageTitle,
|
|
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
|
-
|
|
168
142
|
return (
|
|
169
143
|
<PageHeaderContext.Provider value>
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
144
|
+
<SurfaceHeader
|
|
145
|
+
name="PageHeader"
|
|
146
|
+
slot="page"
|
|
147
|
+
slots={{
|
|
148
|
+
title: PageTitle,
|
|
149
|
+
count: PageMeta,
|
|
150
|
+
description: PageDescription,
|
|
151
|
+
actions: PageActions,
|
|
152
|
+
}}
|
|
178
153
|
{...props}
|
|
179
154
|
>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
{titles}
|
|
183
|
-
{metas}
|
|
184
|
-
</div>
|
|
185
|
-
{descriptions}
|
|
186
|
-
</div>
|
|
187
|
-
{actionSlots}
|
|
188
|
-
</div>
|
|
155
|
+
{children}
|
|
156
|
+
</SurfaceHeader>
|
|
189
157
|
</PageHeaderContext.Provider>
|
|
190
158
|
);
|
|
191
159
|
}
|
|
@@ -198,7 +166,7 @@ export function PageTitle({
|
|
|
198
166
|
return (
|
|
199
167
|
<h1
|
|
200
168
|
data-slot="page-title"
|
|
201
|
-
className={cn(
|
|
169
|
+
className={cn(surfaceHeaderClasses.page.title, className)}
|
|
202
170
|
{...props}
|
|
203
171
|
/>
|
|
204
172
|
);
|
|
@@ -212,7 +180,7 @@ export function PageDescription({
|
|
|
212
180
|
return (
|
|
213
181
|
<p
|
|
214
182
|
data-slot="page-description"
|
|
215
|
-
className={cn(
|
|
183
|
+
className={cn(surfaceHeaderClasses.page.description, className)}
|
|
216
184
|
{...props}
|
|
217
185
|
/>
|
|
218
186
|
);
|
|
@@ -226,7 +194,7 @@ export function PageMeta({
|
|
|
226
194
|
return (
|
|
227
195
|
<span
|
|
228
196
|
data-slot="page-meta"
|
|
229
|
-
className={cn(
|
|
197
|
+
className={cn(surfaceHeaderClasses.page.count, className)}
|
|
230
198
|
{...props}
|
|
231
199
|
/>
|
|
232
200
|
);
|
|
@@ -241,10 +209,7 @@ export function PageActions({
|
|
|
241
209
|
const actions = (
|
|
242
210
|
<div
|
|
243
211
|
data-slot="page-actions"
|
|
244
|
-
className={cn(
|
|
245
|
-
"flex w-full shrink-0 items-center gap-4 sm:w-auto",
|
|
246
|
-
className,
|
|
247
|
-
)}
|
|
212
|
+
className={cn(surfaceHeaderClasses.page.actions, className)}
|
|
248
213
|
{...props}
|
|
249
214
|
/>
|
|
250
215
|
);
|
|
@@ -0,0 +1,148 @@
|
|
|
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 é `Alert context="danger"` com o botão de recuperação;
|
|
6
|
+
* vazio compõe `Empty*`. Os patterns públicos só escolhem o enquadramento (bloco, linha de tabela,
|
|
7
|
+
* 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 { 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
|
+
|
|
25
|
+
export const DEFAULT_EMPTY_MESSAGE = 'Nada por aqui.'
|
|
26
|
+
export const DEFAULT_ERROR_MESSAGE = 'Não foi possível carregar.'
|
|
27
|
+
export const DEFAULT_RETRY_LABEL = 'Tentar de novo'
|
|
28
|
+
|
|
29
|
+
export interface LoadingSurfaceProps {
|
|
30
|
+
/** Texto visível ao lado do Spinner; sem ele, só o contêiner nomeia a espera. */
|
|
31
|
+
title?: ReactNode
|
|
32
|
+
description?: ReactNode
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** O contêiner é o único `role="status"`; o Spinner dentro dele é decorativo. */
|
|
37
|
+
export function LoadingSurface({ title, description, className }: LoadingSurfaceProps): ReactElement {
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
role="status"
|
|
41
|
+
aria-live="polite"
|
|
42
|
+
aria-label={title === undefined ? 'Carregando' : undefined}
|
|
43
|
+
className={cn('flex flex-col items-center justify-center gap-3 text-center text-muted-foreground', className)}
|
|
44
|
+
>
|
|
45
|
+
<Spinner />
|
|
46
|
+
{(title !== undefined || description !== undefined) && (
|
|
47
|
+
<div className="max-w-md space-y-1">
|
|
48
|
+
{title !== undefined && (
|
|
49
|
+
<p data-slot="state-title" className="text-sm font-medium text-foreground">
|
|
50
|
+
{title}
|
|
51
|
+
</p>
|
|
52
|
+
)}
|
|
53
|
+
{description !== undefined && (
|
|
54
|
+
<p data-slot="state-description" className="text-sm text-muted-foreground">
|
|
55
|
+
{description}
|
|
56
|
+
</p>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ErrorSurfaceProps {
|
|
65
|
+
/** A frase segura para a pessoa; o texto técnico fica no console e no `cause`. */
|
|
66
|
+
title: ReactNode
|
|
67
|
+
description?: ReactNode
|
|
68
|
+
icon?: ReactNode
|
|
69
|
+
/** Recuperação: com `onRetry`, o botão entra nas ações do Alert. */
|
|
70
|
+
onRetry?: () => Promise<void> | void
|
|
71
|
+
retryLabel?: string
|
|
72
|
+
/** Outras ações do estado, ao lado do botão de recuperação. */
|
|
73
|
+
children?: ReactNode
|
|
74
|
+
className?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ErrorSurface({
|
|
78
|
+
title,
|
|
79
|
+
description,
|
|
80
|
+
icon,
|
|
81
|
+
onRetry,
|
|
82
|
+
retryLabel = DEFAULT_RETRY_LABEL,
|
|
83
|
+
children,
|
|
84
|
+
className,
|
|
85
|
+
}: ErrorSurfaceProps): ReactElement {
|
|
86
|
+
return (
|
|
87
|
+
<Alert
|
|
88
|
+
context="danger"
|
|
89
|
+
icon={icon ?? <TriangleAlert />}
|
|
90
|
+
title={title}
|
|
91
|
+
description={description}
|
|
92
|
+
className={className}
|
|
93
|
+
>
|
|
94
|
+
{onRetry !== undefined && (
|
|
95
|
+
<Button context="neutral" variant="outline" size="sm" onClick={() => void onRetry()}>
|
|
96
|
+
{retryLabel}
|
|
97
|
+
</Button>
|
|
98
|
+
)}
|
|
99
|
+
{children}
|
|
100
|
+
</Alert>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface EmptySurfaceProps {
|
|
105
|
+
/** Situação reconhecível: a frase curta do vazio. */
|
|
106
|
+
title: ReactNode
|
|
107
|
+
description?: ReactNode
|
|
108
|
+
icon?: ReactNode
|
|
109
|
+
action?: ReactNode
|
|
110
|
+
/**
|
|
111
|
+
* `region`: a moldura tracejada de Empty (área disponível para criar ou vincular).
|
|
112
|
+
* `structural`: a moldura sólida de uma coleção sem registros; `bare`: sem moldura, quando a
|
|
113
|
+
* estrutura ao redor (tabela) já a fornece.
|
|
114
|
+
*/
|
|
115
|
+
frame?: 'region' | 'structural' | 'bare'
|
|
116
|
+
/** Título como frase discreta (seção) em vez do título grande (página). */
|
|
117
|
+
compact?: boolean
|
|
118
|
+
className?: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function EmptySurface({
|
|
122
|
+
title,
|
|
123
|
+
description,
|
|
124
|
+
icon,
|
|
125
|
+
action,
|
|
126
|
+
frame = 'region',
|
|
127
|
+
compact = false,
|
|
128
|
+
className,
|
|
129
|
+
}: EmptySurfaceProps): ReactElement {
|
|
130
|
+
return (
|
|
131
|
+
<Empty
|
|
132
|
+
data-frame={frame}
|
|
133
|
+
className={cn(
|
|
134
|
+
frame === 'structural' && 'border-solid',
|
|
135
|
+
frame === 'bare' && 'border-0 p-0 md:p-0',
|
|
136
|
+
compact && 'gap-4 p-6 md:p-6',
|
|
137
|
+
className,
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
<EmptyHeader>
|
|
141
|
+
{icon !== undefined && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
|
142
|
+
<EmptyTitle className={cn(compact && 'text-sm font-normal text-muted-foreground')}>{title}</EmptyTitle>
|
|
143
|
+
{description !== undefined && <EmptyDescription>{description}</EmptyDescription>}
|
|
144
|
+
</EmptyHeader>
|
|
145
|
+
{action !== undefined && <EmptyActions>{action}</EmptyActions>}
|
|
146
|
+
</Empty>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cabeçalho de superfície (interno): a anatomia que Page e Content compartilham.
|
|
3
|
+
*
|
|
4
|
+
* Um título, um contador opcional na mesma linha de base, uma descrição e uma região de ações
|
|
5
|
+
* que vai para o extremo oposto em larguras amplas e empilha abaixo do contexto nas estreitas.
|
|
6
|
+
* `PageHeader` e `ContentHeader` só nomeiam os slots (`page-*`, `content-*`) e a hierarquia
|
|
7
|
+
* visual (`page` ou `section`); layout, validação e classes vivem aqui, uma vez.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
Children,
|
|
12
|
+
Fragment,
|
|
13
|
+
isValidElement,
|
|
14
|
+
type ElementType,
|
|
15
|
+
type HTMLAttributes,
|
|
16
|
+
type ReactElement,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
} from "react";
|
|
19
|
+
import { cn } from "../../lib/cn.ts";
|
|
20
|
+
|
|
21
|
+
export type SurfaceHeaderVariant = "page" | "section";
|
|
22
|
+
|
|
23
|
+
/** As classes por slot, por hierarquia visual — PageTitle e ContentTitle(page) são o mesmo. */
|
|
24
|
+
export const surfaceHeaderClasses: Record<
|
|
25
|
+
SurfaceHeaderVariant,
|
|
26
|
+
{ title: string; description: string; count: string; actions: string }
|
|
27
|
+
> = {
|
|
28
|
+
page: {
|
|
29
|
+
title: "text-2xl font-semibold tracking-tight",
|
|
30
|
+
description: "mt-1 truncate text-sm text-muted-foreground",
|
|
31
|
+
count: "font-mono text-base text-muted-foreground/60",
|
|
32
|
+
actions: "flex w-full shrink-0 items-center gap-4 sm:w-auto",
|
|
33
|
+
},
|
|
34
|
+
section: {
|
|
35
|
+
title: "text-sm font-semibold",
|
|
36
|
+
description: "mt-0.5 truncate text-xs text-muted-foreground",
|
|
37
|
+
count: "font-mono text-xs text-muted-foreground/60",
|
|
38
|
+
actions: "flex w-full shrink-0 items-center gap-2 sm:w-auto",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
interface SurfaceHeaderSlots {
|
|
43
|
+
title: ElementType;
|
|
44
|
+
count: ElementType;
|
|
45
|
+
description: ElementType;
|
|
46
|
+
actions: ElementType;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
50
|
+
/** Nome do componente público, para as mensagens de erro (`PageHeader`, `ContentHeader`). */
|
|
51
|
+
name: string;
|
|
52
|
+
/** Prefixo dos `data-slot` (`page` → `page-header`, `page-heading`). */
|
|
53
|
+
slot: string;
|
|
54
|
+
/** Os componentes de slot que este cabeçalho reconhece. */
|
|
55
|
+
slots: SurfaceHeaderSlots;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
|
|
59
|
+
function flatten(children: ReactNode): ReactNode[] {
|
|
60
|
+
return Children.toArray(children).flatMap((node) =>
|
|
61
|
+
isValidElement(node) && node.type === Fragment
|
|
62
|
+
? Children.toArray((node.props as { children?: ReactNode }).children)
|
|
63
|
+
: [node],
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function SurfaceHeader({
|
|
68
|
+
name,
|
|
69
|
+
slot,
|
|
70
|
+
slots,
|
|
71
|
+
className,
|
|
72
|
+
children,
|
|
73
|
+
...props
|
|
74
|
+
}: SurfaceHeaderProps): ReactElement {
|
|
75
|
+
const nodes = flatten(children);
|
|
76
|
+
const of = (type: ElementType) =>
|
|
77
|
+
nodes.filter((node) => isValidElement(node) && node.type === type);
|
|
78
|
+
const titles = of(slots.title);
|
|
79
|
+
const counts = of(slots.count);
|
|
80
|
+
const descriptions = of(slots.description);
|
|
81
|
+
const actionSlots = of(slots.actions);
|
|
82
|
+
const recognized =
|
|
83
|
+
titles.length + counts.length + descriptions.length + actionSlots.length;
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
titles.length !== 1 ||
|
|
87
|
+
counts.length > 1 ||
|
|
88
|
+
descriptions.length > 1 ||
|
|
89
|
+
actionSlots.length > 1 ||
|
|
90
|
+
recognized !== nodes.length
|
|
91
|
+
) {
|
|
92
|
+
const label = (type: ElementType) =>
|
|
93
|
+
typeof type === "string" ? type : (type as { name?: string }).name ?? "";
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${name} exige um ${label(slots.title)} e aceita no máximo um ${label(slots.description)}, ${label(slots.count)} e ${label(slots.actions)} como filhos diretos.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
data-slot={`${slot}-header`}
|
|
102
|
+
className={cn(
|
|
103
|
+
actionSlots.length > 0 &&
|
|
104
|
+
"flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
|
|
105
|
+
className,
|
|
106
|
+
)}
|
|
107
|
+
{...props}
|
|
108
|
+
>
|
|
109
|
+
<div data-slot={`${slot}-heading`} className="min-w-0">
|
|
110
|
+
<div className="flex items-baseline gap-2">
|
|
111
|
+
{titles}
|
|
112
|
+
{counts}
|
|
113
|
+
</div>
|
|
114
|
+
{descriptions}
|
|
115
|
+
</div>
|
|
116
|
+
{actionSlots}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* - Loading state automático (disabled + "..." enquanto roda).
|
|
6
6
|
* - Toast em sucesso/erro; cache invalidation via action.invalidates.
|
|
7
7
|
* - Confirmação opcional via <Dialog> (compacto, não fecha no clique fora) antes de disparar.
|
|
8
|
-
* - `icon` faz o botão virar icon-only com tooltip
|
|
9
|
-
* card). Absorveu o antigo DeleteButton, que era este
|
|
8
|
+
* - `icon` faz o botão virar icon-only com tooltip, no quadrado `icon-sm` (1.75rem) da escala:
|
|
9
|
+
* é a ação que mora NO item (linha, card). Absorveu o antigo DeleteButton, que era este
|
|
10
|
+
* componente com uma lixeira.
|
|
10
11
|
* Emite `data-action="<action.name>"` na raiz (selector E2E).
|
|
11
12
|
*/
|
|
12
13
|
|
|
@@ -49,8 +50,8 @@ export interface ActionTriggerProps<TInput, TData> {
|
|
|
49
50
|
context?: ButtonProps['context']
|
|
50
51
|
/** Tratamento visual do Button. */
|
|
51
52
|
variant?: ButtonProps['variant']
|
|
52
|
-
/** Tamanho do botão. */
|
|
53
|
-
size?: '
|
|
53
|
+
/** Tamanho do botão (escala de control.ts). Com `icon`, o botão é sempre `icon-sm`. */
|
|
54
|
+
size?: ButtonProps['size']
|
|
54
55
|
/** Desabilita o botão independente de loading. */
|
|
55
56
|
disabled?: boolean
|
|
56
57
|
/** Texto do confirm dialog. Default vem do `action.confirm` (ConfirmSpec do
|
|
@@ -141,7 +142,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
141
142
|
<Button
|
|
142
143
|
context={triggerContext}
|
|
143
144
|
variant={triggerVariant}
|
|
144
|
-
size={icon !== undefined ? 'icon' : size}
|
|
145
|
+
size={icon !== undefined ? 'icon-sm' : size}
|
|
145
146
|
busy={isLoading}
|
|
146
147
|
disabled={disabled}
|
|
147
148
|
aria-label={icon !== undefined ? buttonLabel : undefined}
|
|
@@ -149,10 +150,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
149
150
|
e.stopPropagation()
|
|
150
151
|
onClick()
|
|
151
152
|
}}
|
|
152
|
-
className={cn(
|
|
153
|
-
icon !== undefined && 'size-7 shrink-0 text-muted-foreground/60',
|
|
154
|
-
className,
|
|
155
|
-
)}
|
|
153
|
+
className={cn(icon !== undefined && 'shrink-0 text-muted-foreground/60', className)}
|
|
156
154
|
data-action={action.name}
|
|
157
155
|
>
|
|
158
156
|
{icon ?? buttonLabel}
|
|
@@ -10,9 +10,8 @@ import type { ReactNode } from 'react'
|
|
|
10
10
|
import type { ViewAction } from '../../../core/index.ts'
|
|
11
11
|
import { useViewAction } from '../../drivers/react.tsx'
|
|
12
12
|
import { isBusinessError, isRetryable } from '../../lib/action-errors.ts'
|
|
13
|
-
import { Alert } from '../primitives/alert.tsx'
|
|
14
13
|
import { Skeleton } from '../primitives/skeleton.tsx'
|
|
15
|
-
import {
|
|
14
|
+
import { EmptySurface, ErrorSurface } from './state-surface.tsx'
|
|
16
15
|
|
|
17
16
|
export interface ActionViewProps<TInput, TData> {
|
|
18
17
|
action: ViewAction<TInput, TData>
|
|
@@ -21,14 +20,16 @@ export interface ActionViewProps<TInput, TData> {
|
|
|
21
20
|
children?: (data: TData, refetch: () => Promise<void>) => ReactNode
|
|
22
21
|
/** Alias de children (a API original). Children tem precedência. */
|
|
23
22
|
render?: (data: TData, refetch: () => Promise<void>) => ReactNode
|
|
24
|
-
/** Renderiza estado vazio (200 mas sem dado). Default: nada. */
|
|
23
|
+
/** Renderiza estado vazio (200 mas sem dado). Default: `emptyMessage` ou nada. */
|
|
25
24
|
empty?: ReactNode
|
|
25
|
+
/** Atalho do vazio: a frase na mesma superfície de DataState (Empty com moldura sólida). */
|
|
26
|
+
emptyMessage?: string
|
|
26
27
|
/** Renderiza loading. Default (ou `true`): 3 skeletons empilhados; `false` não renderiza
|
|
27
28
|
* nada enquanto carrega — o contêiner `data-action` permanece. */
|
|
28
29
|
loading?: ReactNode | boolean
|
|
29
|
-
/** Renderiza erro. Default: "Não foi possível carregar" +
|
|
30
|
-
* falta de permissão ou sessão); a frase do servidor entra como
|
|
31
|
-
* allowlist de lib/action-errors.ts. */
|
|
30
|
+
/** Renderiza erro. Default: a mesma superfície de DataState — "Não foi possível carregar" +
|
|
31
|
+
* "Tentar de novo" (omitido em falta de permissão ou sessão); a frase do servidor entra como
|
|
32
|
+
* descrição só quando está na allowlist de lib/action-errors.ts. */
|
|
32
33
|
error?: (err: { code: string; message: string; category?: string }, retry: () => Promise<void>) => ReactNode
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -38,6 +39,7 @@ export function ActionView<TInput, TData>({
|
|
|
38
39
|
children,
|
|
39
40
|
render,
|
|
40
41
|
empty,
|
|
42
|
+
emptyMessage,
|
|
41
43
|
loading,
|
|
42
44
|
error,
|
|
43
45
|
}: ActionViewProps<TInput, TData>) {
|
|
@@ -66,23 +68,19 @@ export function ActionView<TInput, TData>({
|
|
|
66
68
|
}
|
|
67
69
|
return (
|
|
68
70
|
<div data-action={action.name}>
|
|
69
|
-
<
|
|
70
|
-
context="danger"
|
|
71
|
+
<ErrorSurface
|
|
71
72
|
title="Não foi possível carregar"
|
|
72
73
|
description={isBusinessError(err) ? err.message.trim() : undefined}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
|
76
|
-
Tentar de novo
|
|
77
|
-
</Button>
|
|
78
|
-
)}
|
|
79
|
-
</Alert>
|
|
74
|
+
{...(isRetryable(err) ? { onRetry: () => refetch() } : {})}
|
|
75
|
+
/>
|
|
80
76
|
</div>
|
|
81
77
|
)
|
|
82
78
|
}
|
|
83
79
|
|
|
84
80
|
if (data === undefined) {
|
|
85
|
-
|
|
81
|
+
const emptyNode =
|
|
82
|
+
empty ?? (emptyMessage !== undefined ? <EmptySurface title={emptyMessage} compact frame="structural" /> : null)
|
|
83
|
+
return <div data-action={action.name}>{emptyNode}</div>
|
|
86
84
|
}
|
|
87
85
|
|
|
88
86
|
return <div data-action={action.name}>{renderData(data, refetch)}</div>
|