@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.
- package/CHANGELOG.md +97 -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 +279 -6
- 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/code-style.md +4 -1
- package/docs/data-layer.md +9 -0
- package/package.json +1 -1
- package/registry/instructions/opus.md +3 -3
- package/registry/skills/build-opus-ui/SKILL.md +3 -2
- package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
- package/registry/templates/app/src/App.tsx +11 -6
- package/src/core/types.ts +3 -4
- 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 +42 -141
- package/src/ui/components/patterns/data-state.tsx +42 -68
- package/src/ui/components/patterns/form.tsx +15 -15
- package/src/ui/components/patterns/list.tsx +55 -34
- package/src/ui/components/patterns/page-state.tsx +81 -51
- package/src/ui/components/patterns/page.tsx +228 -97
- package/src/ui/components/patterns/state-surface.tsx +262 -0
- package/src/ui/components/patterns/surface-header.tsx +204 -0
- package/src/ui/components/patterns/trigger.tsx +9 -10
- package/src/ui/components/patterns/view.tsx +14 -16
- package/src/ui/components/primitives/alert.tsx +1 -33
- package/src/ui/components/primitives/avatar.tsx +15 -5
- package/src/ui/components/primitives/badge.tsx +2 -43
- package/src/ui/components/primitives/button-group.tsx +34 -8
- package/src/ui/components/primitives/button.tsx +31 -35
- package/src/ui/components/primitives/control.ts +69 -0
- package/src/ui/components/primitives/dot.tsx +1 -30
- package/src/ui/components/primitives/input-group.tsx +11 -8
- 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/select.tsx +2 -2
- package/src/ui/components/primitives/spinner.tsx +13 -16
- package/src/ui/components/primitives/switch.tsx +4 -1
- package/src/ui/components/primitives/tabs.tsx +5 -3
- package/src/ui/components/primitives/toggle.tsx +9 -4
- package/src/ui/docs/content/action-form.md +26 -0
- package/src/ui/docs/content/action-list-dialog.md +2 -2
- package/src/ui/docs/content/action-list.md +35 -2
- package/src/ui/docs/content/action-trigger.md +5 -4
- package/src/ui/docs/content/action-view.md +3 -2
- package/src/ui/docs/content/alert.md +16 -4
- package/src/ui/docs/content/avatar.md +7 -3
- package/src/ui/docs/content/button.md +48 -17
- package/src/ui/docs/content/communication.md +36 -0
- package/src/ui/docs/content/content.md +5 -4
- package/src/ui/docs/content/data-state.md +17 -13
- package/src/ui/docs/content/dialog.md +1 -4
- package/src/ui/docs/content/input.md +1 -1
- package/src/ui/docs/content/item.md +1 -1
- package/src/ui/docs/content/page.md +160 -37
- package/src/ui/docs/content/pagination.md +11 -9
- package/src/ui/docs/content/semantic-context.md +3 -2
- package/src/ui/docs/content/sidebar.md +2 -42
- package/src/ui/docs/content/spinner.md +9 -6
- package/src/ui/docs/content/switch.md +1 -1
- package/src/ui/docs/content/tabs.md +1 -1
- package/src/ui/docs/content/toggle.md +1 -1
- package/src/ui/drivers/react.tsx +1 -6
- package/src/ui/meta.ts +8 -8
- package/src/ui/react.tsx +16 -18
- package/src/ui/components/patterns/shell-nav.tsx +0 -154
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cabeçalho de superfície (interno): a anatomia que Page e Content compartilham.
|
|
3
|
+
*
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
Children,
|
|
11
|
+
Fragment,
|
|
12
|
+
isValidElement,
|
|
13
|
+
type ElementType,
|
|
14
|
+
type HTMLAttributes,
|
|
15
|
+
type ReactElement,
|
|
16
|
+
type ReactNode,
|
|
17
|
+
} from "react";
|
|
18
|
+
import { cn } from "../../lib/cn.ts";
|
|
19
|
+
|
|
20
|
+
export type SurfaceHeaderVariant = "page" | "section";
|
|
21
|
+
|
|
22
|
+
/** As classes por slot, por hierarquia visual — PageTitle e ContentTitle(page) são o mesmo. */
|
|
23
|
+
export const surfaceHeaderClasses: Record<
|
|
24
|
+
SurfaceHeaderVariant,
|
|
25
|
+
{ title: string; description: string; count: string; actions: string }
|
|
26
|
+
> = {
|
|
27
|
+
page: {
|
|
28
|
+
title: "text-2xl font-semibold tracking-tight",
|
|
29
|
+
description: "mt-1 truncate text-sm text-muted-foreground",
|
|
30
|
+
count: "font-mono text-base text-muted-foreground/60",
|
|
31
|
+
actions: "flex w-full shrink-0 items-center gap-4 sm:w-auto",
|
|
32
|
+
},
|
|
33
|
+
section: {
|
|
34
|
+
title: "text-sm font-semibold",
|
|
35
|
+
description: "mt-0.5 truncate text-xs text-muted-foreground",
|
|
36
|
+
count: "font-mono text-xs text-muted-foreground/60",
|
|
37
|
+
actions: "flex w-full shrink-0 items-center gap-2 sm:w-auto",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
interface SurfaceHeaderSlots {
|
|
42
|
+
leading?: ElementType | readonly ElementType[];
|
|
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
|
+
/** 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;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Expande fragments de primeiro nível: o shorthand monta os slots dentro de um `<>`. */
|
|
63
|
+
function flatten(children: ReactNode): ReactNode[] {
|
|
64
|
+
return Children.toArray(children).flatMap((node) =>
|
|
65
|
+
isValidElement(node) && node.type === Fragment
|
|
66
|
+
? Children.toArray((node.props as { children?: ReactNode }).children)
|
|
67
|
+
: [node],
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function SurfaceHeader({
|
|
72
|
+
name,
|
|
73
|
+
slot,
|
|
74
|
+
slots,
|
|
75
|
+
leadingPlacement = "above",
|
|
76
|
+
contentClassName,
|
|
77
|
+
className,
|
|
78
|
+
children,
|
|
79
|
+
...props
|
|
80
|
+
}: SurfaceHeaderProps): ReactElement {
|
|
81
|
+
const nodes = flatten(children);
|
|
82
|
+
const of = (type: ElementType) =>
|
|
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);
|
|
91
|
+
const titles = of(slots.title);
|
|
92
|
+
const counts = slots.count === undefined ? [] : of(slots.count);
|
|
93
|
+
const descriptions = of(slots.description);
|
|
94
|
+
const actionSlots = of(slots.actions);
|
|
95
|
+
const recognized =
|
|
96
|
+
leading.length +
|
|
97
|
+
titles.length +
|
|
98
|
+
counts.length +
|
|
99
|
+
descriptions.length +
|
|
100
|
+
actionSlots.length;
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
titles.length !== 1 ||
|
|
104
|
+
leading.length > 1 ||
|
|
105
|
+
counts.length > 1 ||
|
|
106
|
+
descriptions.length > 1 ||
|
|
107
|
+
actionSlots.length > 1 ||
|
|
108
|
+
recognized !== nodes.length
|
|
109
|
+
) {
|
|
110
|
+
const label = (type: ElementType) =>
|
|
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
|
+
);
|
|
130
|
+
throw new Error(
|
|
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}.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
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
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
data-slot={`${slot}-header`}
|
|
185
|
+
className={cn(
|
|
186
|
+
contentClassName === undefined &&
|
|
187
|
+
leadingPlacement !== "inline" &&
|
|
188
|
+
leading.length === 0 &&
|
|
189
|
+
actionSlots.length > 0 &&
|
|
190
|
+
"flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between",
|
|
191
|
+
className,
|
|
192
|
+
)}
|
|
193
|
+
{...props}
|
|
194
|
+
>
|
|
195
|
+
{contentClassName === undefined ? (
|
|
196
|
+
layout
|
|
197
|
+
) : (
|
|
198
|
+
<div data-slot={`${slot}-header-content`} className={contentClassName}>
|
|
199
|
+
{layout}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -5,8 +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
|
|
9
|
-
* card).
|
|
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.
|
|
10
12
|
* Emite `data-action="<action.name>"` na raiz (selector E2E).
|
|
11
13
|
*/
|
|
12
14
|
|
|
@@ -49,8 +51,8 @@ export interface ActionTriggerProps<TInput, TData> {
|
|
|
49
51
|
context?: ButtonProps['context']
|
|
50
52
|
/** Tratamento visual do Button. */
|
|
51
53
|
variant?: ButtonProps['variant']
|
|
52
|
-
/** Tamanho do botão. */
|
|
53
|
-
size?: '
|
|
54
|
+
/** Tamanho do botão (escala de control.ts). Com `icon`, o default é `icon-xs`. */
|
|
55
|
+
size?: ButtonProps['size']
|
|
54
56
|
/** Desabilita o botão independente de loading. */
|
|
55
57
|
disabled?: boolean
|
|
56
58
|
/** Texto do confirm dialog. Default vem do `action.confirm` (ConfirmSpec do
|
|
@@ -78,7 +80,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
78
80
|
label,
|
|
79
81
|
context,
|
|
80
82
|
variant,
|
|
81
|
-
size
|
|
83
|
+
size,
|
|
82
84
|
disabled = false,
|
|
83
85
|
confirm: confirmProp,
|
|
84
86
|
onSuccess,
|
|
@@ -141,7 +143,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
141
143
|
<Button
|
|
142
144
|
context={triggerContext}
|
|
143
145
|
variant={triggerVariant}
|
|
144
|
-
size={icon !== undefined ? 'icon' :
|
|
146
|
+
size={size ?? (icon !== undefined ? 'icon-xs' : 'default')}
|
|
145
147
|
busy={isLoading}
|
|
146
148
|
disabled={disabled}
|
|
147
149
|
aria-label={icon !== undefined ? buttonLabel : undefined}
|
|
@@ -149,10 +151,7 @@ export function ActionTrigger<TInput, TData>({
|
|
|
149
151
|
e.stopPropagation()
|
|
150
152
|
onClick()
|
|
151
153
|
}}
|
|
152
|
-
className={cn(
|
|
153
|
-
icon !== undefined && 'size-7 shrink-0 text-muted-foreground/60',
|
|
154
|
-
className,
|
|
155
|
-
)}
|
|
154
|
+
className={cn(icon !== undefined && 'shrink-0 text-muted-foreground/60', className)}
|
|
156
155
|
data-action={action.name}
|
|
157
156
|
>
|
|
158
157
|
{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 {
|
|
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" +
|
|
30
|
-
* falta de permissão ou sessão); a frase do servidor entra como
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
@@ -8,17 +8,9 @@ import {
|
|
|
8
8
|
|
|
9
9
|
export type AlertContext = Exclude<UiContext, "primary">;
|
|
10
10
|
export type AlertVariant = "subtle" | "outline";
|
|
11
|
-
/**
|
|
12
|
-
* @deprecated Aliases de migração: `default` → `context="neutral"`, `destructive` →
|
|
13
|
-
* `context="danger"`; `success`, `warning` e `info` → o `context` homônimo (todos em
|
|
14
|
-
* `subtle`). Declare `context` + `variant`.
|
|
15
|
-
*/
|
|
16
|
-
export type LegacyAlertVariant =
|
|
17
|
-
"default" | "destructive" | "success" | "warning" | "info";
|
|
18
|
-
|
|
19
11
|
interface AlertStyleOptions {
|
|
20
12
|
context?: AlertContext | null;
|
|
21
|
-
variant?: AlertVariant |
|
|
13
|
+
variant?: AlertVariant | null;
|
|
22
14
|
}
|
|
23
15
|
|
|
24
16
|
interface AlertRootContextValue {
|
|
@@ -52,30 +44,6 @@ function resolveAlertStyle({ context, variant }: AlertStyleOptions = {}): {
|
|
|
52
44
|
context: AlertContext;
|
|
53
45
|
variant: AlertVariant;
|
|
54
46
|
} {
|
|
55
|
-
// `danger` não é alias: é um contexto que chegou pela prop errada. Segue como contexto.
|
|
56
|
-
if ((variant as string) === "danger")
|
|
57
|
-
return { context: "danger", variant: "subtle" };
|
|
58
|
-
const legacy =
|
|
59
|
-
variant === "default" ||
|
|
60
|
-
variant === "destructive" ||
|
|
61
|
-
variant === "success" ||
|
|
62
|
-
variant === "warning" ||
|
|
63
|
-
variant === "info";
|
|
64
|
-
if (context != null && legacy) {
|
|
65
|
-
throw new Error(
|
|
66
|
-
"Alert não permite combinar context com uma variante semântica legada.",
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
if (variant === "default") return { context: "neutral", variant: "subtle" };
|
|
70
|
-
if (variant === "destructive")
|
|
71
|
-
return { context: "danger", variant: "subtle" };
|
|
72
|
-
if (
|
|
73
|
-
variant === "success" ||
|
|
74
|
-
variant === "warning" ||
|
|
75
|
-
variant === "info"
|
|
76
|
-
) {
|
|
77
|
-
return { context: variant, variant: "subtle" };
|
|
78
|
-
}
|
|
79
47
|
return { context: context ?? "neutral", variant: variant ?? "subtle" };
|
|
80
48
|
}
|
|
81
49
|
|
|
@@ -2,20 +2,26 @@ import * as React from "react"
|
|
|
2
2
|
import { Avatar as AvatarPrimitive } from "radix-ui"
|
|
3
3
|
|
|
4
4
|
import { cn } from '../../lib/cn.ts'
|
|
5
|
+
import type { ControlSize } from './control.ts'
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Ejetado (14.0.0): `size` segue a escala de control.ts — o avatar de uma linha `sm` mede o
|
|
9
|
+
* mesmo que o controle `sm` ao lado (2rem); `xs` (1.5rem) é a lista densa; `default` (2.25rem)
|
|
10
|
+
* e `lg` (2.5rem) pareiam com Button/Input. O fallback e o selo acompanham o tamanho.
|
|
11
|
+
*/
|
|
6
12
|
function Avatar({
|
|
7
13
|
className,
|
|
8
14
|
size = "default",
|
|
9
15
|
...props
|
|
10
16
|
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
|
11
|
-
size?:
|
|
17
|
+
size?: ControlSize
|
|
12
18
|
}) {
|
|
13
19
|
return (
|
|
14
20
|
<AvatarPrimitive.Root
|
|
15
21
|
data-slot="avatar"
|
|
16
22
|
data-size={size}
|
|
17
23
|
className={cn(
|
|
18
|
-
"group/avatar relative flex
|
|
24
|
+
"group/avatar relative flex shrink-0 overflow-hidden rounded-full select-none data-[size=xs]:size-6 data-[size=sm]:size-8 data-[size=default]:size-9 data-[size=lg]:size-10",
|
|
19
25
|
className
|
|
20
26
|
)}
|
|
21
27
|
{...props}
|
|
@@ -44,7 +50,7 @@ function AvatarFallback({
|
|
|
44
50
|
<AvatarPrimitive.Fallback
|
|
45
51
|
data-slot="avatar-fallback"
|
|
46
52
|
className={cn(
|
|
47
|
-
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
|
53
|
+
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=xs]/avatar:text-xs group-data-[size=sm]/avatar:text-xs",
|
|
48
54
|
className
|
|
49
55
|
)}
|
|
50
56
|
{...props}
|
|
@@ -58,7 +64,8 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
|
|
58
64
|
data-slot="avatar-badge"
|
|
59
65
|
className={cn(
|
|
60
66
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none",
|
|
61
|
-
"group-data-[size=
|
|
67
|
+
"group-data-[size=xs]/avatar:size-2 group-data-[size=xs]/avatar:[&>svg]:hidden",
|
|
68
|
+
"group-data-[size=sm]/avatar:size-2.5 group-data-[size=sm]/avatar:[&>svg]:size-2",
|
|
62
69
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
|
63
70
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
|
64
71
|
className
|
|
@@ -89,7 +96,10 @@ function AvatarGroupCount({
|
|
|
89
96
|
<div
|
|
90
97
|
data-slot="avatar-group-count"
|
|
91
98
|
className={cn(
|
|
92
|
-
"relative flex size-
|
|
99
|
+
"relative flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background [&>svg]:size-4",
|
|
100
|
+
"group-has-data-[size=xs]/avatar-group:size-6 group-has-data-[size=xs]/avatar-group:[&>svg]:size-3",
|
|
101
|
+
"group-has-data-[size=sm]/avatar-group:size-8 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3.5",
|
|
102
|
+
"group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5",
|
|
93
103
|
className
|
|
94
104
|
)}
|
|
95
105
|
{...props}
|
|
@@ -12,22 +12,9 @@ import {
|
|
|
12
12
|
|
|
13
13
|
export type BadgeContext = UiContext
|
|
14
14
|
export type BadgeVariant = 'solid' | 'subtle' | 'outline'
|
|
15
|
-
/**
|
|
16
|
-
* @deprecated Aliases de migração: `default` → `context="primary"` solid, `secondary` →
|
|
17
|
-
* `context="neutral"` solid, `destructive` → `context="danger"` solid; `success`, `warning` e
|
|
18
|
-
* `info` → o `context` homônimo em `subtle`. Declare `context` + `variant`.
|
|
19
|
-
*/
|
|
20
|
-
export type LegacyBadgeVariant =
|
|
21
|
-
| 'default'
|
|
22
|
-
| 'secondary'
|
|
23
|
-
| 'destructive'
|
|
24
|
-
| 'success'
|
|
25
|
-
| 'warning'
|
|
26
|
-
| 'info'
|
|
27
|
-
|
|
28
15
|
export interface BadgeStyleOptions {
|
|
29
16
|
context?: BadgeContext | null
|
|
30
|
-
variant?: BadgeVariant |
|
|
17
|
+
variant?: BadgeVariant | null
|
|
31
18
|
}
|
|
32
19
|
|
|
33
20
|
const baseClasses =
|
|
@@ -37,35 +24,7 @@ export function resolveBadgeStyle({
|
|
|
37
24
|
context,
|
|
38
25
|
variant,
|
|
39
26
|
}: BadgeStyleOptions = {}): { context: BadgeContext; variant: BadgeVariant } {
|
|
40
|
-
|
|
41
|
-
if ((variant as string) === 'danger') return { context: 'danger', variant: 'subtle' }
|
|
42
|
-
const legacy =
|
|
43
|
-
variant === 'default' ||
|
|
44
|
-
variant === 'secondary' ||
|
|
45
|
-
variant === 'destructive' ||
|
|
46
|
-
variant === 'success' ||
|
|
47
|
-
variant === 'warning' ||
|
|
48
|
-
variant === 'info'
|
|
49
|
-
if (context != null && legacy) {
|
|
50
|
-
throw new Error('Badge não permite combinar context com uma variante semântica legada.')
|
|
51
|
-
}
|
|
52
|
-
switch (variant) {
|
|
53
|
-
case 'default':
|
|
54
|
-
return { context: 'primary', variant: 'solid' }
|
|
55
|
-
case 'secondary':
|
|
56
|
-
return { context: 'neutral', variant: 'solid' }
|
|
57
|
-
case 'destructive':
|
|
58
|
-
return { context: 'danger', variant: 'solid' }
|
|
59
|
-
case 'success':
|
|
60
|
-
case 'warning':
|
|
61
|
-
case 'info':
|
|
62
|
-
return { context: variant, variant: 'subtle' }
|
|
63
|
-
default:
|
|
64
|
-
return {
|
|
65
|
-
context: context ?? 'neutral',
|
|
66
|
-
variant: variant ?? 'subtle',
|
|
67
|
-
}
|
|
68
|
-
}
|
|
27
|
+
return { context: context ?? 'neutral', variant: variant ?? 'subtle' }
|
|
69
28
|
}
|
|
70
29
|
|
|
71
30
|
/** Classes do Badge. Contexto seleciona significado; variante seleciona o tratamento visual. */
|
|
@@ -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}
|