@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
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Namespace `dialog` de propósito: `window.alert/confirm/prompt` são globais do browser, e
|
|
14
14
|
* um import esquecido cairia no nativo (cinza, sem tema) sem erro. `window.dialog` não
|
|
15
|
-
* existe — a família fica imune ao footgun.
|
|
15
|
+
* existe — a família fica imune ao footgun.
|
|
16
16
|
*
|
|
17
17
|
* Por baixo é o <Dialog mode="alert">: `role="alertdialog"`, não fecha no clique fora, foco preso
|
|
18
18
|
* — a semântica que uma interrupção que EXIGE resposta pede. ESC/Cancelar resolve o "não"
|
|
@@ -59,16 +59,12 @@ export interface ConfirmOptions extends DialogBase {
|
|
|
59
59
|
cancel?: string
|
|
60
60
|
/** Contexto do botão de confirmação. */
|
|
61
61
|
context?: Extract<UiContext, 'primary' | 'danger'>
|
|
62
|
-
/** @deprecated Use `context="danger"`. */
|
|
63
|
-
variant?: 'default' | 'destructive'
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
/** `prompt` — uma string livre. Form de verdade (campos, contrato) → ActionFormDialog. */
|
|
67
65
|
export interface PromptOptions extends DialogBase {
|
|
68
66
|
cancel?: string
|
|
69
67
|
context?: Extract<UiContext, 'primary' | 'danger'>
|
|
70
|
-
/** @deprecated Use `context="danger"`. */
|
|
71
|
-
variant?: 'default' | 'destructive'
|
|
72
68
|
placeholder?: string
|
|
73
69
|
defaultValue?: string
|
|
74
70
|
}
|
|
@@ -107,16 +103,6 @@ function requireHost(fn: string): Error | null {
|
|
|
107
103
|
)
|
|
108
104
|
}
|
|
109
105
|
|
|
110
|
-
function rejectAmbiguousContext(
|
|
111
|
-
fn: string,
|
|
112
|
-
options: ConfirmOptions | PromptOptions,
|
|
113
|
-
): Error | null {
|
|
114
|
-
if (options.context === undefined || options.variant === undefined) return null
|
|
115
|
-
return new Error(
|
|
116
|
-
`${fn} não permite combinar context com uma variante semântica legada.`,
|
|
117
|
-
)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
106
|
function validateChoose<TResult extends string>(options: ChooseOptions<TResult>): Error | null {
|
|
121
107
|
if (options.actions.length === 0) {
|
|
122
108
|
return new Error('dialog.choose() exige pelo menos uma ação.')
|
|
@@ -145,8 +131,6 @@ export const dialog = {
|
|
|
145
131
|
},
|
|
146
132
|
/** Pergunta sim/não. `Promise<boolean>` — fechar/ESC resolve `false`. */
|
|
147
133
|
confirm(options: ConfirmOptions): Promise<boolean> {
|
|
148
|
-
const ambiguous = rejectAmbiguousContext('dialog.confirm()', options)
|
|
149
|
-
if (ambiguous !== null) return Promise.reject(ambiguous)
|
|
150
134
|
const err = requireHost('dialog.confirm()')
|
|
151
135
|
if (err !== null) return Promise.reject(err)
|
|
152
136
|
return new Promise<boolean>((resolve) => {
|
|
@@ -155,8 +139,6 @@ export const dialog = {
|
|
|
155
139
|
},
|
|
156
140
|
/** Pede uma string. `Promise<string | null>` — fechar/ESC/Cancelar resolve `null`. */
|
|
157
141
|
prompt(options: PromptOptions): Promise<string | null> {
|
|
158
|
-
const ambiguous = rejectAmbiguousContext('dialog.prompt()', options)
|
|
159
|
-
if (ambiguous !== null) return Promise.reject(ambiguous)
|
|
160
142
|
const err = requireHost('dialog.prompt()')
|
|
161
143
|
if (err !== null) return Promise.reject(err)
|
|
162
144
|
return new Promise<string | null>((resolve) => {
|
|
@@ -180,11 +162,6 @@ export const dialog = {
|
|
|
180
162
|
},
|
|
181
163
|
}
|
|
182
164
|
|
|
183
|
-
/** @deprecated Alias retrocompatível — use `dialog.confirm()`. */
|
|
184
|
-
export function confirm(options: ConfirmOptions): Promise<boolean> {
|
|
185
|
-
return dialog.confirm(options)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
165
|
/**
|
|
189
166
|
* O host — um por app, no shell. Enfileira: uma por vez (duas caixas empilhadas seriam
|
|
190
167
|
* ambíguas sobre qual clique respondeu o quê).
|
|
@@ -228,11 +205,8 @@ export function DialogHost(): React.ReactElement {
|
|
|
228
205
|
else p.resolve(value)
|
|
229
206
|
}
|
|
230
207
|
|
|
231
|
-
const variant = current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
|
|
232
|
-
? current.variant
|
|
233
|
-
: undefined
|
|
234
208
|
const context = current !== undefined && (current.kind === 'confirm' || current.kind === 'prompt')
|
|
235
|
-
? current.context ??
|
|
209
|
+
? current.context ?? 'primary'
|
|
236
210
|
: 'primary'
|
|
237
211
|
const defaultAction = current?.kind === 'alert' ? 'OK' : 'Confirmar'
|
|
238
212
|
|
|
@@ -344,6 +318,3 @@ export function DialogHost(): React.ReactElement {
|
|
|
344
318
|
</Dialog>
|
|
345
319
|
)
|
|
346
320
|
}
|
|
347
|
-
|
|
348
|
-
/** @deprecated Alias retrocompatível — monte `<DialogHost />`. */
|
|
349
|
-
export const ConfirmHost = DialogHost
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Children,
|
|
3
|
-
Fragment,
|
|
4
3
|
createContext,
|
|
5
4
|
isValidElement,
|
|
6
5
|
useContext,
|
|
@@ -10,9 +9,14 @@ import {
|
|
|
10
9
|
type ReactNode,
|
|
11
10
|
} from "react";
|
|
12
11
|
import { cn } from "../../lib/cn.ts";
|
|
12
|
+
import {
|
|
13
|
+
SurfaceHeader,
|
|
14
|
+
surfaceHeaderClasses,
|
|
15
|
+
type SurfaceHeaderVariant,
|
|
16
|
+
} from "./surface-header.tsx";
|
|
13
17
|
|
|
14
18
|
export type ContentHeaderLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
15
|
-
export type ContentHeaderVariant =
|
|
19
|
+
export type ContentHeaderVariant = SurfaceHeaderVariant;
|
|
16
20
|
|
|
17
21
|
interface ContentContextValue {
|
|
18
22
|
level: ContentHeaderLevel;
|
|
@@ -39,14 +43,14 @@ interface ContentBaseProps extends Omit<
|
|
|
39
43
|
> {
|
|
40
44
|
/** Nível semântico do heading, independente de seu destaque visual. */
|
|
41
45
|
level?: ContentHeaderLevel;
|
|
42
|
-
/** Hierarquia visual
|
|
46
|
+
/** Hierarquia visual: `section` (default) ou `page`, a mesma de PageHeader. */
|
|
43
47
|
variant?: ContentHeaderVariant;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
interface ContentShorthandProps extends ContentBaseProps {
|
|
47
51
|
title: ReactNode;
|
|
48
|
-
/**
|
|
49
|
-
|
|
52
|
+
/** Total de itens ao lado do título — o mesmo `count` de Page. */
|
|
53
|
+
count?: number;
|
|
50
54
|
description?: ReactNode;
|
|
51
55
|
actions?: ReactNode;
|
|
52
56
|
children: ReactNode;
|
|
@@ -54,7 +58,7 @@ interface ContentShorthandProps extends ContentBaseProps {
|
|
|
54
58
|
|
|
55
59
|
interface ContentComposedProps extends ContentBaseProps {
|
|
56
60
|
title?: never;
|
|
57
|
-
|
|
61
|
+
count?: never;
|
|
58
62
|
description?: never;
|
|
59
63
|
actions?: never;
|
|
60
64
|
children: ReactNode;
|
|
@@ -65,7 +69,7 @@ export type ContentProps = ContentShorthandProps | ContentComposedProps;
|
|
|
65
69
|
/** Região de conteúdo nomeada. A forma curta e a explícita produzem a mesma anatomia. */
|
|
66
70
|
export function Content({
|
|
67
71
|
title,
|
|
68
|
-
|
|
72
|
+
count,
|
|
69
73
|
description,
|
|
70
74
|
actions,
|
|
71
75
|
level = 2,
|
|
@@ -119,7 +123,7 @@ export function Content({
|
|
|
119
123
|
<>
|
|
120
124
|
<ContentHeader>
|
|
121
125
|
<ContentTitle>{title}</ContentTitle>
|
|
122
|
-
{
|
|
126
|
+
{count !== undefined && <ContentMeta>{count}</ContentMeta>}
|
|
123
127
|
{description !== undefined && (
|
|
124
128
|
<ContentDescription>{description}</ContentDescription>
|
|
125
129
|
)}
|
|
@@ -137,134 +141,36 @@ export function Content({
|
|
|
137
141
|
);
|
|
138
142
|
}
|
|
139
143
|
|
|
140
|
-
|
|
141
|
-
HTMLAttributes<HTMLDivElement>,
|
|
142
|
-
"children" | "title"
|
|
143
|
-
> {
|
|
144
|
-
/** @deprecated Prefira os slots explícitos ou o shorthand de Content. */
|
|
145
|
-
title: ReactNode;
|
|
146
|
-
meta?: ReactNode;
|
|
147
|
-
description?: ReactNode;
|
|
148
|
-
actions?: ReactNode;
|
|
149
|
-
/** @deprecated Declare o nível em Content. Compatibilidade da série 12. */
|
|
150
|
-
level?: ContentHeaderLevel;
|
|
151
|
-
/** @deprecated Declare a hierarquia visual em Content. Compatibilidade da série 12. */
|
|
152
|
-
variant?: ContentHeaderVariant;
|
|
153
|
-
children?: never;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
interface ContentHeaderComposedProps extends Omit<
|
|
157
|
-
HTMLAttributes<HTMLDivElement>,
|
|
158
|
-
"title"
|
|
159
|
-
> {
|
|
160
|
-
/** @deprecated Declare o nível em Content. Compatibilidade da série 12. */
|
|
161
|
-
level?: ContentHeaderLevel;
|
|
162
|
-
/** @deprecated Declare a hierarquia visual em Content. Compatibilidade da série 12. */
|
|
163
|
-
variant?: ContentHeaderVariant;
|
|
164
|
-
title?: never;
|
|
165
|
-
meta?: never;
|
|
166
|
-
description?: never;
|
|
167
|
-
actions?: never;
|
|
168
|
-
children: ReactNode;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
export type ContentHeaderProps =
|
|
172
|
-
ContentHeaderShorthandProps | ContentHeaderComposedProps;
|
|
144
|
+
export type ContentHeaderProps = Omit<HTMLAttributes<HTMLDivElement>, "title">;
|
|
173
145
|
|
|
174
146
|
/**
|
|
175
|
-
* Header estrutural de Content; não é uma região autônoma.
|
|
176
|
-
*
|
|
177
|
-
* Compartilha o layout (título, meta, descrição e ações) com `PageHeader` (page.tsx); os dois
|
|
178
|
-
* serão unificados numa próxima série — até lá, uma correção de layout em um deve ser espelhada
|
|
179
|
-
* no outro.
|
|
147
|
+
* Header estrutural de Content; não é uma região autônoma. A anatomia é a de
|
|
148
|
+
* `SurfaceHeader`, a mesma de `PageHeader`: só os nomes dos slots e a hierarquia mudam.
|
|
180
149
|
*/
|
|
181
150
|
export function ContentHeader({
|
|
182
|
-
title,
|
|
183
|
-
meta,
|
|
184
|
-
description,
|
|
185
|
-
actions,
|
|
186
|
-
level,
|
|
187
|
-
variant,
|
|
188
151
|
children,
|
|
189
|
-
className,
|
|
190
152
|
...props
|
|
191
153
|
}: ContentHeaderProps): ReactElement {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
throw new Error("ContentHeader deve estar dentro de Content.");
|
|
197
|
-
}
|
|
198
|
-
const content: ContentContextValue = {
|
|
199
|
-
level: level ?? parent?.level ?? 2,
|
|
200
|
-
titleId: parent?.titleId ?? standaloneTitleId,
|
|
201
|
-
variant: variant ?? parent?.variant ?? "section",
|
|
202
|
-
};
|
|
203
|
-
const nodes = Children.toArray(
|
|
204
|
-
shorthand ? (
|
|
205
|
-
<>
|
|
206
|
-
<ContentTitle>{title}</ContentTitle>
|
|
207
|
-
{meta !== undefined && <ContentMeta>{meta}</ContentMeta>}
|
|
208
|
-
{description !== undefined && (
|
|
209
|
-
<ContentDescription>{description}</ContentDescription>
|
|
210
|
-
)}
|
|
211
|
-
{actions !== undefined && <ContentActions>{actions}</ContentActions>}
|
|
212
|
-
</>
|
|
213
|
-
) : (
|
|
214
|
-
children
|
|
215
|
-
),
|
|
216
|
-
).flatMap((node) =>
|
|
217
|
-
isValidElement(node) && node.type === Fragment
|
|
218
|
-
? Children.toArray((node.props as { children?: ReactNode }).children)
|
|
219
|
-
: [node],
|
|
220
|
-
);
|
|
221
|
-
const titles = nodes.filter(
|
|
222
|
-
(node) => isValidElement(node) && node.type === ContentTitle,
|
|
223
|
-
);
|
|
224
|
-
const metas = nodes.filter(
|
|
225
|
-
(node) => isValidElement(node) && node.type === ContentMeta,
|
|
226
|
-
);
|
|
227
|
-
const descriptions = nodes.filter(
|
|
228
|
-
(node) => isValidElement(node) && node.type === ContentDescription,
|
|
229
|
-
);
|
|
230
|
-
const actionSlots = nodes.filter(
|
|
231
|
-
(node) => isValidElement(node) && node.type === ContentActions,
|
|
154
|
+
const content = requiredContext(
|
|
155
|
+
useContext(ContentContext),
|
|
156
|
+
"ContentHeader",
|
|
157
|
+
"Content",
|
|
232
158
|
);
|
|
233
|
-
const recognized =
|
|
234
|
-
titles.length + metas.length + descriptions.length + actionSlots.length;
|
|
235
|
-
|
|
236
|
-
if (
|
|
237
|
-
titles.length !== 1 ||
|
|
238
|
-
metas.length > 1 ||
|
|
239
|
-
descriptions.length > 1 ||
|
|
240
|
-
actionSlots.length > 1 ||
|
|
241
|
-
recognized !== nodes.length
|
|
242
|
-
) {
|
|
243
|
-
throw new Error(
|
|
244
|
-
"ContentHeader exige um ContentTitle e aceita no máximo um ContentDescription, ContentMeta e ContentActions como filhos diretos.",
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
159
|
return (
|
|
249
160
|
<ContentHeaderContext.Provider value={content}>
|
|
250
|
-
<
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
161
|
+
<SurfaceHeader
|
|
162
|
+
name="ContentHeader"
|
|
163
|
+
slot="content"
|
|
164
|
+
slots={{
|
|
165
|
+
title: ContentTitle,
|
|
166
|
+
count: ContentMeta,
|
|
167
|
+
description: ContentDescription,
|
|
168
|
+
actions: ContentActions,
|
|
169
|
+
}}
|
|
257
170
|
{...props}
|
|
258
171
|
>
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
{titles}
|
|
262
|
-
{metas}
|
|
263
|
-
</div>
|
|
264
|
-
{descriptions}
|
|
265
|
-
</div>
|
|
266
|
-
{actionSlots}
|
|
267
|
-
</div>
|
|
172
|
+
{children}
|
|
173
|
+
</SurfaceHeader>
|
|
268
174
|
</ContentHeaderContext.Provider>
|
|
269
175
|
);
|
|
270
176
|
}
|
|
@@ -293,11 +199,7 @@ export function ContentTitle({
|
|
|
293
199
|
<Heading
|
|
294
200
|
id={id ?? context.titleId}
|
|
295
201
|
data-slot="content-title"
|
|
296
|
-
className={cn(
|
|
297
|
-
"font-semibold",
|
|
298
|
-
context.variant === "page" ? "text-2xl tracking-tight" : "text-sm",
|
|
299
|
-
className,
|
|
300
|
-
)}
|
|
202
|
+
className={cn(surfaceHeaderClasses[context.variant].title, className)}
|
|
301
203
|
{...props}
|
|
302
204
|
/>
|
|
303
205
|
);
|
|
@@ -315,26 +217,29 @@ export function ContentDescription({
|
|
|
315
217
|
return (
|
|
316
218
|
<p
|
|
317
219
|
data-slot="content-description"
|
|
318
|
-
className={cn(
|
|
319
|
-
"truncate text-muted-foreground",
|
|
320
|
-
context.variant === "page" ? "mt-1 text-sm" : "mt-0.5 text-xs",
|
|
321
|
-
className,
|
|
322
|
-
)}
|
|
220
|
+
className={cn(surfaceHeaderClasses[context.variant].description, className)}
|
|
323
221
|
{...props}
|
|
324
222
|
/>
|
|
325
223
|
);
|
|
326
224
|
}
|
|
327
225
|
|
|
226
|
+
/** O contador ao lado do título — o slot `count` do shorthand. */
|
|
328
227
|
export function ContentMeta({
|
|
329
228
|
className,
|
|
330
229
|
...props
|
|
331
230
|
}: HTMLAttributes<HTMLSpanElement>): ReactElement {
|
|
332
|
-
requiredContext(
|
|
231
|
+
const context = requiredContext(
|
|
333
232
|
useContext(ContentHeaderContext),
|
|
334
233
|
"ContentMeta",
|
|
335
234
|
"ContentHeader",
|
|
336
235
|
);
|
|
337
|
-
return
|
|
236
|
+
return (
|
|
237
|
+
<span
|
|
238
|
+
data-slot="content-meta"
|
|
239
|
+
className={cn(surfaceHeaderClasses[context.variant].count, className)}
|
|
240
|
+
{...props}
|
|
241
|
+
/>
|
|
242
|
+
);
|
|
338
243
|
}
|
|
339
244
|
|
|
340
245
|
export function ContentActions({
|
|
@@ -349,11 +254,7 @@ export function ContentActions({
|
|
|
349
254
|
return (
|
|
350
255
|
<div
|
|
351
256
|
data-slot="content-actions"
|
|
352
|
-
className={cn(
|
|
353
|
-
"flex w-full shrink-0 items-center sm:w-auto",
|
|
354
|
-
context.variant === "page" ? "gap-4" : "gap-2",
|
|
355
|
-
className,
|
|
356
|
-
)}
|
|
257
|
+
className={cn(surfaceHeaderClasses[context.variant].actions, className)}
|
|
357
258
|
{...props}
|
|
358
259
|
/>
|
|
359
260
|
);
|
|
@@ -2,31 +2,39 @@
|
|
|
2
2
|
* <DataState /> — o estado "carregando" da casa: o que mostrar ANTES do conteúdo chegar.
|
|
3
3
|
*
|
|
4
4
|
* Orquestra os estados de uma carga assíncrona (lista/fetch) num lugar só: erro (calmo, sem
|
|
5
|
-
* jargão na tela), carregando (Spinner centralizado), vazio (
|
|
5
|
+
* jargão na tela), carregando (Spinner centralizado), vazio (Empty) e, por fim, o conteúdo.
|
|
6
6
|
* Dissolve o `error ? … : isLoading ? <Spinner/> : empty ? … : children` repetido em toda
|
|
7
7
|
* tela. NÃO confundir com "processando" (AÇÃO em andamento depois do clique) — isso é o
|
|
8
8
|
* `busy` do Button. Pra placeholder com forma de conteúdo, use Skeleton.
|
|
9
|
+
*
|
|
10
|
+
* As superfícies são as mesmas de PageState (state-surface.tsx); aqui elas ficam na escala de
|
|
11
|
+
* uma seção: o vazio é a frase discreta com moldura sólida e o erro é o Alert com recuperação.
|
|
9
12
|
*/
|
|
10
13
|
import * as React from 'react'
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_EMPTY_MESSAGE,
|
|
16
|
+
DEFAULT_ERROR_MESSAGE,
|
|
17
|
+
DEFAULT_RETRY_LABEL,
|
|
18
|
+
EmptySurface,
|
|
19
|
+
ErrorSurface,
|
|
20
|
+
LoadingSurface,
|
|
21
|
+
} from './state-surface.tsx'
|
|
15
22
|
|
|
16
23
|
export interface DataStateProps {
|
|
17
24
|
/** Carregando (antes do conteúdo) — mostra o Spinner centralizado. */
|
|
18
25
|
loading?: boolean
|
|
19
|
-
/** Erro da carga. A mensagem técnica NÃO vai pra tela (passe `
|
|
26
|
+
/** Erro da carga. A mensagem técnica NÃO vai pra tela (passe `errorMessage` pra um aviso). */
|
|
20
27
|
error?: { message?: string } | null
|
|
21
|
-
/** Sem itens — mostra o `
|
|
28
|
+
/** Sem itens — mostra o `emptyMessage`. */
|
|
22
29
|
empty?: boolean
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
/** Aviso de erro (calmo, orientado
|
|
26
|
-
|
|
30
|
+
/** Frase do vazio (pt-BR, ex.: "Nenhum papel."). Default: "Nada por aqui.". */
|
|
31
|
+
emptyMessage?: string
|
|
32
|
+
/** Aviso de erro (calmo, orientado à pessoa). Default: "Não foi possível carregar.". */
|
|
33
|
+
errorMessage?: string
|
|
27
34
|
/** Recuperação opcional exibida somente no estado de erro. */
|
|
28
35
|
onRetry?: () => Promise<void> | void
|
|
29
|
-
|
|
36
|
+
/** Rótulo do botão de recuperação. Default: "Tentar de novo". */
|
|
37
|
+
retryLabel?: string
|
|
30
38
|
/** Em TABELA: renderiza o estado como UMA linha (<tr><td colSpan>), não um bloco — pra
|
|
31
39
|
* caber direto no <tbody>. Passe o nº de colunas da tabela. */
|
|
32
40
|
colSpan?: number
|
|
@@ -39,78 +47,44 @@ export function DataState({
|
|
|
39
47
|
loading = false,
|
|
40
48
|
error = null,
|
|
41
49
|
empty = false,
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
emptyMessage = DEFAULT_EMPTY_MESSAGE,
|
|
51
|
+
errorMessage = DEFAULT_ERROR_MESSAGE,
|
|
44
52
|
onRetry,
|
|
45
|
-
|
|
53
|
+
retryLabel = DEFAULT_RETRY_LABEL,
|
|
46
54
|
colSpan,
|
|
47
55
|
children,
|
|
48
56
|
}: DataStateProps): React.ReactElement {
|
|
49
57
|
// Há conteúdo: passa direto (no modo tabela, são as <tr> dos itens).
|
|
50
58
|
if (!error && !loading && !empty) return <>{children}</>
|
|
51
59
|
|
|
60
|
+
const status = error ? 'error' : loading ? 'loading' : 'empty'
|
|
61
|
+
const surface = error ? (
|
|
62
|
+
<ErrorSurface
|
|
63
|
+
title={errorMessage}
|
|
64
|
+
{...(onRetry !== undefined ? { onRetry } : {})}
|
|
65
|
+
retryLabel={retryLabel}
|
|
66
|
+
/>
|
|
67
|
+
) : loading ? (
|
|
68
|
+
<LoadingSurface className="py-10" />
|
|
69
|
+
) : (
|
|
70
|
+
// A tabela já traz a moldura: dentro dela o vazio é só a frase (`bare`).
|
|
71
|
+
<EmptySurface title={emptyMessage} compact frame={colSpan === undefined ? 'structural' : 'bare'} />
|
|
72
|
+
)
|
|
73
|
+
|
|
52
74
|
// Modo TABELA: o estado vira UMA linha de largura cheia (<td colSpan>), pra caber no <tbody>.
|
|
53
75
|
if (colSpan !== undefined) {
|
|
54
|
-
const status = error ? 'error' : loading ? 'loading' : 'empty'
|
|
55
76
|
return (
|
|
56
77
|
<tr data-slot="data-state" data-status={status}>
|
|
57
|
-
<td
|
|
58
|
-
|
|
59
|
-
className={cn('px-4 py-10 text-center text-sm', error ? 'text-context-danger-emphasis' : 'text-muted-foreground/60')}
|
|
60
|
-
>
|
|
61
|
-
{error ? (
|
|
62
|
-
<div className="flex flex-col items-center gap-3">
|
|
63
|
-
<span>{errorText ?? 'Não foi possível carregar.'}</span>
|
|
64
|
-
{onRetry !== undefined && (
|
|
65
|
-
<Button variant="outline" size="sm" onClick={() => void onRetry()}>
|
|
66
|
-
{retryText}
|
|
67
|
-
</Button>
|
|
68
|
-
)}
|
|
69
|
-
</div>
|
|
70
|
-
) : loading ? (
|
|
71
|
-
<Spinner className="mx-auto" />
|
|
72
|
-
) : (
|
|
73
|
-
emptyText
|
|
74
|
-
)}
|
|
78
|
+
<td colSpan={colSpan} className="px-4 py-4 text-center text-sm">
|
|
79
|
+
{surface}
|
|
75
80
|
</td>
|
|
76
81
|
</tr>
|
|
77
82
|
)
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
// Modo BLOCO (default).
|
|
81
|
-
if (error) {
|
|
82
|
-
return (
|
|
83
|
-
<div data-slot="data-state" data-status="error">
|
|
84
|
-
<Alert context="danger" description={errorText ?? 'Não foi possível carregar.'}>
|
|
85
|
-
{onRetry !== undefined && (
|
|
86
|
-
<Button variant="outline" size="sm" onClick={() => void onRetry()}>
|
|
87
|
-
{retryText}
|
|
88
|
-
</Button>
|
|
89
|
-
)}
|
|
90
|
-
</Alert>
|
|
91
|
-
</div>
|
|
92
|
-
)
|
|
93
|
-
}
|
|
94
|
-
if (loading) {
|
|
95
|
-
return (
|
|
96
|
-
<div
|
|
97
|
-
data-slot="data-state"
|
|
98
|
-
data-status="loading"
|
|
99
|
-
role="status"
|
|
100
|
-
aria-live="polite"
|
|
101
|
-
className="py-10 text-center text-muted-foreground"
|
|
102
|
-
>
|
|
103
|
-
<Spinner className="mx-auto" />
|
|
104
|
-
</div>
|
|
105
|
-
)
|
|
106
|
-
}
|
|
107
85
|
return (
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
className="rounded-lg border border-border py-10 text-center text-sm text-muted-foreground/60"
|
|
112
|
-
>
|
|
113
|
-
{emptyText}
|
|
114
|
-
</p>
|
|
86
|
+
<div data-slot="data-state" data-status={status}>
|
|
87
|
+
{surface}
|
|
88
|
+
</div>
|
|
115
89
|
)
|
|
116
90
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Dois modos, um motor:
|
|
5
5
|
* - AUTO (sem children): monta TODOS os campos na ordem do contrato — zero JSX de campo.
|
|
6
6
|
* - COMPOSIÇÃO (com children): você diagrama; <ActionFormField name /> coloca cada campo
|
|
7
|
-
* (label/
|
|
7
|
+
* (label/help/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
|
|
8
8
|
* JSX ({cond && <ActionFormField/>}); opções de runtime entram por prop no campo. O
|
|
9
9
|
* FieldGroup canônico permanece nos dois modos; grids e seções entram como filhos explícitos.
|
|
10
10
|
*
|
|
@@ -40,7 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../pri
|
|
|
40
40
|
import { Info } from 'lucide-react'
|
|
41
41
|
import { Select, type SelectOption } from '../primitives/select.tsx'
|
|
42
42
|
import { ToggleGroup, ToggleGroupItem } from '../primitives/toggle-group.tsx'
|
|
43
|
-
import { Field,
|
|
43
|
+
import { Field, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
|
|
44
44
|
|
|
45
45
|
// =============================================================================
|
|
46
46
|
// Inferência de tipo de field a partir do Zod schema
|
|
@@ -112,8 +112,6 @@ function inferFieldKind(schema: ZodTypeAny): FieldKind {
|
|
|
112
112
|
interface FieldSpec {
|
|
113
113
|
label?: string
|
|
114
114
|
placeholder?: string
|
|
115
|
-
/** @deprecated Use `help` (ajuda junto à label) ou `description` do campo composto. */
|
|
116
|
-
hint?: string
|
|
117
115
|
/** Ajuda na label: ícone ⓘ + tooltip no hover. */
|
|
118
116
|
help?: string
|
|
119
117
|
/** Override explícito do tipo de campo, quando o auto-detect do Zod não basta.
|
|
@@ -173,11 +171,18 @@ function dictMetaOptions(schema: ZodTypeAny): SelectOption[] | undefined {
|
|
|
173
171
|
})
|
|
174
172
|
}
|
|
175
173
|
|
|
176
|
-
/**
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Ícone de ajuda junto à label (ⓘ + tooltip Radix). Self-contained (inclui o Provider):
|
|
176
|
+
* renderiza somente quando `help` tem texto. É o mesmo slot que o `ActionFormField` usa para
|
|
177
|
+
* `FieldSpec.help`; exportado para que um formulário sem contrato mostre a ajuda no mesmo
|
|
178
|
+
* lugar (`<FieldLabel>Nome<LabelHelp help="…" /></FieldLabel>`) e o `opus copy` inventarie
|
|
179
|
+
* o texto de `help` como helper-text.
|
|
180
|
+
*
|
|
181
|
+
* `tabIndex={-1}`: fica FORA do autofocus do diálogo (senão o modal foca no ícone ao abrir e
|
|
182
|
+
* o tooltip vem aberto); abre só no hover. preventDefault: clicar o ícone não dispara o label
|
|
183
|
+
* (não foca o input / não toggla).
|
|
184
|
+
*/
|
|
185
|
+
export function LabelHelp({ help }: { help: string | undefined }): React.ReactElement | null {
|
|
181
186
|
if (help === undefined || help.trim().length === 0) return null
|
|
182
187
|
return (
|
|
183
188
|
<TooltipProvider delayDuration={300}>
|
|
@@ -289,9 +294,8 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
289
294
|
const spec: FieldSpec = fields[name] ?? {}
|
|
290
295
|
const fieldError = form.formState.errors[name]
|
|
291
296
|
const errorMessage = typeof fieldError?.message === 'string' ? fieldError.message : undefined
|
|
292
|
-
const descriptionId = spec.hint !== undefined ? `${name}-description` : undefined
|
|
293
297
|
const errorId = errorMessage !== undefined ? `${name}-error` : undefined
|
|
294
|
-
const describedBy =
|
|
298
|
+
const describedBy = errorId
|
|
295
299
|
useEffect(() => {
|
|
296
300
|
if (errorMessage === undefined) return
|
|
297
301
|
const control = document.getElementById(name)
|
|
@@ -373,7 +377,6 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
373
377
|
</span>
|
|
374
378
|
<LabelHelp help={spec.help} />
|
|
375
379
|
</FieldLabel>
|
|
376
|
-
{spec.hint !== undefined && <FieldDescription id={descriptionId}>{spec.hint}</FieldDescription>}
|
|
377
380
|
{errorMessage !== undefined && <FieldError id={errorId}>{errorMessage}</FieldError>}
|
|
378
381
|
</Field>
|
|
379
382
|
)
|
|
@@ -595,9 +598,6 @@ export function ActionFormField({ name, options: optionsProp, className }: Actio
|
|
|
595
598
|
/>
|
|
596
599
|
)}
|
|
597
600
|
|
|
598
|
-
{spec.hint !== undefined && (
|
|
599
|
-
<FieldDescription id={descriptionId}>{spec.hint}</FieldDescription>
|
|
600
|
-
)}
|
|
601
601
|
{errorMessage !== undefined && (
|
|
602
602
|
<FieldError id={errorId}>{errorMessage}</FieldError>
|
|
603
603
|
)}
|
|
@@ -309,8 +309,12 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
309
309
|
/** Itens por página (o pattern manda `limit` e `page` no input; o handler implementa
|
|
310
310
|
* o OFFSET e devolve `total`). O rodapé mostra o total e o pager quando transborda. */
|
|
311
311
|
pageSize?: number;
|
|
312
|
-
/** Mensagem
|
|
312
|
+
/** Mensagem do estado vazio. Default: "Nenhum resultado." */
|
|
313
313
|
emptyMessage?: string;
|
|
314
|
+
/** Mensagem do estado de erro (calma, sem o texto técnico). Default do DataState. */
|
|
315
|
+
errorMessage?: string;
|
|
316
|
+
/** Rótulo do botão de recuperação no erro. Default: "Tentar de novo". */
|
|
317
|
+
retryLabel?: string;
|
|
314
318
|
/** Sobrepõe o vazio derivado (items.length === 0) — ex.: form inline aberto conta
|
|
315
319
|
* como conteúdo, então os children devem renderizar mesmo com a lista vazia. */
|
|
316
320
|
empty?: (items: TItem[]) => boolean;
|
|
@@ -1138,6 +1142,8 @@ export function ActionList<TInput, TItem>({
|
|
|
1138
1142
|
rowId = (item: TItem) => String((item as { id?: unknown }).id ?? ""),
|
|
1139
1143
|
pageSize = 50,
|
|
1140
1144
|
emptyMessage = "Nenhum resultado.",
|
|
1145
|
+
errorMessage,
|
|
1146
|
+
retryLabel,
|
|
1141
1147
|
empty,
|
|
1142
1148
|
loading: extraLoading,
|
|
1143
1149
|
onRowClick,
|
|
@@ -1629,7 +1635,9 @@ export function ActionList<TInput, TItem>({
|
|
|
1629
1635
|
loading={busy && items.length === 0 && !(empty !== undefined && !isEmpty)}
|
|
1630
1636
|
error={isError ? error : null}
|
|
1631
1637
|
empty={isEmpty}
|
|
1632
|
-
|
|
1638
|
+
emptyMessage={emptyMessage}
|
|
1639
|
+
{...(errorMessage !== undefined ? { errorMessage } : {})}
|
|
1640
|
+
{...(retryLabel !== undefined ? { retryLabel } : {})}
|
|
1633
1641
|
onRetry={() => refetch()}
|
|
1634
1642
|
>
|
|
1635
1643
|
{content}
|
|
@@ -1697,15 +1705,14 @@ export function ActionList<TInput, TItem>({
|
|
|
1697
1705
|
Página {state.page} de {pages}
|
|
1698
1706
|
</span>
|
|
1699
1707
|
{pages > 1 && (
|
|
1700
|
-
// A primitiva pública na escala densa do rodapé: setas
|
|
1708
|
+
// A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
|
|
1701
1709
|
// números `h-7 min-w-7` que crescem com os dígitos (5726 e 5727 não se colam).
|
|
1702
1710
|
<Pagination className="mx-0 w-auto">
|
|
1703
1711
|
<PaginationContent className="gap-0.5">
|
|
1704
1712
|
<PaginationItem>
|
|
1705
1713
|
<PaginationPrevious
|
|
1706
1714
|
iconOnly
|
|
1707
|
-
|
|
1708
|
-
iconClassName="size-3.5"
|
|
1715
|
+
size="icon-sm"
|
|
1709
1716
|
disabled={state.page <= 1}
|
|
1710
1717
|
onClick={() => goTo(state.page - 1)}
|
|
1711
1718
|
/>
|
|
@@ -1714,7 +1721,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1714
1721
|
n === "…" ? (
|
|
1715
1722
|
// eslint-disable-next-line react/no-array-index-key
|
|
1716
1723
|
<PaginationItem key={`e${i}`}>
|
|
1717
|
-
<PaginationEllipsis
|
|
1724
|
+
<PaginationEllipsis size="icon-sm" />
|
|
1718
1725
|
</PaginationItem>
|
|
1719
1726
|
) : (
|
|
1720
1727
|
<PaginationItem key={n}>
|
|
@@ -1730,8 +1737,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1730
1737
|
<PaginationItem>
|
|
1731
1738
|
<PaginationNext
|
|
1732
1739
|
iconOnly
|
|
1733
|
-
|
|
1734
|
-
iconClassName="size-3.5"
|
|
1740
|
+
size="icon-sm"
|
|
1735
1741
|
disabled={state.page >= pages}
|
|
1736
1742
|
onClick={() => goTo(state.page + 1)}
|
|
1737
1743
|
/>
|