@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +63 -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 +3 -0
  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/data-layer.md +9 -0
  12. package/package.json +1 -1
  13. package/registry/skills/build-opus-ui/SKILL.md +3 -2
  14. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -6
  15. package/src/ui/components/patterns/list.tsx +41 -26
  16. package/src/ui/components/patterns/page-state.tsx +48 -6
  17. package/src/ui/components/patterns/page.tsx +221 -55
  18. package/src/ui/components/patterns/state-surface.tsx +137 -23
  19. package/src/ui/components/patterns/surface-header.tsx +102 -17
  20. package/src/ui/components/patterns/trigger.tsx +7 -6
  21. package/src/ui/components/primitives/button-group.tsx +34 -8
  22. package/src/ui/components/primitives/control.ts +12 -3
  23. package/src/ui/docs/content/action-list-dialog.md +1 -1
  24. package/src/ui/docs/content/action-list.md +34 -3
  25. package/src/ui/docs/content/action-trigger.md +4 -3
  26. package/src/ui/docs/content/alert.md +16 -4
  27. package/src/ui/docs/content/button.md +20 -5
  28. package/src/ui/docs/content/content.md +3 -3
  29. package/src/ui/docs/content/data-state.md +4 -4
  30. package/src/ui/docs/content/page.md +159 -44
  31. package/src/ui/meta.ts +6 -6
  32. 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 é `Alert context="danger"` com o botão de recuperação;
6
- * vazio compõe `Empty*`. Os patterns públicos escolhem o enquadramento (bloco, linha de tabela,
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({ title, description, className }: LoadingSurfaceProps): ReactElement {
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('flex flex-col items-center justify-center gap-3 text-center text-muted-foreground', className)}
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 data-slot="state-title" className="text-sm font-medium text-foreground">
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 data-slot="state-description" className="text-sm text-muted-foreground">
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`, o botão entra nas ações do Alert. */
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
- <Alert
88
- context="danger"
89
- icon={icon ?? <TriangleAlert />}
90
- title={title}
91
- description={description}
92
- className={className}
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
- {onRetry !== undefined && (
95
- <Button context="neutral" variant="outline" size="sm" onClick={() => void onRetry()}>
96
- {retryLabel}
97
- </Button>
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
- {children}
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 className={cn(compact && 'text-sm font-normal text-muted-foreground')}>{title}</EmptyTitle>
143
- {description !== undefined && <EmptyDescription>{description}</EmptyDescription>}
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, um contador opcional na mesma linha de base, uma descrição e uma região de ações
5
- * que vai para o extremo oposto em larguras amplas e empilha abaixo do contexto nas estreitas.
6
- * `PageHeader` e `ContentHeader` nomeiam os slots (`page-*`, `content-*`) e a hierarquia
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: ElementType;
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
- titles.length + counts.length + descriptions.length + actionSlots.length;
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" ? type : (type as { name?: string }).name ?? "";
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
- `${name} exige um ${label(slots.title)} e aceita no máximo um ${label(slots.description)}, ${label(slots.count)} e ${label(slots.actions)} como filhos diretos.`,
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
- actionSlots.length > 0 &&
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
- <div data-slot={`${slot}-heading`} className="min-w-0">
110
- <div className="flex items-baseline gap-2">
111
- {titles}
112
- {counts}
195
+ {contentClassName === undefined ? (
196
+ layout
197
+ ) : (
198
+ <div data-slot={`${slot}-header-content`} className={contentClassName}>
199
+ {layout}
113
200
  </div>
114
- {descriptions}
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-sm` (1.75rem) da escala:
9
- * é a ação que mora NO item (linha, card). Absorveu o antigo DeleteButton, que era este
10
- * 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.
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 botão é sempre `icon-sm`. */
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 = 'default',
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-sm' : size}
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
- "[&>*: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}
@@ -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-sm` é 1.75rem de propósito: é o degrau da ação que mora dentro de uma linha densa
12
- * (o que os consumidores forçavam com `size-7`), menor que o botão de texto `sm` ao lado.
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 `[&_svg:not([class*=size-])]:${controlGlyph[controlBase(size)]}`
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, repassados ao `ActionList` (e dele ao `DataState`). |
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; o botão de tentar de novo refaz a consulta. |
272
- | `retryLabel` | `string` | `'Tentar de novo'` | Rótulo do botão de recuperação. |
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
- | `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita) — apresentação de quem chama, como `cells`; cliques ali não disparam o `onRowClick`. |
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`. |