@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/bin/lib/copy.mjs +276 -6
  3. package/docs/code-style.md +4 -1
  4. package/package.json +1 -1
  5. package/registry/instructions/opus.md +3 -3
  6. package/registry/templates/app/src/App.tsx +11 -6
  7. package/src/core/types.ts +3 -4
  8. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  9. package/src/ui/components/patterns/confirm.tsx +2 -31
  10. package/src/ui/components/patterns/content-header.tsx +42 -141
  11. package/src/ui/components/patterns/data-state.tsx +42 -68
  12. package/src/ui/components/patterns/form.tsx +15 -15
  13. package/src/ui/components/patterns/list.tsx +14 -8
  14. package/src/ui/components/patterns/page-state.tsx +39 -51
  15. package/src/ui/components/patterns/page.tsx +18 -53
  16. package/src/ui/components/patterns/state-surface.tsx +148 -0
  17. package/src/ui/components/patterns/surface-header.tsx +119 -0
  18. package/src/ui/components/patterns/trigger.tsx +7 -9
  19. package/src/ui/components/patterns/view.tsx +14 -16
  20. package/src/ui/components/primitives/alert.tsx +1 -33
  21. package/src/ui/components/primitives/avatar.tsx +15 -5
  22. package/src/ui/components/primitives/badge.tsx +2 -43
  23. package/src/ui/components/primitives/button.tsx +31 -35
  24. package/src/ui/components/primitives/control.ts +60 -0
  25. package/src/ui/components/primitives/dot.tsx +1 -30
  26. package/src/ui/components/primitives/input-group.tsx +11 -8
  27. package/src/ui/components/primitives/item.tsx +3 -1
  28. package/src/ui/components/primitives/menu.tsx +1 -7
  29. package/src/ui/components/primitives/pagination.tsx +16 -8
  30. package/src/ui/components/primitives/select.tsx +2 -2
  31. package/src/ui/components/primitives/spinner.tsx +13 -16
  32. package/src/ui/components/primitives/switch.tsx +4 -1
  33. package/src/ui/components/primitives/tabs.tsx +5 -3
  34. package/src/ui/components/primitives/toggle.tsx +9 -4
  35. package/src/ui/docs/content/action-form.md +26 -0
  36. package/src/ui/docs/content/action-list-dialog.md +2 -2
  37. package/src/ui/docs/content/action-list.md +3 -1
  38. package/src/ui/docs/content/action-trigger.md +4 -4
  39. package/src/ui/docs/content/action-view.md +3 -2
  40. package/src/ui/docs/content/avatar.md +7 -3
  41. package/src/ui/docs/content/button.md +30 -14
  42. package/src/ui/docs/content/communication.md +36 -0
  43. package/src/ui/docs/content/content.md +5 -4
  44. package/src/ui/docs/content/data-state.md +17 -13
  45. package/src/ui/docs/content/dialog.md +1 -4
  46. package/src/ui/docs/content/input.md +1 -1
  47. package/src/ui/docs/content/item.md +1 -1
  48. package/src/ui/docs/content/page.md +12 -4
  49. package/src/ui/docs/content/pagination.md +11 -9
  50. package/src/ui/docs/content/semantic-context.md +3 -2
  51. package/src/ui/docs/content/sidebar.md +2 -42
  52. package/src/ui/docs/content/spinner.md +9 -6
  53. package/src/ui/docs/content/switch.md +1 -1
  54. package/src/ui/docs/content/tabs.md +1 -1
  55. package/src/ui/docs/content/toggle.md +1 -1
  56. package/src/ui/drivers/react.tsx +1 -6
  57. package/src/ui/meta.ts +5 -5
  58. package/src/ui/react.tsx +8 -16
  59. 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. `confirm()` segue exportado como alias.
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 ?? (variant === 'destructive' ? 'danger' : 'primary')
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 = "page" | "section";
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. `page` permanece somente para compatibilidade durante a versão 12. */
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
- /** Conteúdo complementar exibido ao lado do título, como uma contagem. */
49
- meta?: ReactNode;
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
- meta?: never;
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
- meta,
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
- {meta !== undefined && <ContentMeta>{meta}</ContentMeta>}
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
- interface ContentHeaderShorthandProps extends Omit<
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 parent = useContext(ContentContext);
193
- const standaloneTitleId = useId();
194
- const shorthand = title !== undefined;
195
- if (parent === null && !shorthand) {
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
- <div
251
- data-slot="content-header"
252
- className={cn(
253
- actionSlots.length > 0 &&
254
- "flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
255
- className,
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
- <div data-slot="content-header-heading" className="min-w-0">
260
- <div className="flex items-baseline gap-2">
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 <span data-slot="content-meta" className={className} {...props} />;
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 (texto) e, por fim, o conteúdo.
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 { cn } from '../../lib/cn.ts'
12
- import { Alert } from '../primitives/alert.tsx'
13
- import { Button } from '../primitives/button.tsx'
14
- import { Spinner } from '../primitives/spinner.tsx'
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 `errorText` pra um aviso). */
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 `emptyText`. */
28
+ /** Sem itens — mostra o `emptyMessage`. */
22
29
  empty?: boolean
23
- /** Texto do vazio (pt-BR, ex.: "Nenhum papel."). */
24
- emptyText?: string
25
- /** Aviso de erro (calmo, orientado ao usuário). Default: "Não foi possível carregar.". */
26
- errorText?: string
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
- retryText?: string
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
- emptyText = 'Nada por aqui.',
43
- errorText,
50
+ emptyMessage = DEFAULT_EMPTY_MESSAGE,
51
+ errorMessage = DEFAULT_ERROR_MESSAGE,
44
52
  onRetry,
45
- retryText = 'Tentar de novo',
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
- colSpan={colSpan}
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
- <p
109
- data-slot="data-state"
110
- data-status="empty"
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/hint/widget/erro/asterisco derivados do contrato) onde quiser. Condicional é
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, FieldDescription, FieldError, FieldGroup, FieldLabel } from '../primitives/field.tsx'
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
- /** Ícone de ajuda na label (ⓘ + tooltip Radix). Self-contained (inclui o Provider) — só
177
- * renderiza quando o FieldSpec define `help`. `tabIndex={-1}`: fica FORA do autofocus do
178
- * diálogo (senão o modal foca no ícone ao abrir e o tooltip vem aberto); abre no hover.
179
- * preventDefault: clicar o ícone não dispara o label (não foca o input / não toggla). */
180
- function LabelHelp({ help }: { help: string | undefined }): React.ReactElement | null {
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 = [descriptionId, errorId].filter(Boolean).join(' ') || undefined
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 de empty state. Default: "Nenhum resultado." */
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
- emptyText={emptyMessage}
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 quadradas `size-7`,
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
- className="size-7"
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 className="size-7" />
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
- className="size-7"
1734
- iconClassName="size-3.5"
1740
+ size="icon-sm"
1735
1741
  disabled={state.page >= pages}
1736
1742
  onClick={() => goTo(state.page + 1)}
1737
1743
  />