@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
@@ -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
- Empty,
15
- EmptyActions,
16
- EmptyDescription,
17
- EmptyHeader,
18
- EmptyMedia,
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 tentar novamente, escolher ou criar. */
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
- data-slot="page-state"
55
- data-status="loading"
56
- role="status"
57
- aria-live="polite"
58
- className={cn(
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
- <Alert
78
+ <ErrorSurface
89
79
  className="max-w-xl"
90
- context="danger"
91
- icon={icon ?? <TriangleAlert />}
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
- </Alert>
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
- <Empty className="min-h-64">
104
- <EmptyHeader>
105
- {icon !== undefined && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
106
- <EmptyTitle>{title ?? "Nada por aqui"}</EmptyTitle>
107
- {description !== undefined && (
108
- <EmptyDescription>{description}</EmptyDescription>
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
- <div
171
- data-slot="page-header"
172
- className={cn(
173
- "mb-6",
174
- actionSlots.length > 0 &&
175
- "flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
176
- className,
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
- <div data-slot="page-heading" className="min-w-0">
181
- <div className="flex items-baseline gap-2">
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("text-2xl font-semibold tracking-tight", className)}
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("mt-1 truncate text-sm text-muted-foreground", className)}
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("font-mono text-base text-muted-foreground/60", className)}
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: é a ação que mora NO item (linha,
9
- * card). Absorveu o antigo DeleteButton, que era este componente com uma lixeira.
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?: 'default' | 'sm' | 'lg' | 'icon'
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 { Button } from '../primitives/button.tsx'
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" + botão "Tentar de novo" (omitido em
30
- * falta de permissão ou sessão); a frase do servidor entra como descrição só quando está na
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
- <Alert
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
- {isRetryable(err) && (
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
- return <div data-action={action.name}>{empty ?? null}</div>
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>