@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.
Files changed (72) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +33 -5
  4. package/bin/lib/cli-shared.mjs +30 -1
  5. package/bin/lib/copy.mjs +279 -6
  6. package/bin/lib/db.mjs +2 -0
  7. package/docs/adr/0004-page-content-state-is-composed.md +39 -5
  8. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
  9. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
  10. package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
  11. package/docs/code-style.md +4 -1
  12. package/docs/data-layer.md +9 -0
  13. package/package.json +1 -1
  14. package/registry/instructions/opus.md +3 -3
  15. package/registry/skills/build-opus-ui/SKILL.md +3 -2
  16. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
  17. package/registry/templates/app/src/App.tsx +11 -6
  18. package/src/core/types.ts +3 -4
  19. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  20. package/src/ui/components/patterns/confirm.tsx +2 -31
  21. package/src/ui/components/patterns/content-header.tsx +42 -141
  22. package/src/ui/components/patterns/data-state.tsx +42 -68
  23. package/src/ui/components/patterns/form.tsx +15 -15
  24. package/src/ui/components/patterns/list.tsx +55 -34
  25. package/src/ui/components/patterns/page-state.tsx +81 -51
  26. package/src/ui/components/patterns/page.tsx +228 -97
  27. package/src/ui/components/patterns/state-surface.tsx +262 -0
  28. package/src/ui/components/patterns/surface-header.tsx +204 -0
  29. package/src/ui/components/patterns/trigger.tsx +9 -10
  30. package/src/ui/components/patterns/view.tsx +14 -16
  31. package/src/ui/components/primitives/alert.tsx +1 -33
  32. package/src/ui/components/primitives/avatar.tsx +15 -5
  33. package/src/ui/components/primitives/badge.tsx +2 -43
  34. package/src/ui/components/primitives/button-group.tsx +34 -8
  35. package/src/ui/components/primitives/button.tsx +31 -35
  36. package/src/ui/components/primitives/control.ts +69 -0
  37. package/src/ui/components/primitives/dot.tsx +1 -30
  38. package/src/ui/components/primitives/input-group.tsx +11 -8
  39. package/src/ui/components/primitives/item.tsx +3 -1
  40. package/src/ui/components/primitives/menu.tsx +1 -7
  41. package/src/ui/components/primitives/pagination.tsx +16 -8
  42. package/src/ui/components/primitives/select.tsx +2 -2
  43. package/src/ui/components/primitives/spinner.tsx +13 -16
  44. package/src/ui/components/primitives/switch.tsx +4 -1
  45. package/src/ui/components/primitives/tabs.tsx +5 -3
  46. package/src/ui/components/primitives/toggle.tsx +9 -4
  47. package/src/ui/docs/content/action-form.md +26 -0
  48. package/src/ui/docs/content/action-list-dialog.md +2 -2
  49. package/src/ui/docs/content/action-list.md +35 -2
  50. package/src/ui/docs/content/action-trigger.md +5 -4
  51. package/src/ui/docs/content/action-view.md +3 -2
  52. package/src/ui/docs/content/alert.md +16 -4
  53. package/src/ui/docs/content/avatar.md +7 -3
  54. package/src/ui/docs/content/button.md +48 -17
  55. package/src/ui/docs/content/communication.md +36 -0
  56. package/src/ui/docs/content/content.md +5 -4
  57. package/src/ui/docs/content/data-state.md +17 -13
  58. package/src/ui/docs/content/dialog.md +1 -4
  59. package/src/ui/docs/content/input.md +1 -1
  60. package/src/ui/docs/content/item.md +1 -1
  61. package/src/ui/docs/content/page.md +160 -37
  62. package/src/ui/docs/content/pagination.md +11 -9
  63. package/src/ui/docs/content/semantic-context.md +3 -2
  64. package/src/ui/docs/content/sidebar.md +2 -42
  65. package/src/ui/docs/content/spinner.md +9 -6
  66. package/src/ui/docs/content/switch.md +1 -1
  67. package/src/ui/docs/content/tabs.md +1 -1
  68. package/src/ui/docs/content/toggle.md +1 -1
  69. package/src/ui/drivers/react.tsx +1 -6
  70. package/src/ui/meta.ts +8 -8
  71. package/src/ui/react.tsx +16 -18
  72. package/src/ui/components/patterns/shell-nav.tsx +0 -154
@@ -9,16 +9,38 @@ import {
9
9
  Children,
10
10
  createContext,
11
11
  isValidElement,
12
+ useCallback,
12
13
  useContext,
14
+ useRef,
15
+ useState,
13
16
  type HTMLAttributes,
17
+ type ComponentProps,
14
18
  type ReactElement,
15
19
  type ReactNode,
16
20
  } from "react";
17
21
  import { createPortal } from "react-dom";
22
+ import { ArrowLeft } from "lucide-react";
18
23
  import { cn } from "../../lib/cn.ts";
24
+ import { Button } from "../primitives/button.tsx";
25
+ import {
26
+ Tooltip,
27
+ TooltipContent,
28
+ TooltipProvider,
29
+ TooltipTrigger,
30
+ } from "../primitives/tooltip.tsx";
31
+ import { SurfaceHeader, surfaceHeaderClasses } from "./surface-header.tsx";
32
+ import { PageStatePresenceProvider } from "./page-state.tsx";
33
+
34
+ export type PageHeaderVariant = "default" | "bar";
19
35
 
20
- const PageContext = createContext(false);
21
- const PageHeaderContext = createContext(false);
36
+ interface PageContextValue {
37
+ headerVariant: PageHeaderVariant;
38
+ containerClassName?: string;
39
+ }
40
+
41
+ const PageContext = createContext<PageContextValue | null>(null);
42
+ const PageHeaderContext = createContext<PageHeaderVariant | null>(null);
43
+ const PageIntegralStateContext = createContext(false);
22
44
  const PageActionsTargetContext = createContext<HTMLElement | null>(null);
23
45
 
24
46
  export function PageActionsTarget({
@@ -53,8 +75,6 @@ interface PageBaseProps extends Omit<
53
75
 
54
76
  interface PageShorthandProps extends PageBaseProps {
55
77
  title: ReactNode;
56
- /** Total de itens ao lado do título. */
57
- count?: number;
58
78
  description?: ReactNode;
59
79
  actions?: ReactNode;
60
80
  children: ReactNode;
@@ -62,7 +82,6 @@ interface PageShorthandProps extends PageBaseProps {
62
82
 
63
83
  interface PageComposedProps extends PageBaseProps {
64
84
  title?: never;
65
- count?: never;
66
85
  description?: never;
67
86
  actions?: never;
68
87
  children: ReactNode;
@@ -72,7 +91,6 @@ export type PageProps = PageShorthandProps | PageComposedProps;
72
91
 
73
92
  export function Page({
74
93
  title,
75
- count,
76
94
  description,
77
95
  actions,
78
96
  className,
@@ -81,6 +99,25 @@ export function Page({
81
99
  }: PageProps): ReactElement {
82
100
  const shorthand = title !== undefined;
83
101
  const nodes = Children.toArray(children);
102
+ const headers = nodes.filter(
103
+ (node) => isValidElement(node) && node.type === PageHeader,
104
+ );
105
+ const bodies = nodes.filter(
106
+ (node) => isValidElement(node) && node.type === PageBody,
107
+ );
108
+ const activeStateCount = useRef(0);
109
+ const [integralState, setIntegralState] = useState(false);
110
+ const registerIntegralState = useCallback(() => {
111
+ activeStateCount.current += 1;
112
+ setIntegralState(true);
113
+ let registered = true;
114
+ return () => {
115
+ if (!registered) return;
116
+ registered = false;
117
+ activeStateCount.current = Math.max(0, activeStateCount.current - 1);
118
+ setIntegralState(activeStateCount.current > 0);
119
+ };
120
+ }, []);
84
121
  if (
85
122
  shorthand &&
86
123
  nodes.some((node) => isValidElement(node) && node.type === PageHeader)
@@ -90,12 +127,6 @@ export function Page({
90
127
  );
91
128
  }
92
129
  if (!shorthand) {
93
- const headers = nodes.filter(
94
- (node) => isValidElement(node) && node.type === PageHeader,
95
- );
96
- const bodies = nodes.filter(
97
- (node) => isValidElement(node) && node.type === PageBody,
98
- );
99
130
  if (
100
131
  headers.length !== 1 ||
101
132
  bodies.length !== 1 ||
@@ -106,86 +137,106 @@ export function Page({
106
137
  );
107
138
  }
108
139
  }
140
+ const headerVariant: PageHeaderVariant = shorthand
141
+ ? "default"
142
+ : ((headers[0] as ReactElement<PageHeaderProps> | undefined)?.props
143
+ .variant ?? "default");
144
+ const pageContext: PageContextValue = {
145
+ headerVariant,
146
+ containerClassName: className,
147
+ };
148
+
149
+ const content = shorthand ? (
150
+ <>
151
+ <PageHeader>
152
+ <PageTitle>{title}</PageTitle>
153
+ {description !== undefined && (
154
+ <PageDescription>{description}</PageDescription>
155
+ )}
156
+ {actions !== undefined && <PageActions>{actions}</PageActions>}
157
+ </PageHeader>
158
+ <PageBody>{children}</PageBody>
159
+ </>
160
+ ) : (
161
+ children
162
+ );
109
163
 
110
164
  return (
111
- <PageContext.Provider value>
112
- <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
- {shorthand ? (
115
- <>
116
- <PageHeader>
117
- <PageTitle>{title}</PageTitle>
118
- {count !== undefined && <PageMeta>{count}</PageMeta>}
119
- {description !== undefined && (
120
- <PageDescription>{description}</PageDescription>
165
+ <PageContext.Provider value={pageContext}>
166
+ <PageIntegralStateContext.Provider value={integralState}>
167
+ <PageStatePresenceProvider register={registerIntegralState}>
168
+ <main
169
+ data-slot="page"
170
+ className={cn(
171
+ "min-w-0 flex-1",
172
+ headerVariant === "bar" && "flex min-h-0 flex-col",
173
+ integralState && "flex min-h-full flex-col",
174
+ )}
175
+ {...props}
176
+ >
177
+ {headerVariant === "bar" ? (
178
+ content
179
+ ) : (
180
+ <div
181
+ className={cn(
182
+ "mx-auto max-w-7xl space-y-6 px-8 py-8",
183
+ integralState &&
184
+ "flex min-h-full w-full flex-1 flex-col space-y-0",
185
+ className,
121
186
  )}
122
- {actions !== undefined && <PageActions>{actions}</PageActions>}
123
- </PageHeader>
124
- <PageBody>{children}</PageBody>
125
- </>
126
- ) : (
127
- children
128
- )}
129
- </div>
130
- </main>
187
+ >
188
+ {content}
189
+ </div>
190
+ )}
191
+ </main>
192
+ </PageStatePresenceProvider>
193
+ </PageIntegralStateContext.Provider>
131
194
  </PageContext.Provider>
132
195
  );
133
196
  }
134
197
 
198
+ /** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
199
+ export interface PageHeaderProps extends HTMLAttributes<HTMLDivElement> {
200
+ /** `default` fica no container; `bar` cria uma faixa compacta no topo da Page. */
201
+ variant?: PageHeaderVariant;
202
+ }
203
+
135
204
  export function PageHeader({
205
+ variant = "default",
136
206
  className,
137
207
  children,
138
208
  ...props
139
- }: HTMLAttributes<HTMLDivElement>): ReactElement {
140
- 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
-
209
+ }: PageHeaderProps): ReactElement {
210
+ const page = useContext(PageContext);
211
+ const integralState = useContext(PageIntegralStateContext);
212
+ requireParent(page !== null, "PageHeader", "Page");
213
+ if (integralState) return <></>;
168
214
  return (
169
- <PageHeaderContext.Provider value>
170
- <div
171
- data-slot="page-header"
215
+ <PageHeaderContext.Provider value={variant}>
216
+ <SurfaceHeader
217
+ name="PageHeader"
218
+ slot="page"
219
+ slots={{
220
+ leading: [PageBack, PageNavigation],
221
+ title: PageTitle,
222
+ description: PageDescription,
223
+ actions: PageActions,
224
+ }}
225
+ leadingPlacement={variant === "bar" ? "inline" : "above"}
226
+ contentClassName={
227
+ variant === "bar"
228
+ ? cn("mx-auto w-full max-w-7xl px-8 py-2", page?.containerClassName)
229
+ : undefined
230
+ }
172
231
  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",
232
+ variant === "bar" &&
233
+ "shrink-0 border-b border-border bg-background text-foreground",
176
234
  className,
177
235
  )}
178
236
  {...props}
179
237
  >
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>
238
+ {children}
239
+ </SurfaceHeader>
189
240
  </PageHeaderContext.Provider>
190
241
  );
191
242
  }
@@ -194,11 +245,17 @@ export function PageTitle({
194
245
  className,
195
246
  ...props
196
247
  }: HTMLAttributes<HTMLHeadingElement>): ReactElement {
197
- requireParent(useContext(PageHeaderContext), "PageTitle", "PageHeader");
248
+ const variant = useContext(PageHeaderContext);
249
+ requireParent(variant !== null, "PageTitle", "PageHeader");
198
250
  return (
199
251
  <h1
200
252
  data-slot="page-title"
201
- className={cn("text-2xl font-semibold tracking-tight", className)}
253
+ className={cn(
254
+ variant === "bar"
255
+ ? "truncate text-sm font-semibold"
256
+ : surfaceHeaderClasses.page.title,
257
+ className,
258
+ )}
202
259
  {...props}
203
260
  />
204
261
  );
@@ -208,25 +265,17 @@ export function PageDescription({
208
265
  className,
209
266
  ...props
210
267
  }: HTMLAttributes<HTMLParagraphElement>): ReactElement {
211
- requireParent(useContext(PageHeaderContext), "PageDescription", "PageHeader");
268
+ const variant = useContext(PageHeaderContext);
269
+ requireParent(variant !== null, "PageDescription", "PageHeader");
212
270
  return (
213
271
  <p
214
272
  data-slot="page-description"
215
- className={cn("mt-1 truncate text-sm text-muted-foreground", className)}
216
- {...props}
217
- />
218
- );
219
- }
220
-
221
- export function PageMeta({
222
- className,
223
- ...props
224
- }: HTMLAttributes<HTMLSpanElement>): ReactElement {
225
- requireParent(useContext(PageHeaderContext), "PageMeta", "PageHeader");
226
- return (
227
- <span
228
- data-slot="page-meta"
229
- className={cn("font-mono text-base text-muted-foreground/60", className)}
273
+ className={cn(
274
+ variant === "bar"
275
+ ? "mt-0.5 truncate text-xs text-muted-foreground"
276
+ : surfaceHeaderClasses.page.description,
277
+ className,
278
+ )}
230
279
  {...props}
231
280
  />
232
281
  );
@@ -236,13 +285,16 @@ export function PageActions({
236
285
  className,
237
286
  ...props
238
287
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
239
- requireParent(useContext(PageHeaderContext), "PageActions", "PageHeader");
288
+ const variant = useContext(PageHeaderContext);
289
+ requireParent(variant !== null, "PageActions", "PageHeader");
240
290
  const target = useContext(PageActionsTargetContext);
241
291
  const actions = (
242
292
  <div
243
293
  data-slot="page-actions"
244
294
  className={cn(
245
- "flex w-full shrink-0 items-center gap-4 sm:w-auto",
295
+ variant === "bar"
296
+ ? "flex shrink-0 items-center gap-2"
297
+ : surfaceHeaderClasses.page.actions,
246
298
  className,
247
299
  )}
248
300
  {...props}
@@ -251,10 +303,89 @@ export function PageActions({
251
303
  return target === null ? actions : createPortal(actions, target);
252
304
  }
253
305
 
306
+ export interface PageBackProps extends Omit<ComponentProps<"a">, "children" | "href"> {
307
+ /** Nome do destino pai. Na barra, também alimenta tooltip e nome acessível. */
308
+ children: ReactNode;
309
+ /** Destino explícito. Obrigatório: sem `href` o elemento não é tabulável, não expõe
310
+ * `role="link"` e o navegador descarta o nome acessível — na barra, onde não há texto
311
+ * visível, o retorno simplesmente desapareceria para tecnologia assistiva. */
312
+ href: string;
313
+ }
314
+
315
+ /** Região introdutória para uma trilha estrutural com mais de um ancestral relevante. */
316
+ export function PageNavigation({
317
+ className,
318
+ ...props
319
+ }: HTMLAttributes<HTMLDivElement>): ReactElement {
320
+ requireParent(
321
+ useContext(PageHeaderContext) !== null,
322
+ "PageNavigation",
323
+ "PageHeader",
324
+ );
325
+ return (
326
+ <div
327
+ data-slot="page-navigation-content"
328
+ className={cn("min-w-0", className)}
329
+ {...props}
330
+ />
331
+ );
332
+ }
333
+
334
+ export function PageBack({
335
+ children,
336
+ className,
337
+ "aria-label": ariaLabel,
338
+ ...props
339
+ }: PageBackProps): ReactElement {
340
+ const variant = useContext(PageHeaderContext);
341
+ requireParent(variant !== null, "PageBack", "PageHeader");
342
+ const destination =
343
+ typeof children === "string" ? children : "a página anterior";
344
+ const accessibleLabel = ariaLabel ?? `Voltar para ${destination}`;
345
+ const link = (
346
+ <Button asChild variant="ghost" size={variant === "bar" ? "icon-sm" : "sm"}>
347
+ <a
348
+ data-slot="page-back"
349
+ aria-label={accessibleLabel}
350
+ className={className}
351
+ {...props}
352
+ >
353
+ <ArrowLeft aria-hidden="true" />
354
+ {variant === "default" && <span>{children}</span>}
355
+ </a>
356
+ </Button>
357
+ );
358
+
359
+ return variant === "bar" ? (
360
+ <TooltipProvider delayDuration={200}>
361
+ <Tooltip>
362
+ <TooltipTrigger asChild>{link}</TooltipTrigger>
363
+ <TooltipContent>{accessibleLabel}</TooltipContent>
364
+ </Tooltip>
365
+ </TooltipProvider>
366
+ ) : (
367
+ link
368
+ );
369
+ }
370
+
254
371
  export function PageBody({
255
372
  className,
256
373
  ...props
257
374
  }: HTMLAttributes<HTMLDivElement>): ReactElement {
258
- requireParent(useContext(PageContext), "PageBody", "Page");
259
- return <div data-slot="page-body" className={className} {...props} />;
375
+ const page = useContext(PageContext);
376
+ const integralState = useContext(PageIntegralStateContext);
377
+ requireParent(page !== null, "PageBody", "Page");
378
+ return (
379
+ <div
380
+ data-slot="page-body"
381
+ className={cn(
382
+ page?.headerVariant === "bar" &&
383
+ "mx-auto w-full max-w-7xl flex-1 px-8 py-8",
384
+ page?.headerVariant === "bar" && page.containerClassName,
385
+ integralState && "flex min-h-0 flex-1 flex-col",
386
+ className,
387
+ )}
388
+ {...props}
389
+ />
390
+ );
260
391
  }
@@ -0,0 +1,262 @@
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 preserva `role="alert"` e escolhe entre aviso inline e
6
+ * estado central; vazio compõe `Empty*`. Os patterns públicos escolhem o enquadramento (bloco,
7
+ * linha de tabela, 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 { RefreshCw, 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
+ import {
25
+ Tooltip,
26
+ TooltipContent,
27
+ TooltipProvider,
28
+ TooltipTrigger,
29
+ } from '../primitives/tooltip.tsx'
30
+
31
+ export const DEFAULT_EMPTY_MESSAGE = 'Nada por aqui.'
32
+ export const DEFAULT_ERROR_MESSAGE = 'Não foi possível carregar.'
33
+ export const DEFAULT_RETRY_LABEL = 'Tentar de novo'
34
+
35
+ export interface LoadingSurfaceProps {
36
+ /** Texto visível ao lado do Spinner; sem ele, só o contêiner nomeia a espera. */
37
+ title?: ReactNode
38
+ description?: ReactNode
39
+ /** Nível semântico do título quando a superfície representa uma região nomeada. */
40
+ titleLevel?: 1 | 2 | 3 | 4 | 5 | 6
41
+ className?: string
42
+ }
43
+
44
+ /** O contêiner é o único `role="status"`; o Spinner dentro dele é decorativo. */
45
+ export function LoadingSurface({
46
+ title,
47
+ description,
48
+ titleLevel,
49
+ className,
50
+ }: LoadingSurfaceProps): ReactElement {
51
+ return (
52
+ <div
53
+ role="status"
54
+ aria-live="polite"
55
+ aria-label={title === undefined ? 'Carregando' : undefined}
56
+ className={cn(
57
+ 'flex flex-col items-center justify-center gap-3 text-center text-muted-foreground',
58
+ className,
59
+ )}
60
+ >
61
+ <Spinner />
62
+ {(title !== undefined || description !== undefined) && (
63
+ <div className="max-w-md space-y-1">
64
+ {title !== undefined && (
65
+ <p
66
+ data-slot="state-title"
67
+ role={titleLevel === undefined ? undefined : 'heading'}
68
+ aria-level={titleLevel}
69
+ className="text-sm font-medium text-foreground"
70
+ >
71
+ {title}
72
+ </p>
73
+ )}
74
+ {description !== undefined && (
75
+ <p
76
+ data-slot="state-description"
77
+ className="text-sm text-muted-foreground"
78
+ >
79
+ {description}
80
+ </p>
81
+ )}
82
+ </div>
83
+ )}
84
+ </div>
85
+ )
86
+ }
87
+
88
+ export interface ErrorSurfaceProps {
89
+ /** A frase segura para a pessoa; o texto técnico fica no console e no `cause`. */
90
+ title: ReactNode
91
+ description?: ReactNode
92
+ icon?: ReactNode
93
+ /** Recuperação: com `onRetry`, uma ação somente com ícone entra nas ações do Alert. */
94
+ onRetry?: () => Promise<void> | void
95
+ retryLabel?: string
96
+ /** Outras ações do estado, ao lado do botão de recuperação. */
97
+ children?: ReactNode
98
+ /** `alert` preserva o aviso inline; `state` usa a composição central dos estados integrais. */
99
+ presentation?: 'alert' | 'state'
100
+ /** Nível semântico do título na apresentação de estado. */
101
+ titleLevel?: 1 | 2 | 3 | 4 | 5 | 6
102
+ className?: string
103
+ }
104
+
105
+ export function ErrorSurface({
106
+ title,
107
+ description,
108
+ icon,
109
+ onRetry,
110
+ retryLabel = DEFAULT_RETRY_LABEL,
111
+ children,
112
+ presentation = 'alert',
113
+ titleLevel,
114
+ className,
115
+ }: ErrorSurfaceProps): ReactElement {
116
+ if (presentation === 'alert') {
117
+ return (
118
+ <Alert
119
+ context="danger"
120
+ icon={icon ?? <TriangleAlert />}
121
+ title={title}
122
+ description={description}
123
+ className={className}
124
+ >
125
+ {onRetry !== undefined && (
126
+ <TooltipProvider delayDuration={200}>
127
+ <Tooltip>
128
+ <TooltipTrigger asChild>
129
+ <Button
130
+ context="neutral"
131
+ variant="ghost"
132
+ size="icon"
133
+ aria-label={retryLabel}
134
+ onClick={() => void onRetry()}
135
+ >
136
+ <RefreshCw />
137
+ </Button>
138
+ </TooltipTrigger>
139
+ <TooltipContent>{retryLabel}</TooltipContent>
140
+ </Tooltip>
141
+ </TooltipProvider>
142
+ )}
143
+ {children}
144
+ </Alert>
145
+ )
146
+ }
147
+
148
+ return (
149
+ <div
150
+ data-slot="error-state"
151
+ role="alert"
152
+ className={cn(
153
+ 'flex min-w-0 flex-col items-center justify-center gap-6 text-center text-balance',
154
+ className,
155
+ )}
156
+ >
157
+ <div
158
+ data-slot="state-header"
159
+ className="flex max-w-sm flex-col items-center gap-2 text-center"
160
+ >
161
+ <div
162
+ data-slot="state-icon"
163
+ aria-hidden="true"
164
+ className="mb-2 flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-context-danger-emphasis [&_svg]:size-6 [&_svg]:shrink-0"
165
+ >
166
+ {icon ?? <TriangleAlert />}
167
+ </div>
168
+ <div
169
+ data-slot="state-title"
170
+ role={titleLevel === undefined ? undefined : 'heading'}
171
+ aria-level={titleLevel}
172
+ className="text-lg font-medium tracking-tight"
173
+ >
174
+ {title}
175
+ </div>
176
+ {description !== undefined && (
177
+ <div
178
+ data-slot="state-description"
179
+ className="text-sm/relaxed text-muted-foreground"
180
+ >
181
+ {description}
182
+ </div>
183
+ )}
184
+ </div>
185
+ {(onRetry !== undefined ||
186
+ (children !== undefined && children !== null)) && (
187
+ <div
188
+ data-slot="state-actions"
189
+ className="flex w-full max-w-sm items-center justify-center gap-2 text-sm"
190
+ >
191
+ {onRetry !== undefined && (
192
+ <Button
193
+ context="neutral"
194
+ variant="outline"
195
+ onClick={() => void onRetry()}
196
+ >
197
+ {retryLabel}
198
+ </Button>
199
+ )}
200
+ {children}
201
+ </div>
202
+ )}
203
+ </div>
204
+ )
205
+ }
206
+
207
+ export interface EmptySurfaceProps {
208
+ /** Situação reconhecível: a frase curta do vazio. */
209
+ title: ReactNode
210
+ description?: ReactNode
211
+ icon?: ReactNode
212
+ action?: ReactNode
213
+ /**
214
+ * `region`: a moldura tracejada de Empty (área disponível para criar ou vincular).
215
+ * `structural`: a moldura sólida de uma coleção sem registros; `bare`: sem moldura, quando a
216
+ * estrutura ao redor (tabela) já a fornece.
217
+ */
218
+ frame?: 'region' | 'structural' | 'bare'
219
+ /** Título como frase discreta (seção) em vez do título grande (página). */
220
+ compact?: boolean
221
+ /** Nível semântico do título quando a superfície representa uma região nomeada. */
222
+ titleLevel?: 1 | 2 | 3 | 4 | 5 | 6
223
+ className?: string
224
+ }
225
+
226
+ export function EmptySurface({
227
+ title,
228
+ description,
229
+ icon,
230
+ action,
231
+ frame = 'region',
232
+ compact = false,
233
+ titleLevel,
234
+ className,
235
+ }: EmptySurfaceProps): ReactElement {
236
+ return (
237
+ <Empty
238
+ data-frame={frame}
239
+ className={cn(
240
+ frame === 'structural' && 'border-solid',
241
+ frame === 'bare' && 'border-0 p-0 md:p-0',
242
+ compact && 'gap-4 p-6 md:p-6',
243
+ className,
244
+ )}
245
+ >
246
+ <EmptyHeader>
247
+ {icon !== undefined && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
248
+ <EmptyTitle
249
+ role={titleLevel === undefined ? undefined : 'heading'}
250
+ aria-level={titleLevel}
251
+ className={cn(compact && 'text-sm font-normal text-muted-foreground')}
252
+ >
253
+ {title}
254
+ </EmptyTitle>
255
+ {description !== undefined && (
256
+ <EmptyDescription>{description}</EmptyDescription>
257
+ )}
258
+ </EmptyHeader>
259
+ {action !== undefined && <EmptyActions>{action}</EmptyActions>}
260
+ </Empty>
261
+ )
262
+ }