@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
|
@@ -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
|
)}
|