@softize/opus 14.0.0 → 15.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/bin/cli.mjs +2 -0
- package/bin/lib/check.mjs +33 -5
- package/bin/lib/cli-shared.mjs +30 -1
- package/bin/lib/copy.mjs +3 -0
- package/bin/lib/db.mjs +2 -0
- package/docs/adr/0004-page-content-state-is-composed.md +39 -5
- package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
- package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
- package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
- package/docs/data-layer.md +9 -0
- package/package.json +1 -1
- package/registry/skills/build-opus-ui/SKILL.md +3 -2
- package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
- package/src/ui/components/patterns/list.tsx +41 -26
- package/src/ui/components/patterns/page-state.tsx +48 -6
- package/src/ui/components/patterns/page.tsx +221 -55
- package/src/ui/components/patterns/state-surface.tsx +137 -23
- package/src/ui/components/patterns/surface-header.tsx +102 -17
- package/src/ui/components/patterns/trigger.tsx +7 -6
- package/src/ui/components/primitives/button-group.tsx +34 -8
- package/src/ui/components/primitives/control.ts +12 -3
- package/src/ui/docs/content/action-list-dialog.md +1 -1
- package/src/ui/docs/content/action-list.md +34 -3
- package/src/ui/docs/content/action-trigger.md +4 -3
- package/src/ui/docs/content/alert.md +16 -4
- package/src/ui/docs/content/button.md +20 -5
- package/src/ui/docs/content/content.md +3 -3
- package/src/ui/docs/content/data-state.md +4 -4
- package/src/ui/docs/content/page.md +159 -44
- package/src/ui/meta.ts +6 -6
- package/src/ui/react.tsx +8 -2
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
* Superfícies de estado compartilhadas por DataState, PageState e ActionView (interno).
|
|
3
3
|
*
|
|
4
4
|
* Uma composição só para cada estado: carregar é um contêiner `role="status"` com o Spinner
|
|
5
|
-
* (decorativo — o status é um só); erro
|
|
6
|
-
* vazio compõe `Empty*`. Os patterns públicos
|
|
7
|
-
* página inteira) e os textos. Não exportado no barrel: é anatomia, não API.
|
|
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
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ReactElement, ReactNode } from 'react'
|
|
11
|
-
import { TriangleAlert } from 'lucide-react'
|
|
11
|
+
import { RefreshCw, TriangleAlert } from 'lucide-react'
|
|
12
12
|
import { cn } from '../../lib/cn.ts'
|
|
13
13
|
import { Alert } from '../primitives/alert.tsx'
|
|
14
14
|
import { Button } from '../primitives/button.tsx'
|
|
@@ -21,6 +21,12 @@ import {
|
|
|
21
21
|
EmptyTitle,
|
|
22
22
|
} from '../primitives/empty.tsx'
|
|
23
23
|
import { Spinner } from '../primitives/spinner.tsx'
|
|
24
|
+
import {
|
|
25
|
+
Tooltip,
|
|
26
|
+
TooltipContent,
|
|
27
|
+
TooltipProvider,
|
|
28
|
+
TooltipTrigger,
|
|
29
|
+
} from '../primitives/tooltip.tsx'
|
|
24
30
|
|
|
25
31
|
export const DEFAULT_EMPTY_MESSAGE = 'Nada por aqui.'
|
|
26
32
|
export const DEFAULT_ERROR_MESSAGE = 'Não foi possível carregar.'
|
|
@@ -30,28 +36,46 @@ export interface LoadingSurfaceProps {
|
|
|
30
36
|
/** Texto visível ao lado do Spinner; sem ele, só o contêiner nomeia a espera. */
|
|
31
37
|
title?: ReactNode
|
|
32
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
|
|
33
41
|
className?: string
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
/** O contêiner é o único `role="status"`; o Spinner dentro dele é decorativo. */
|
|
37
|
-
export function LoadingSurface({
|
|
45
|
+
export function LoadingSurface({
|
|
46
|
+
title,
|
|
47
|
+
description,
|
|
48
|
+
titleLevel,
|
|
49
|
+
className,
|
|
50
|
+
}: LoadingSurfaceProps): ReactElement {
|
|
38
51
|
return (
|
|
39
52
|
<div
|
|
40
53
|
role="status"
|
|
41
54
|
aria-live="polite"
|
|
42
55
|
aria-label={title === undefined ? 'Carregando' : undefined}
|
|
43
|
-
className={cn(
|
|
56
|
+
className={cn(
|
|
57
|
+
'flex flex-col items-center justify-center gap-3 text-center text-muted-foreground',
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
44
60
|
>
|
|
45
61
|
<Spinner />
|
|
46
62
|
{(title !== undefined || description !== undefined) && (
|
|
47
63
|
<div className="max-w-md space-y-1">
|
|
48
64
|
{title !== undefined && (
|
|
49
|
-
<p
|
|
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
|
+
>
|
|
50
71
|
{title}
|
|
51
72
|
</p>
|
|
52
73
|
)}
|
|
53
74
|
{description !== undefined && (
|
|
54
|
-
<p
|
|
75
|
+
<p
|
|
76
|
+
data-slot="state-description"
|
|
77
|
+
className="text-sm text-muted-foreground"
|
|
78
|
+
>
|
|
55
79
|
{description}
|
|
56
80
|
</p>
|
|
57
81
|
)}
|
|
@@ -66,11 +90,15 @@ export interface ErrorSurfaceProps {
|
|
|
66
90
|
title: ReactNode
|
|
67
91
|
description?: ReactNode
|
|
68
92
|
icon?: ReactNode
|
|
69
|
-
/** Recuperação: com `onRetry`,
|
|
93
|
+
/** Recuperação: com `onRetry`, uma ação somente com ícone entra nas ações do Alert. */
|
|
70
94
|
onRetry?: () => Promise<void> | void
|
|
71
95
|
retryLabel?: string
|
|
72
96
|
/** Outras ações do estado, ao lado do botão de recuperação. */
|
|
73
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
|
|
74
102
|
className?: string
|
|
75
103
|
}
|
|
76
104
|
|
|
@@ -81,23 +109,98 @@ export function ErrorSurface({
|
|
|
81
109
|
onRetry,
|
|
82
110
|
retryLabel = DEFAULT_RETRY_LABEL,
|
|
83
111
|
children,
|
|
112
|
+
presentation = 'alert',
|
|
113
|
+
titleLevel,
|
|
84
114
|
className,
|
|
85
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
|
+
|
|
86
148
|
return (
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
+
)}
|
|
93
156
|
>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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>
|
|
98
202
|
)}
|
|
99
|
-
|
|
100
|
-
</Alert>
|
|
203
|
+
</div>
|
|
101
204
|
)
|
|
102
205
|
}
|
|
103
206
|
|
|
@@ -115,6 +218,8 @@ export interface EmptySurfaceProps {
|
|
|
115
218
|
frame?: 'region' | 'structural' | 'bare'
|
|
116
219
|
/** Título como frase discreta (seção) em vez do título grande (página). */
|
|
117
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
|
|
118
223
|
className?: string
|
|
119
224
|
}
|
|
120
225
|
|
|
@@ -125,6 +230,7 @@ export function EmptySurface({
|
|
|
125
230
|
action,
|
|
126
231
|
frame = 'region',
|
|
127
232
|
compact = false,
|
|
233
|
+
titleLevel,
|
|
128
234
|
className,
|
|
129
235
|
}: EmptySurfaceProps): ReactElement {
|
|
130
236
|
return (
|
|
@@ -139,8 +245,16 @@ export function EmptySurface({
|
|
|
139
245
|
>
|
|
140
246
|
<EmptyHeader>
|
|
141
247
|
{icon !== undefined && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
|
142
|
-
<EmptyTitle
|
|
143
|
-
|
|
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
|
+
)}
|
|
144
258
|
</EmptyHeader>
|
|
145
259
|
{action !== undefined && <EmptyActions>{action}</EmptyActions>}
|
|
146
260
|
</Empty>
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cabeçalho de superfície (interno): a anatomia que Page e Content compartilham.
|
|
3
3
|
*
|
|
4
|
-
* Um título,
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* visual (`page` ou `section`); layout, validação e classes vivem aqui, uma vez.
|
|
4
|
+
* Um título, metadata opcional, uma descrição e uma região de ações que vai para o extremo oposto
|
|
5
|
+
* em larguras amplas e empilha abaixo do contexto nas estreitas. Cada família escolhe quais slots
|
|
6
|
+
* expõe; layout, validação e classes vivem aqui, uma vez.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import {
|
|
@@ -40,8 +39,9 @@ export const surfaceHeaderClasses: Record<
|
|
|
40
39
|
};
|
|
41
40
|
|
|
42
41
|
interface SurfaceHeaderSlots {
|
|
42
|
+
leading?: ElementType | readonly ElementType[];
|
|
43
43
|
title: ElementType;
|
|
44
|
-
count
|
|
44
|
+
count?: ElementType;
|
|
45
45
|
description: ElementType;
|
|
46
46
|
actions: ElementType;
|
|
47
47
|
}
|
|
@@ -53,6 +53,10 @@ export interface SurfaceHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
|
|
53
53
|
slot: string;
|
|
54
54
|
/** Os componentes de slot que este cabeçalho reconhece. */
|
|
55
55
|
slots: SurfaceHeaderSlots;
|
|
56
|
+
/** Posição de um slot introdutório opcional, antes da linha ou dentro dela. */
|
|
57
|
+
leadingPlacement?: "above" | "inline";
|
|
58
|
+
/** Classes de um container interno quando a moldura precisa ocupar toda a largura. */
|
|
59
|
+
contentClassName?: string;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
/** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
|
|
@@ -68,6 +72,8 @@ export function SurfaceHeader({
|
|
|
68
72
|
name,
|
|
69
73
|
slot,
|
|
70
74
|
slots,
|
|
75
|
+
leadingPlacement = "above",
|
|
76
|
+
contentClassName,
|
|
71
77
|
className,
|
|
72
78
|
children,
|
|
73
79
|
...props
|
|
@@ -75,45 +81,124 @@ export function SurfaceHeader({
|
|
|
75
81
|
const nodes = flatten(children);
|
|
76
82
|
const of = (type: ElementType) =>
|
|
77
83
|
nodes.filter((node) => isValidElement(node) && node.type === type);
|
|
84
|
+
const leadingTypes: readonly ElementType[] =
|
|
85
|
+
slots.leading === undefined
|
|
86
|
+
? []
|
|
87
|
+
: Array.isArray(slots.leading)
|
|
88
|
+
? slots.leading
|
|
89
|
+
: [slots.leading as ElementType];
|
|
90
|
+
const leading = leadingTypes.flatMap(of);
|
|
78
91
|
const titles = of(slots.title);
|
|
79
|
-
const counts = of(slots.count);
|
|
92
|
+
const counts = slots.count === undefined ? [] : of(slots.count);
|
|
80
93
|
const descriptions = of(slots.description);
|
|
81
94
|
const actionSlots = of(slots.actions);
|
|
82
95
|
const recognized =
|
|
83
|
-
|
|
96
|
+
leading.length +
|
|
97
|
+
titles.length +
|
|
98
|
+
counts.length +
|
|
99
|
+
descriptions.length +
|
|
100
|
+
actionSlots.length;
|
|
84
101
|
|
|
85
102
|
if (
|
|
86
103
|
titles.length !== 1 ||
|
|
104
|
+
leading.length > 1 ||
|
|
87
105
|
counts.length > 1 ||
|
|
88
106
|
descriptions.length > 1 ||
|
|
89
107
|
actionSlots.length > 1 ||
|
|
90
108
|
recognized !== nodes.length
|
|
91
109
|
) {
|
|
92
110
|
const label = (type: ElementType) =>
|
|
93
|
-
typeof type === "string"
|
|
111
|
+
typeof type === "string"
|
|
112
|
+
? type
|
|
113
|
+
: ((type as { name?: string }).name ?? "");
|
|
114
|
+
const optionalSlots = [
|
|
115
|
+
...leadingTypes,
|
|
116
|
+
slots.description,
|
|
117
|
+
slots.count,
|
|
118
|
+
slots.actions,
|
|
119
|
+
]
|
|
120
|
+
.filter((type): type is ElementType => type !== undefined)
|
|
121
|
+
.map(label)
|
|
122
|
+
.join(", ");
|
|
123
|
+
// A região introdutória aceita mais de um TIPO, mas só um por vez: a mensagem genérica
|
|
124
|
+
// ("no máximo um de cada") não diz que eles são alternativas entre si. O que distingue os
|
|
125
|
+
// dois erros é a quantidade de tipos distintos presentes — dois PageBack são repetição,
|
|
126
|
+
// não combinação, e merecem a mensagem genérica.
|
|
127
|
+
const distinctLeading = new Set(
|
|
128
|
+
leading.flatMap((node) => (isValidElement(node) ? [node.type as ElementType] : [])),
|
|
129
|
+
);
|
|
94
130
|
throw new Error(
|
|
95
|
-
|
|
131
|
+
distinctLeading.size > 1
|
|
132
|
+
? `${name} aceita ${[...distinctLeading].map(label).join(" ou ")} na região introdutória, nunca os dois na mesma superfície.`
|
|
133
|
+
: `${name} exige um ${label(slots.title)} e aceita no máximo um de cada: ${optionalSlots}.`,
|
|
96
134
|
);
|
|
97
135
|
}
|
|
98
136
|
|
|
137
|
+
const heading = (
|
|
138
|
+
<div
|
|
139
|
+
data-slot={`${slot}-heading`}
|
|
140
|
+
className={cn("min-w-0", leadingPlacement === "inline" && "flex-1")}
|
|
141
|
+
>
|
|
142
|
+
<div className="flex items-baseline gap-2">
|
|
143
|
+
{titles}
|
|
144
|
+
{counts}
|
|
145
|
+
</div>
|
|
146
|
+
{descriptions}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
const row = (
|
|
150
|
+
<>
|
|
151
|
+
{leadingPlacement === "inline" && leading}
|
|
152
|
+
{heading}
|
|
153
|
+
{actionSlots}
|
|
154
|
+
</>
|
|
155
|
+
);
|
|
156
|
+
const layout =
|
|
157
|
+
leadingPlacement === "inline" ? (
|
|
158
|
+
<div className="flex min-w-0 items-center gap-3">{row}</div>
|
|
159
|
+
) : leading.length > 0 ? (
|
|
160
|
+
<>
|
|
161
|
+
<div data-slot={`${slot}-navigation`} className="mb-4">
|
|
162
|
+
{leading}
|
|
163
|
+
</div>
|
|
164
|
+
<div
|
|
165
|
+
data-slot={`${slot}-header-row`}
|
|
166
|
+
className={cn(
|
|
167
|
+
actionSlots.length > 0 &&
|
|
168
|
+
"flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
{heading}
|
|
172
|
+
{actionSlots}
|
|
173
|
+
</div>
|
|
174
|
+
</>
|
|
175
|
+
) : (
|
|
176
|
+
<>
|
|
177
|
+
{heading}
|
|
178
|
+
{actionSlots}
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
|
|
99
182
|
return (
|
|
100
183
|
<div
|
|
101
184
|
data-slot={`${slot}-header`}
|
|
102
185
|
className={cn(
|
|
103
|
-
|
|
186
|
+
contentClassName === undefined &&
|
|
187
|
+
leadingPlacement !== "inline" &&
|
|
188
|
+
leading.length === 0 &&
|
|
189
|
+
actionSlots.length > 0 &&
|
|
104
190
|
"flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
|
|
105
191
|
className,
|
|
106
192
|
)}
|
|
107
193
|
{...props}
|
|
108
194
|
>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
195
|
+
{contentClassName === undefined ? (
|
|
196
|
+
layout
|
|
197
|
+
) : (
|
|
198
|
+
<div data-slot={`${slot}-header-content`} className={contentClassName}>
|
|
199
|
+
{layout}
|
|
113
200
|
</div>
|
|
114
|
-
|
|
115
|
-
</div>
|
|
116
|
-
{actionSlots}
|
|
201
|
+
)}
|
|
117
202
|
</div>
|
|
118
203
|
);
|
|
119
204
|
}
|
|
@@ -5,9 +5,10 @@
|
|
|
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, no quadrado `icon-
|
|
9
|
-
* é a ação que mora NO item (linha, card).
|
|
10
|
-
*
|
|
8
|
+
* - `icon` faz o botão virar icon-only com tooltip, no quadrado `icon-xs` (1.5rem) da escala:
|
|
9
|
+
* é a ação que mora NO item (linha, card). Uma composição que peça mais presença — a barra
|
|
10
|
+
* do `PageHeader`, por exemplo — declara `size="icon-sm"`. Absorveu o antigo DeleteButton,
|
|
11
|
+
* que era este componente com uma lixeira.
|
|
11
12
|
* Emite `data-action="<action.name>"` na raiz (selector E2E).
|
|
12
13
|
*/
|
|
13
14
|
|
|
@@ -50,7 +51,7 @@ export interface ActionTriggerProps<TInput, TData> {
|
|
|
50
51
|
context?: ButtonProps['context']
|
|
51
52
|
/** Tratamento visual do Button. */
|
|
52
53
|
variant?: ButtonProps['variant']
|
|
53
|
-
/** Tamanho do botão (escala de control.ts). Com `icon`, o
|
|
54
|
+
/** Tamanho do botão (escala de control.ts). Com `icon`, o default é `icon-xs`. */
|
|
54
55
|
size?: ButtonProps['size']
|
|
55
56
|
/** Desabilita o botão independente de loading. */
|
|
56
57
|
disabled?: boolean
|
|
@@ -79,7 +80,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
79
80
|
label,
|
|
80
81
|
context,
|
|
81
82
|
variant,
|
|
82
|
-
size
|
|
83
|
+
size,
|
|
83
84
|
disabled = false,
|
|
84
85
|
confirm: confirmProp,
|
|
85
86
|
onSuccess,
|
|
@@ -142,7 +143,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
142
143
|
<Button
|
|
143
144
|
context={triggerContext}
|
|
144
145
|
variant={triggerVariant}
|
|
145
|
-
size={icon !== undefined ? 'icon-
|
|
146
|
+
size={size ?? (icon !== undefined ? 'icon-xs' : 'default')}
|
|
146
147
|
busy={isLoading}
|
|
147
148
|
disabled={disabled}
|
|
148
149
|
aria-label={icon !== undefined ? buttonLabel : undefined}
|
|
@@ -9,10 +9,12 @@ const buttonGroupVariants = cva(
|
|
|
9
9
|
{
|
|
10
10
|
variants: {
|
|
11
11
|
orientation: {
|
|
12
|
-
horizontal:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
horizontal: '',
|
|
13
|
+
vertical: 'flex-col',
|
|
14
|
+
},
|
|
15
|
+
mode: {
|
|
16
|
+
connected: '',
|
|
17
|
+
spaced: 'gap-1',
|
|
16
18
|
},
|
|
17
19
|
shape: {
|
|
18
20
|
default: '',
|
|
@@ -22,17 +24,38 @@ const buttonGroupVariants = cva(
|
|
|
22
24
|
compoundVariants: [
|
|
23
25
|
{
|
|
24
26
|
orientation: 'horizontal',
|
|
27
|
+
mode: 'connected',
|
|
28
|
+
className:
|
|
29
|
+
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
orientation: 'vertical',
|
|
33
|
+
mode: 'connected',
|
|
34
|
+
className:
|
|
35
|
+
'[&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
orientation: 'horizontal',
|
|
39
|
+
mode: 'connected',
|
|
25
40
|
shape: 'pill',
|
|
26
41
|
className: '[&>*:first-child]:rounded-l-full [&>*:last-child]:rounded-r-full [&>[data-slot=select]:first-child_[data-slot=select-control]]:rounded-l-full [&>[data-slot=select]:last-child_[data-slot=select-control]]:rounded-r-full [&>[data-slot=select-wrapper]:first-child_[data-slot=select]]:rounded-l-full [&>[data-slot=select-wrapper]:last-child_[data-slot=select]]:rounded-r-full',
|
|
27
42
|
},
|
|
28
43
|
{
|
|
29
44
|
orientation: 'vertical',
|
|
45
|
+
mode: 'connected',
|
|
30
46
|
shape: 'pill',
|
|
31
47
|
className: '[&>*:first-child]:rounded-t-full [&>*:last-child]:rounded-b-full [&>[data-slot=select]:first-child_[data-slot=select-control]]:rounded-t-full [&>[data-slot=select]:last-child_[data-slot=select-control]]:rounded-b-full [&>[data-slot=select-wrapper]:first-child_[data-slot=select]]:rounded-t-full [&>[data-slot=select-wrapper]:last-child_[data-slot=select]]:rounded-b-full',
|
|
32
48
|
},
|
|
49
|
+
{
|
|
50
|
+
mode: 'spaced',
|
|
51
|
+
shape: 'pill',
|
|
52
|
+
className:
|
|
53
|
+
'[&>*]:rounded-full [&>[data-slot=select]_[data-slot=select-control]]:rounded-full [&>[data-slot=select-wrapper]_[data-slot=select]]:rounded-full',
|
|
54
|
+
},
|
|
33
55
|
],
|
|
34
56
|
defaultVariants: {
|
|
35
57
|
orientation: "horizontal",
|
|
58
|
+
mode: 'connected',
|
|
36
59
|
shape: "default",
|
|
37
60
|
},
|
|
38
61
|
}
|
|
@@ -41,6 +64,7 @@ const buttonGroupVariants = cva(
|
|
|
41
64
|
function ButtonGroup({
|
|
42
65
|
className,
|
|
43
66
|
orientation,
|
|
67
|
+
mode,
|
|
44
68
|
shape,
|
|
45
69
|
...props
|
|
46
70
|
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
|
@@ -49,12 +73,14 @@ function ButtonGroup({
|
|
|
49
73
|
role="group"
|
|
50
74
|
data-slot="button-group"
|
|
51
75
|
data-orientation={orientation}
|
|
76
|
+
data-mode={mode}
|
|
52
77
|
data-shape={shape}
|
|
53
78
|
className={cn(
|
|
54
|
-
buttonGroupVariants({ orientation, shape }),
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
buttonGroupVariants({ orientation, mode, shape }),
|
|
80
|
+
mode !== 'spaced' &&
|
|
81
|
+
(orientation === 'vertical'
|
|
82
|
+
? '[&>[data-slot=select]:not(:first-child)_[data-slot=select-control]]:rounded-t-none [&>[data-slot=select]:not(:first-child)_[data-slot=select-control]]:border-t-0 [&>[data-slot=select]:not(:last-child)_[data-slot=select-control]]:rounded-b-none [&>[data-slot=select-wrapper]:not(:first-child)_[data-slot=select]]:rounded-t-none [&>[data-slot=select-wrapper]:not(:first-child)_[data-slot=select]]:border-t-0 [&>[data-slot=select-wrapper]:not(:last-child)_[data-slot=select]]:rounded-b-none'
|
|
83
|
+
: '[&>[data-slot=select]:not(:first-child)_[data-slot=select-control]]:rounded-l-none [&>[data-slot=select]:not(:first-child)_[data-slot=select-control]]:border-l-0 [&>[data-slot=select]:not(:last-child)_[data-slot=select-control]]:rounded-r-none [&>[data-slot=select-wrapper]:not(:first-child)_[data-slot=select]]:rounded-l-none [&>[data-slot=select-wrapper]:not(:first-child)_[data-slot=select]]:border-l-0 [&>[data-slot=select-wrapper]:not(:last-child)_[data-slot=select]]:rounded-r-none'),
|
|
58
84
|
className,
|
|
59
85
|
)}
|
|
60
86
|
{...props}
|
|
@@ -8,8 +8,8 @@ export type ControlShape = 'default' | 'pill'
|
|
|
8
8
|
*
|
|
9
9
|
* - Altura de linha (`xs` · `sm` · `default` · `lg`): 1.5 · 2 · 2.25 · 2.5rem.
|
|
10
10
|
* - Quadrado só-ícone (`icon-xs` · `icon-sm` · `icon` · `icon-lg`): 1.5 · 1.75 · 2.25 · 2.5rem.
|
|
11
|
-
* `icon-
|
|
12
|
-
*
|
|
11
|
+
* `icon-xs` é o degrau da ação que mora dentro de uma linha densa. `icon-sm` atende
|
|
12
|
+
* composições compactas que ainda precisam de mais presença.
|
|
13
13
|
* - Glifo (o svg dentro do controle daquele tamanho): 0.875 · 0.875 · 1 · 1.25rem. O Spinner
|
|
14
14
|
* usa esta medida, por isso `busy` substitui o ícone sem mexer na largura do botão.
|
|
15
15
|
*/
|
|
@@ -38,6 +38,15 @@ export const controlGlyph: Record<ControlSize, string> = {
|
|
|
38
38
|
lg: 'size-5',
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// As classes completas precisam permanecer literais para o scanner do Tailwind gerar os seletores.
|
|
42
|
+
// Montar `]:${controlGlyph[...]}` em runtime devolve o nome certo no DOM, mas deixa o CSS ausente.
|
|
43
|
+
const controlGlyphSelector: Record<ControlSize, string> = {
|
|
44
|
+
xs: '[&_svg:not([class*=size-])]:size-3.5',
|
|
45
|
+
sm: '[&_svg:not([class*=size-])]:size-3.5',
|
|
46
|
+
default: '[&_svg:not([class*=size-])]:size-4',
|
|
47
|
+
lg: '[&_svg:not([class*=size-])]:size-5',
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
/** A altura de linha por trás de um tamanho da escala (`icon-sm` → `sm`). */
|
|
42
51
|
export function controlBase(size: ControlScale | null | undefined): ControlSize {
|
|
43
52
|
switch (size) {
|
|
@@ -58,7 +67,7 @@ export function controlBase(size: ControlScale | null | undefined): ControlSize
|
|
|
58
67
|
|
|
59
68
|
/** Fallback de tamanho do svg descendente, respeitando um `size-*` explícito no próprio ícone. */
|
|
60
69
|
export function controlGlyphClass(size: ControlScale | null | undefined): string {
|
|
61
|
-
return
|
|
70
|
+
return controlGlyphSelector[controlBase(size)]
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
/**
|
|
@@ -69,5 +69,5 @@ corpo do modal.
|
|
|
69
69
|
| `actions` | `ReactNode` | | Ação à direita da toolbar — em geral o botão de criar. |
|
|
70
70
|
| `empty` | `(items) => boolean` | `items.length === 0` | Sobrepõe o vazio derivado. |
|
|
71
71
|
| `loading` | `boolean` | | Carga extra agregada à do fetch (query irmã). |
|
|
72
|
-
| `emptyMessage / errorMessage / retryLabel` | `string` | | Textos dos estados
|
|
72
|
+
| `emptyMessage / errorMessage / retryLabel` | `string` | | Textos dos estados; `retryLabel` nomeia a ação somente com ícone e seu tooltip. |
|
|
73
73
|
| `className` | `string` | `sm:max-w-3xl` | Largura do DialogContent. |
|
|
@@ -7,6 +7,9 @@ os filtros aplicados permanecem visíveis como chips removíveis.
|
|
|
7
7
|
|
|
8
8
|
A barra se adapta ao espaço disponível. A busca cede largura primeiro e filtros que deixam de caber
|
|
9
9
|
migram para o modal; a linha só quebra quando nenhum controle restante puder ceder espaço.
|
|
10
|
+
`toolbarActions` acrescenta ações do consumidor ao fim da barra. Recarregar e exibição formam um
|
|
11
|
+
`ButtonGroup` espaçado; as ações do consumidor vêm depois, com um intervalo maior para preservar a
|
|
12
|
+
hierarquia entre ferramentas e a ação principal.
|
|
10
13
|
|
|
11
14
|
```tsx preview col
|
|
12
15
|
render(
|
|
@@ -18,6 +21,20 @@ render(
|
|
|
18
21
|
)
|
|
19
22
|
```
|
|
20
23
|
|
|
24
|
+
Uma ação icon-only mantém o nome no tooltip e no `aria-label`:
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
<ActionList
|
|
28
|
+
action={workspaceList}
|
|
29
|
+
input={{}}
|
|
30
|
+
toolbarActions={
|
|
31
|
+
<Button context="primary" size="icon" aria-label="Novo workspace">
|
|
32
|
+
<Plus />
|
|
33
|
+
</Button>
|
|
34
|
+
}
|
|
35
|
+
/>
|
|
36
|
+
```
|
|
37
|
+
|
|
21
38
|
## Barra de filtros compartilhada
|
|
22
39
|
|
|
23
40
|
`ActionFilterBar` expõe a mesma linguagem declarativa de busca, filtros e período para
|
|
@@ -235,6 +252,19 @@ render(
|
|
|
235
252
|
)
|
|
236
253
|
```
|
|
237
254
|
|
|
255
|
+
## Propriedades de ActionFilterBar
|
|
256
|
+
|
|
257
|
+
| Propriedade | Tipo | Padrão | Descrição |
|
|
258
|
+
|---|---|---|---|
|
|
259
|
+
| `action` | `Pick<ListAction, 'filters' \| 'text' \| 'periods'>` | | Declara busca, filtros e períodos disponíveis. |
|
|
260
|
+
| `state` | `ActionFilterState` | | Estado atual da barra. |
|
|
261
|
+
| `onStateChange` | `(next) => void` | | Recebe o estado completo depois de cada alteração. |
|
|
262
|
+
| `filterOptions` | `Record<string, SelectOption[]>` | | Fornece opções de runtime para filtros select e lookup. |
|
|
263
|
+
| `onRefresh` | `() => Promise<void> \| void` | | Exibe a ação de recarregar e executa a consulta do consumidor. |
|
|
264
|
+
| `refreshing` | `boolean` | `false` | Desabilita e anima a ação de recarregar durante a consulta. |
|
|
265
|
+
| `controls` | `ReactNode` | | Controles auxiliares agrupados com recarregar em um `ButtonGroup` espaçado. |
|
|
266
|
+
| `actions` | `ReactNode` | | Ações do consumidor exibidas depois do grupo de controles, com um intervalo maior. |
|
|
267
|
+
|
|
238
268
|
## Declaração na ListAction
|
|
239
269
|
|
|
240
270
|
| Chave | O que declara |
|
|
@@ -268,7 +298,8 @@ chamar a action.
|
|
|
268
298
|
| `pageSize` | `number` | `50` | Itens por página padrão (vira `limit`/`page` no input; handler devolve `total`). O usuário troca no popover de exibição. |
|
|
269
299
|
| `state / onStateChange` | `ActionListState / (next) => void` | interno | Estado da toolbar controlado — para quem embala sincronizar com a URL. |
|
|
270
300
|
| `emptyMessage` | `string` | `'Nenhum resultado.'` | Frase do estado vazio (o `Empty` do `DataState`). |
|
|
271
|
-
| `errorMessage` | `string` | `'Não foi possível carregar.'` | Título do aviso de erro;
|
|
272
|
-
| `retryLabel` | `string` | `'Tentar de novo'` |
|
|
301
|
+
| `errorMessage` | `string` | `'Não foi possível carregar.'` | Título do aviso de erro; a ação de tentar de novo refaz a consulta. |
|
|
302
|
+
| `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
|
|
273
303
|
| `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
|
|
274
|
-
| `
|
|
304
|
+
| `toolbarActions` | `ReactNode` | | Ações do consumidor no fim da barra, separadas do grupo de controles. |
|
|
305
|
+
| `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita), agrupadas automaticamente com intervalo compacto; cliques ali não disparam o `onRowClick`. |
|