@softize/opus 13.0.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -0
- package/PROMOTED.md +46 -0
- package/README.md +28 -19
- package/bin/cli.mjs +87 -216
- package/bin/lib/cli-shared.mjs +131 -0
- package/bin/lib/copy.mjs +276 -6
- package/bin/lib/db.mjs +16 -74
- package/bin/lib/gen-openapi.mjs +3 -3
- package/bin/lib/gen-runner.mjs +1 -1
- package/bin/lib/gen.mjs +14 -69
- package/bin/lib/mcp.mjs +3 -1
- package/bin/lib/seed.mjs +5 -62
- package/docs/code-style.md +4 -1
- package/docs/ownership-vs-shadcn-lock.md +2 -3
- package/docs/protocol.md +7 -7
- package/docs/releasing.md +8 -2
- package/package.json +7 -3
- package/registry/instructions/opus.md +3 -3
- package/registry/templates/app/package.json +1 -1
- package/registry/templates/app/src/App.tsx +11 -6
- package/registry/templates/app/src/main.tsx +4 -4
- package/src/audit/drivers/console.ts +1 -0
- package/src/auth/drivers/better-auth.ts +1 -0
- package/src/auth/drivers/jwt.ts +1 -0
- package/src/cache/drivers/memory.ts +1 -0
- package/src/client/drivers/fetch.ts +2 -1
- package/src/core/actions.ts +6 -1
- package/src/core/audit.ts +9 -3
- package/src/core/contracts.ts +7 -0
- package/src/core/domain.ts +1 -1
- package/src/core/errors.ts +18 -15
- package/src/core/index.ts +4 -2
- package/src/core/package-version.ts +26 -0
- package/src/core/reactions.ts +1 -1
- package/src/core/runtime.ts +33 -23
- package/src/core/schedules.ts +1 -1
- package/src/core/types.ts +5 -6
- package/src/dsl/eval.ts +2 -2
- package/src/dsl/kysely.ts +2 -2
- package/src/dsl/loads.ts +1 -1
- package/src/dsl/parser.ts +5 -5
- package/src/events/drivers/mitt.ts +1 -0
- package/src/mcp/index.ts +2 -1
- package/src/observability/drivers/opentelemetry.ts +1 -0
- package/src/queue/drivers/bullmq.ts +3 -3
- package/src/scheduler/drivers/node-cron.ts +3 -2
- package/src/scheduler/every.ts +7 -7
- package/src/schema/openapi.ts +3 -3
- package/src/seed/index.ts +29 -0
- package/src/server/drivers/fastify.ts +5 -2
- package/src/server/drivers/node.ts +9 -6
- package/src/server/index.ts +3 -1
- package/src/storage/drivers/fs.ts +1 -0
- package/src/testing/index.ts +3 -3
- 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 +44 -137
- package/src/ui/components/patterns/data-state.tsx +42 -68
- package/src/ui/components/patterns/dock.tsx +20 -3
- package/src/ui/components/patterns/form.tsx +26 -22
- package/src/ui/components/patterns/list.tsx +18 -12
- package/src/ui/components/patterns/page-state.tsx +39 -51
- package/src/ui/components/patterns/page.tsx +37 -54
- package/src/ui/components/patterns/sidebar.tsx +17 -6
- package/src/ui/components/patterns/state-surface.tsx +148 -0
- package/src/ui/components/patterns/surface-header.tsx +119 -0
- package/src/ui/components/patterns/trigger.tsx +21 -25
- package/src/ui/components/patterns/view.tsx +29 -22
- package/src/ui/components/primitives/alert.tsx +1 -27
- package/src/ui/components/primitives/ask.tsx +3 -3
- package/src/ui/components/primitives/avatar.tsx +15 -5
- package/src/ui/components/primitives/badge.tsx +5 -41
- package/src/ui/components/primitives/breadcrumb.tsx +2 -2
- package/src/ui/components/primitives/button.tsx +39 -30
- package/src/ui/components/primitives/calendar.tsx +28 -2
- package/src/ui/components/primitives/carousel.tsx +3 -3
- package/src/ui/components/primitives/chat.tsx +1 -1
- package/src/ui/components/primitives/checkbox.tsx +1 -1
- package/src/ui/components/primitives/command.tsx +2 -2
- package/src/ui/components/primitives/control.ts +72 -0
- package/src/ui/components/primitives/copyable.tsx +1 -1
- package/src/ui/components/primitives/dialog.tsx +12 -7
- package/src/ui/components/primitives/dot.tsx +1 -25
- package/src/ui/components/primitives/drawer.tsx +10 -3
- package/src/ui/components/primitives/field.tsx +3 -3
- package/src/ui/components/primitives/icon-picker.tsx +3 -1
- package/src/ui/components/primitives/input-group.tsx +12 -9
- package/src/ui/components/primitives/input-otp.tsx +1 -1
- package/src/ui/components/primitives/input.tsx +2 -2
- 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/progress.tsx +32 -3
- package/src/ui/components/primitives/radio-group.tsx +1 -1
- package/src/ui/components/primitives/resizable.tsx +3 -1
- package/src/ui/components/primitives/select.tsx +6 -6
- package/src/ui/components/primitives/slider.tsx +5 -1
- package/src/ui/components/primitives/sonner.tsx +3 -0
- package/src/ui/components/primitives/spinner.tsx +13 -16
- package/src/ui/components/primitives/switch.tsx +5 -1
- package/src/ui/components/primitives/tabs.tsx +6 -3
- package/src/ui/components/primitives/textarea.tsx +1 -1
- package/src/ui/components/primitives/toggle.tsx +9 -4
- package/src/ui/components/primitives/tooltip.tsx +1 -0
- package/src/ui/docs/changelog.tsx +1 -1
- package/src/ui/docs/content/action-form.md +36 -3
- package/src/ui/docs/content/action-list-dialog.md +2 -2
- package/src/ui/docs/content/action-list.md +13 -2
- package/src/ui/docs/content/action-trigger.md +12 -5
- package/src/ui/docs/content/action-view.md +11 -3
- package/src/ui/docs/content/ask.md +11 -0
- package/src/ui/docs/content/avatar.md +7 -3
- package/src/ui/docs/content/button.md +30 -14
- package/src/ui/docs/content/calendar.md +13 -0
- package/src/ui/docs/content/card.md +26 -0
- package/src/ui/docs/content/chat.md +20 -0
- package/src/ui/docs/content/cli.md +71 -19
- package/src/ui/docs/content/communication.md +36 -0
- package/src/ui/docs/content/composer.md +15 -0
- package/src/ui/docs/content/content.md +17 -1
- package/src/ui/docs/content/copyable.md +8 -0
- package/src/ui/docs/content/data-state.md +17 -13
- package/src/ui/docs/content/detail.md +19 -1
- package/src/ui/docs/content/dialog.md +1 -4
- package/src/ui/docs/content/dictionary-value.md +9 -2
- package/src/ui/docs/content/dock.md +8 -0
- package/src/ui/docs/content/dot.md +8 -0
- package/src/ui/docs/content/empty.md +2 -2
- package/src/ui/docs/content/getting-started.md +2 -2
- package/src/ui/docs/content/icon-picker.md +11 -0
- package/src/ui/docs/content/input.md +1 -1
- package/src/ui/docs/content/item.md +1 -1
- package/src/ui/docs/content/label.md +7 -0
- package/src/ui/docs/content/menu.md +6 -0
- package/src/ui/docs/content/metric-card.md +13 -0
- package/src/ui/docs/content/page.md +20 -4
- package/src/ui/docs/content/pagination.md +11 -9
- package/src/ui/docs/content/popover.md +6 -0
- package/src/ui/docs/content/progress.md +8 -11
- package/src/ui/docs/content/select.md +5 -5
- package/src/ui/docs/content/semantic-context.md +5 -4
- package/src/ui/docs/content/sidebar.md +13 -47
- package/src/ui/docs/content/skeleton.md +6 -0
- package/src/ui/docs/content/spinner.md +9 -6
- package/src/ui/docs/content/split.md +21 -0
- package/src/ui/docs/content/switch.md +1 -1
- package/src/ui/docs/content/tabs.md +1 -1
- package/src/ui/docs/content/textarea.md +7 -0
- package/src/ui/docs/content/toggle.md +1 -1
- package/src/ui/docs/content/tokens.md +4 -4
- package/src/ui/docs/content/truncate.md +8 -0
- package/src/ui/docs/content/ui.md +14 -0
- package/src/ui/docs/doc-client.tsx +5 -5
- package/src/ui/docs/doc.tsx +26 -14
- package/src/ui/docs/registry.tsx +5 -5
- package/src/ui/docs/standalone.tsx +2 -2
- package/src/ui/drivers/react.tsx +12 -12
- package/src/ui/lib/action-errors.ts +45 -0
- package/src/ui/lib/zod-pt-br.ts +31 -4
- package/src/ui/meta.ts +8 -8
- package/src/ui/react.tsx +10 -16
- package/src/ui/theme.css +10 -8
- package/src/vite/design.ts +6 -18
- package/src/ui/components/patterns/shell-nav.tsx +0 -147
|
@@ -309,8 +309,12 @@ export interface ActionListProps<TInput, TItem> {
|
|
|
309
309
|
/** Itens por página (o pattern manda `limit` e `page` no input; o handler implementa
|
|
310
310
|
* o OFFSET e devolve `total`). O rodapé mostra o total e o pager quando transborda. */
|
|
311
311
|
pageSize?: number;
|
|
312
|
-
/** Mensagem
|
|
312
|
+
/** Mensagem do estado vazio. Default: "Nenhum resultado." */
|
|
313
313
|
emptyMessage?: string;
|
|
314
|
+
/** Mensagem do estado de erro (calma, sem o texto técnico). Default do DataState. */
|
|
315
|
+
errorMessage?: string;
|
|
316
|
+
/** Rótulo do botão de recuperação no erro. Default: "Tentar de novo". */
|
|
317
|
+
retryLabel?: string;
|
|
314
318
|
/** Sobrepõe o vazio derivado (items.length === 0) — ex.: form inline aberto conta
|
|
315
319
|
* como conteúdo, então os children devem renderizar mesmo com a lista vazia. */
|
|
316
320
|
empty?: (items: TItem[]) => boolean;
|
|
@@ -754,7 +758,7 @@ function PeriodControl({
|
|
|
754
758
|
<Button
|
|
755
759
|
key={p.value}
|
|
756
760
|
size="sm"
|
|
757
|
-
context={period === p.value ? "
|
|
761
|
+
context={period === p.value ? "primary" : "neutral"}
|
|
758
762
|
variant={period === p.value ? "solid" : "ghost"}
|
|
759
763
|
className="justify-start"
|
|
760
764
|
onClick={() => {
|
|
@@ -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,
|
|
@@ -1428,6 +1434,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1428
1434
|
<PopoverTrigger asChild>
|
|
1429
1435
|
<Button
|
|
1430
1436
|
variant="outline"
|
|
1437
|
+
size="icon"
|
|
1431
1438
|
className="shrink-0"
|
|
1432
1439
|
aria-label="Exibição"
|
|
1433
1440
|
>
|
|
@@ -1628,7 +1635,9 @@ export function ActionList<TInput, TItem>({
|
|
|
1628
1635
|
loading={busy && items.length === 0 && !(empty !== undefined && !isEmpty)}
|
|
1629
1636
|
error={isError ? error : null}
|
|
1630
1637
|
empty={isEmpty}
|
|
1631
|
-
|
|
1638
|
+
emptyMessage={emptyMessage}
|
|
1639
|
+
{...(errorMessage !== undefined ? { errorMessage } : {})}
|
|
1640
|
+
{...(retryLabel !== undefined ? { retryLabel } : {})}
|
|
1632
1641
|
onRetry={() => refetch()}
|
|
1633
1642
|
>
|
|
1634
1643
|
{content}
|
|
@@ -1696,15 +1705,14 @@ export function ActionList<TInput, TItem>({
|
|
|
1696
1705
|
Página {state.page} de {pages}
|
|
1697
1706
|
</span>
|
|
1698
1707
|
{pages > 1 && (
|
|
1699
|
-
// A primitiva pública na escala densa do rodapé: setas
|
|
1708
|
+
// A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
|
|
1700
1709
|
// números `h-7 min-w-7` que crescem com os dígitos (5726 e 5727 não se colam).
|
|
1701
1710
|
<Pagination className="mx-0 w-auto">
|
|
1702
1711
|
<PaginationContent className="gap-0.5">
|
|
1703
1712
|
<PaginationItem>
|
|
1704
1713
|
<PaginationPrevious
|
|
1705
1714
|
iconOnly
|
|
1706
|
-
|
|
1707
|
-
iconClassName="size-3.5"
|
|
1715
|
+
size="icon-sm"
|
|
1708
1716
|
disabled={state.page <= 1}
|
|
1709
1717
|
onClick={() => goTo(state.page - 1)}
|
|
1710
1718
|
/>
|
|
@@ -1713,7 +1721,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1713
1721
|
n === "…" ? (
|
|
1714
1722
|
// eslint-disable-next-line react/no-array-index-key
|
|
1715
1723
|
<PaginationItem key={`e${i}`}>
|
|
1716
|
-
<PaginationEllipsis
|
|
1724
|
+
<PaginationEllipsis size="icon-sm" />
|
|
1717
1725
|
</PaginationItem>
|
|
1718
1726
|
) : (
|
|
1719
1727
|
<PaginationItem key={n}>
|
|
@@ -1729,8 +1737,7 @@ export function ActionList<TInput, TItem>({
|
|
|
1729
1737
|
<PaginationItem>
|
|
1730
1738
|
<PaginationNext
|
|
1731
1739
|
iconOnly
|
|
1732
|
-
|
|
1733
|
-
iconClassName="size-3.5"
|
|
1740
|
+
size="icon-sm"
|
|
1734
1741
|
disabled={state.page >= pages}
|
|
1735
1742
|
onClick={() => goTo(state.page + 1)}
|
|
1736
1743
|
/>
|
|
@@ -1790,9 +1797,8 @@ export function ActionList<TInput, TItem>({
|
|
|
1790
1797
|
<DialogClose asChild>
|
|
1791
1798
|
<Button
|
|
1792
1799
|
size="sm"
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
}
|
|
1800
|
+
context={confirming.destructive === true ? "danger" : "primary"}
|
|
1801
|
+
variant="solid"
|
|
1796
1802
|
onClick={() => {
|
|
1797
1803
|
const a = confirming;
|
|
1798
1804
|
setConfirming(null);
|
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
<
|
|
78
|
+
<ErrorSurface
|
|
89
79
|
className="max-w-xl"
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
}
|
|
@@ -14,10 +14,27 @@ import {
|
|
|
14
14
|
type ReactElement,
|
|
15
15
|
type ReactNode,
|
|
16
16
|
} from "react";
|
|
17
|
+
import { createPortal } from "react-dom";
|
|
17
18
|
import { cn } from "../../lib/cn.ts";
|
|
19
|
+
import { SurfaceHeader, surfaceHeaderClasses } from "./surface-header.tsx";
|
|
18
20
|
|
|
19
21
|
const PageContext = createContext(false);
|
|
20
22
|
const PageHeaderContext = createContext(false);
|
|
23
|
+
const PageActionsTargetContext = createContext<HTMLElement | null>(null);
|
|
24
|
+
|
|
25
|
+
export function PageActionsTarget({
|
|
26
|
+
target,
|
|
27
|
+
children,
|
|
28
|
+
}: {
|
|
29
|
+
target: HTMLElement | null;
|
|
30
|
+
children: ReactNode;
|
|
31
|
+
}): ReactElement {
|
|
32
|
+
return (
|
|
33
|
+
<PageActionsTargetContext.Provider value={target}>
|
|
34
|
+
{children}
|
|
35
|
+
</PageActionsTargetContext.Provider>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
21
38
|
|
|
22
39
|
function requireParent(
|
|
23
40
|
inside: boolean,
|
|
@@ -94,7 +111,7 @@ export function Page({
|
|
|
94
111
|
return (
|
|
95
112
|
<PageContext.Provider value>
|
|
96
113
|
<main data-slot="page" className="min-w-0 flex-1" {...props}>
|
|
97
|
-
<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)}>
|
|
98
115
|
{shorthand ? (
|
|
99
116
|
<>
|
|
100
117
|
<PageHeader>
|
|
@@ -116,60 +133,27 @@ export function Page({
|
|
|
116
133
|
);
|
|
117
134
|
}
|
|
118
135
|
|
|
136
|
+
/** A anatomia é a de `SurfaceHeader`, a mesma de `ContentHeader`; só os slots mudam de nome. */
|
|
119
137
|
export function PageHeader({
|
|
120
|
-
className,
|
|
121
138
|
children,
|
|
122
139
|
...props
|
|
123
140
|
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
124
141
|
requireParent(useContext(PageContext), "PageHeader", "Page");
|
|
125
|
-
const nodes = Children.toArray(children);
|
|
126
|
-
const titles = nodes.filter(
|
|
127
|
-
(node) => isValidElement(node) && node.type === PageTitle,
|
|
128
|
-
);
|
|
129
|
-
const metas = nodes.filter(
|
|
130
|
-
(node) => isValidElement(node) && node.type === PageMeta,
|
|
131
|
-
);
|
|
132
|
-
const descriptions = nodes.filter(
|
|
133
|
-
(node) => isValidElement(node) && node.type === PageDescription,
|
|
134
|
-
);
|
|
135
|
-
const actionSlots = nodes.filter(
|
|
136
|
-
(node) => isValidElement(node) && node.type === PageActions,
|
|
137
|
-
);
|
|
138
|
-
const recognized =
|
|
139
|
-
titles.length + metas.length + descriptions.length + actionSlots.length;
|
|
140
|
-
if (
|
|
141
|
-
titles.length !== 1 ||
|
|
142
|
-
metas.length > 1 ||
|
|
143
|
-
descriptions.length > 1 ||
|
|
144
|
-
actionSlots.length > 1 ||
|
|
145
|
-
recognized !== nodes.length
|
|
146
|
-
) {
|
|
147
|
-
throw new Error(
|
|
148
|
-
"PageHeader exige um PageTitle e aceita no máximo um PageDescription, PageMeta e PageActions como filhos diretos.",
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
142
|
return (
|
|
153
143
|
<PageHeaderContext.Provider value>
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
144
|
+
<SurfaceHeader
|
|
145
|
+
name="PageHeader"
|
|
146
|
+
slot="page"
|
|
147
|
+
slots={{
|
|
148
|
+
title: PageTitle,
|
|
149
|
+
count: PageMeta,
|
|
150
|
+
description: PageDescription,
|
|
151
|
+
actions: PageActions,
|
|
152
|
+
}}
|
|
162
153
|
{...props}
|
|
163
154
|
>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
{titles}
|
|
167
|
-
{metas}
|
|
168
|
-
</div>
|
|
169
|
-
{descriptions}
|
|
170
|
-
</div>
|
|
171
|
-
{actionSlots}
|
|
172
|
-
</div>
|
|
155
|
+
{children}
|
|
156
|
+
</SurfaceHeader>
|
|
173
157
|
</PageHeaderContext.Provider>
|
|
174
158
|
);
|
|
175
159
|
}
|
|
@@ -182,7 +166,7 @@ export function PageTitle({
|
|
|
182
166
|
return (
|
|
183
167
|
<h1
|
|
184
168
|
data-slot="page-title"
|
|
185
|
-
className={cn(
|
|
169
|
+
className={cn(surfaceHeaderClasses.page.title, className)}
|
|
186
170
|
{...props}
|
|
187
171
|
/>
|
|
188
172
|
);
|
|
@@ -196,7 +180,7 @@ export function PageDescription({
|
|
|
196
180
|
return (
|
|
197
181
|
<p
|
|
198
182
|
data-slot="page-description"
|
|
199
|
-
className={cn(
|
|
183
|
+
className={cn(surfaceHeaderClasses.page.description, className)}
|
|
200
184
|
{...props}
|
|
201
185
|
/>
|
|
202
186
|
);
|
|
@@ -210,7 +194,7 @@ export function PageMeta({
|
|
|
210
194
|
return (
|
|
211
195
|
<span
|
|
212
196
|
data-slot="page-meta"
|
|
213
|
-
className={cn(
|
|
197
|
+
className={cn(surfaceHeaderClasses.page.count, className)}
|
|
214
198
|
{...props}
|
|
215
199
|
/>
|
|
216
200
|
);
|
|
@@ -221,16 +205,15 @@ export function PageActions({
|
|
|
221
205
|
...props
|
|
222
206
|
}: HTMLAttributes<HTMLDivElement>): ReactElement {
|
|
223
207
|
requireParent(useContext(PageHeaderContext), "PageActions", "PageHeader");
|
|
224
|
-
|
|
208
|
+
const target = useContext(PageActionsTargetContext);
|
|
209
|
+
const actions = (
|
|
225
210
|
<div
|
|
226
211
|
data-slot="page-actions"
|
|
227
|
-
className={cn(
|
|
228
|
-
"flex w-full shrink-0 items-center gap-4 sm:w-auto",
|
|
229
|
-
className,
|
|
230
|
-
)}
|
|
212
|
+
className={cn(surfaceHeaderClasses.page.actions, className)}
|
|
231
213
|
{...props}
|
|
232
214
|
/>
|
|
233
215
|
);
|
|
216
|
+
return target === null ? actions : createPortal(actions, target);
|
|
234
217
|
}
|
|
235
218
|
|
|
236
219
|
export function PageBody({
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import { ChevronRight } from "lucide-react";
|
|
8
8
|
import { useOverflowing } from "../../lib/overflow.ts";
|
|
9
9
|
import { cn } from "../../lib/cn.ts";
|
|
10
|
+
import { focusRing } from "../primitives/control.ts";
|
|
10
11
|
import {
|
|
11
12
|
Tooltip,
|
|
12
13
|
TooltipContent,
|
|
@@ -177,17 +178,24 @@ export function SidebarItem({
|
|
|
177
178
|
<button
|
|
178
179
|
type="button"
|
|
179
180
|
disabled={disabled}
|
|
181
|
+
aria-label={label}
|
|
180
182
|
aria-current={active ? "page" : undefined}
|
|
181
183
|
onClick={onClick}
|
|
182
184
|
className={cn(
|
|
183
185
|
"mx-auto grid size-9 place-items-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-40",
|
|
184
|
-
"outline-none
|
|
186
|
+
"outline-none",
|
|
187
|
+
focusRing,
|
|
185
188
|
active
|
|
186
189
|
? "bg-muted font-medium text-foreground"
|
|
187
190
|
: "text-foreground/80 hover:bg-muted/60",
|
|
188
191
|
)}
|
|
189
192
|
>
|
|
190
|
-
|
|
193
|
+
<span
|
|
194
|
+
data-slot="sidebar-item-icon"
|
|
195
|
+
className="grid size-4 shrink-0 place-items-center [&_svg]:size-4"
|
|
196
|
+
>
|
|
197
|
+
{icon}
|
|
198
|
+
</span>
|
|
191
199
|
</button>
|
|
192
200
|
);
|
|
193
201
|
// `className` vai no MESMO elemento nos dois modos — a linha —, senão a prop mudaria de alvo
|
|
@@ -235,8 +243,9 @@ export function SidebarItem({
|
|
|
235
243
|
{/* Sem ícone, o chevron não se esconde: ele é o único ocupante do lugar. */}
|
|
236
244
|
{icon !== undefined && (
|
|
237
245
|
<span
|
|
246
|
+
data-slot="sidebar-item-icon"
|
|
238
247
|
className={cn(
|
|
239
|
-
"absolute inset-0 flex items-center justify-center transition-opacity",
|
|
248
|
+
"absolute inset-0 flex items-center justify-center transition-opacity [&_svg]:size-4",
|
|
240
249
|
onToggle !== undefined &&
|
|
241
250
|
"group-hover/sidebar-item:opacity-0 group-has-[[data-slot=sidebar-item-toggle]:focus-visible]/sidebar-item:opacity-0",
|
|
242
251
|
)}
|
|
@@ -252,7 +261,8 @@ export function SidebarItem({
|
|
|
252
261
|
aria-label={expanded ? `Fechar ${label}` : `Abrir ${label}`}
|
|
253
262
|
aria-expanded={expanded}
|
|
254
263
|
className={cn(
|
|
255
|
-
"absolute inset-0 grid place-items-center rounded-sm text-muted-foreground transition-[color,opacity] outline-none hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100
|
|
264
|
+
"absolute inset-0 grid place-items-center rounded-sm text-muted-foreground transition-[color,opacity] outline-none hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100",
|
|
265
|
+
focusRing,
|
|
256
266
|
icon === undefined
|
|
257
267
|
? "pointer-events-auto opacity-100"
|
|
258
268
|
: "pointer-events-none opacity-0 group-hover/sidebar-item:pointer-events-auto group-hover/sidebar-item:opacity-100",
|
|
@@ -275,7 +285,8 @@ export function SidebarItem({
|
|
|
275
285
|
onClick={onClick}
|
|
276
286
|
className={cn(
|
|
277
287
|
"flex min-w-0 flex-1 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm disabled:pointer-events-none",
|
|
278
|
-
"outline-none
|
|
288
|
+
"outline-none",
|
|
289
|
+
focusRing,
|
|
279
290
|
disabled
|
|
280
291
|
? "text-muted-foreground/50"
|
|
281
292
|
: active
|
|
@@ -349,7 +360,7 @@ export function SidebarGroupLabel({
|
|
|
349
360
|
<div
|
|
350
361
|
data-slot="sidebar-group-label"
|
|
351
362
|
className={cn(
|
|
352
|
-
"px-2.5
|
|
363
|
+
"px-2.5 py-1 text-sm font-medium text-muted-foreground/70",
|
|
353
364
|
className,
|
|
354
365
|
)}
|
|
355
366
|
>
|
|
@@ -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
|
+
}
|