@softize/opus 13.1.0 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/cli.mjs +2 -0
  3. package/bin/lib/check.mjs +33 -5
  4. package/bin/lib/cli-shared.mjs +30 -1
  5. package/bin/lib/copy.mjs +279 -6
  6. package/bin/lib/db.mjs +2 -0
  7. package/docs/adr/0004-page-content-state-is-composed.md +39 -5
  8. package/docs/adr/0005-structural-surfaces-share-an-explicit-anatomy.md +9 -3
  9. package/docs/adr/0009-page-title-does-not-carry-a-counter.md +57 -0
  10. package/docs/adr/0010-page-header-owns-page-chrome.md +73 -0
  11. package/docs/code-style.md +4 -1
  12. package/docs/data-layer.md +9 -0
  13. package/package.json +1 -1
  14. package/registry/instructions/opus.md +3 -3
  15. package/registry/skills/build-opus-ui/SKILL.md +3 -2
  16. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
  17. package/registry/templates/app/src/App.tsx +11 -6
  18. package/src/core/types.ts +3 -4
  19. package/src/ui/components/patterns/action-list-dialog.tsx +10 -3
  20. package/src/ui/components/patterns/confirm.tsx +2 -31
  21. package/src/ui/components/patterns/content-header.tsx +42 -141
  22. package/src/ui/components/patterns/data-state.tsx +42 -68
  23. package/src/ui/components/patterns/form.tsx +15 -15
  24. package/src/ui/components/patterns/list.tsx +55 -34
  25. package/src/ui/components/patterns/page-state.tsx +81 -51
  26. package/src/ui/components/patterns/page.tsx +228 -97
  27. package/src/ui/components/patterns/state-surface.tsx +262 -0
  28. package/src/ui/components/patterns/surface-header.tsx +204 -0
  29. package/src/ui/components/patterns/trigger.tsx +9 -10
  30. package/src/ui/components/patterns/view.tsx +14 -16
  31. package/src/ui/components/primitives/alert.tsx +1 -33
  32. package/src/ui/components/primitives/avatar.tsx +15 -5
  33. package/src/ui/components/primitives/badge.tsx +2 -43
  34. package/src/ui/components/primitives/button-group.tsx +34 -8
  35. package/src/ui/components/primitives/button.tsx +31 -35
  36. package/src/ui/components/primitives/control.ts +69 -0
  37. package/src/ui/components/primitives/dot.tsx +1 -30
  38. package/src/ui/components/primitives/input-group.tsx +11 -8
  39. package/src/ui/components/primitives/item.tsx +3 -1
  40. package/src/ui/components/primitives/menu.tsx +1 -7
  41. package/src/ui/components/primitives/pagination.tsx +16 -8
  42. package/src/ui/components/primitives/select.tsx +2 -2
  43. package/src/ui/components/primitives/spinner.tsx +13 -16
  44. package/src/ui/components/primitives/switch.tsx +4 -1
  45. package/src/ui/components/primitives/tabs.tsx +5 -3
  46. package/src/ui/components/primitives/toggle.tsx +9 -4
  47. package/src/ui/docs/content/action-form.md +26 -0
  48. package/src/ui/docs/content/action-list-dialog.md +2 -2
  49. package/src/ui/docs/content/action-list.md +35 -2
  50. package/src/ui/docs/content/action-trigger.md +5 -4
  51. package/src/ui/docs/content/action-view.md +3 -2
  52. package/src/ui/docs/content/alert.md +16 -4
  53. package/src/ui/docs/content/avatar.md +7 -3
  54. package/src/ui/docs/content/button.md +48 -17
  55. package/src/ui/docs/content/communication.md +36 -0
  56. package/src/ui/docs/content/content.md +5 -4
  57. package/src/ui/docs/content/data-state.md +17 -13
  58. package/src/ui/docs/content/dialog.md +1 -4
  59. package/src/ui/docs/content/input.md +1 -1
  60. package/src/ui/docs/content/item.md +1 -1
  61. package/src/ui/docs/content/page.md +160 -37
  62. package/src/ui/docs/content/pagination.md +11 -9
  63. package/src/ui/docs/content/semantic-context.md +3 -2
  64. package/src/ui/docs/content/sidebar.md +2 -42
  65. package/src/ui/docs/content/spinner.md +9 -6
  66. package/src/ui/docs/content/switch.md +1 -1
  67. package/src/ui/docs/content/tabs.md +1 -1
  68. package/src/ui/docs/content/toggle.md +1 -1
  69. package/src/ui/drivers/react.tsx +1 -6
  70. package/src/ui/meta.ts +8 -8
  71. package/src/ui/react.tsx +16 -18
  72. package/src/ui/components/patterns/shell-nav.tsx +0 -154
@@ -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: é a ação que mora NO item (linha,
9
- * card). Absorveu o antigo DeleteButton, que era este componente com uma lixeira.
8
+ * - `icon` faz o botão virar icon-only com tooltip, no quadrado `icon-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?: 'default' | 'sm' | 'lg' | 'icon'
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 = 'default',
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' : size}
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 { Button } from '../primitives/button.tsx'
14
+ import { EmptySurface, ErrorSurface } from './state-surface.tsx'
16
15
 
17
16
  export interface ActionViewProps<TInput, TData> {
18
17
  action: ViewAction<TInput, TData>
@@ -21,14 +20,16 @@ export interface ActionViewProps<TInput, TData> {
21
20
  children?: (data: TData, refetch: () => Promise<void>) => ReactNode
22
21
  /** Alias de children (a API original). Children tem precedência. */
23
22
  render?: (data: TData, refetch: () => Promise<void>) => ReactNode
24
- /** Renderiza estado vazio (200 mas sem dado). Default: nada. */
23
+ /** Renderiza estado vazio (200 mas sem dado). Default: `emptyMessage` ou nada. */
25
24
  empty?: ReactNode
25
+ /** Atalho do vazio: a frase na mesma superfície de DataState (Empty com moldura sólida). */
26
+ emptyMessage?: string
26
27
  /** Renderiza loading. Default (ou `true`): 3 skeletons empilhados; `false` não renderiza
27
28
  * nada enquanto carrega — o contêiner `data-action` permanece. */
28
29
  loading?: ReactNode | boolean
29
- /** Renderiza erro. Default: "Não foi possível carregar" + botão "Tentar de novo" (omitido em
30
- * falta de permissão ou sessão); a frase do servidor entra como descrição só quando está na
31
- * allowlist de lib/action-errors.ts. */
30
+ /** Renderiza erro. Default: a mesma superfície de DataState — "Não foi possível carregar" +
31
+ * "Tentar de novo" (omitido em falta de permissão ou sessão); a frase do servidor entra como
32
+ * descrição só quando está na allowlist de lib/action-errors.ts. */
32
33
  error?: (err: { code: string; message: string; category?: string }, retry: () => Promise<void>) => ReactNode
33
34
  }
34
35
 
@@ -38,6 +39,7 @@ export function ActionView<TInput, TData>({
38
39
  children,
39
40
  render,
40
41
  empty,
42
+ emptyMessage,
41
43
  loading,
42
44
  error,
43
45
  }: ActionViewProps<TInput, TData>) {
@@ -66,23 +68,19 @@ export function ActionView<TInput, TData>({
66
68
  }
67
69
  return (
68
70
  <div data-action={action.name}>
69
- <Alert
70
- context="danger"
71
+ <ErrorSurface
71
72
  title="Não foi possível carregar"
72
73
  description={isBusinessError(err) ? err.message.trim() : undefined}
73
- >
74
- {isRetryable(err) && (
75
- <Button variant="outline" size="sm" onClick={() => void refetch()}>
76
- Tentar de novo
77
- </Button>
78
- )}
79
- </Alert>
74
+ {...(isRetryable(err) ? { onRetry: () => refetch() } : {})}
75
+ />
80
76
  </div>
81
77
  )
82
78
  }
83
79
 
84
80
  if (data === undefined) {
85
- return <div data-action={action.name}>{empty ?? null}</div>
81
+ const emptyNode =
82
+ empty ?? (emptyMessage !== undefined ? <EmptySurface title={emptyMessage} compact frame="structural" /> : null)
83
+ return <div data-action={action.name}>{emptyNode}</div>
86
84
  }
87
85
 
88
86
  return <div data-action={action.name}>{renderData(data, refetch)}</div>
@@ -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 | LegacyAlertVariant | null;
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?: "default" | "sm" | "lg"
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 size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
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=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
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-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
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 | LegacyBadgeVariant | null
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
- // `danger` não é alias: é um contexto que chegou pela prop errada. Segue como contexto.
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
- "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
14
- vertical:
15
- "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
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
- orientation === 'vertical'
56
- ? '[&>[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'
57
- : '[&>[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',
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}