@softize/opus 27.1.0 → 28.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 (37) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/bin/lib/check.mjs +139 -30
  3. package/docs/adr/0015-action-size-follows-interaction-density.md +2 -0
  4. package/docs/adr/0029-page-header-is-the-single-page-introduction.md +3 -1
  5. package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +161 -0
  6. package/docs/adr/README.md +2 -0
  7. package/package.json +1 -1
  8. package/registry/skills/build-opus-ui/SKILL.md +11 -11
  9. package/registry/skills/build-opus-ui/references/evaluations.md +10 -0
  10. package/registry/skills/build-opus-ui/references/ui-patterns.md +17 -15
  11. package/src/ui/components/patterns/list.tsx +62 -53
  12. package/src/ui/components/patterns/page.tsx +146 -38
  13. package/src/ui/components/patterns/presentation.tsx +44 -59
  14. package/src/ui/components/patterns/structural-header.tsx +42 -6
  15. package/src/ui/components/patterns/surface-anatomy.ts +52 -22
  16. package/src/ui/components/patterns/surface.tsx +2 -3
  17. package/src/ui/components/primitives/control.ts +2 -2
  18. package/src/ui/components/primitives/dialog.tsx +7 -2
  19. package/src/ui/components/primitives/drawer.tsx +7 -2
  20. package/src/ui/components/primitives/icon-picker.tsx +271 -34
  21. package/src/ui/components/primitives/pagination.tsx +5 -4
  22. package/src/ui/components/primitives/tabs.tsx +15 -2
  23. package/src/ui/components/primitives/toggle.tsx +1 -1
  24. package/src/ui/docs/content/action-list.md +11 -2
  25. package/src/ui/docs/content/button.md +7 -7
  26. package/src/ui/docs/content/compositions.md +35 -23
  27. package/src/ui/docs/content/dialog.md +18 -6
  28. package/src/ui/docs/content/drawer.md +17 -5
  29. package/src/ui/docs/content/icon-picker.md +37 -19
  30. package/src/ui/docs/content/input.md +1 -1
  31. package/src/ui/docs/content/page.md +169 -69
  32. package/src/ui/docs/content/pagination.md +7 -8
  33. package/src/ui/docs/content/presentation.md +4 -3
  34. package/src/ui/docs/content/surface.md +8 -5
  35. package/src/ui/docs/content/tabs.md +12 -4
  36. package/src/ui/meta.ts +1 -1
  37. package/src/ui/react.tsx +9 -1
@@ -7,10 +7,10 @@ import {
7
7
  Briefcase,
8
8
  Building2,
9
9
  Calendar,
10
+ Car,
10
11
  ChartBar,
11
12
  ChartLine,
12
13
  ChartPie,
13
- Check,
14
14
  ChevronsUpDown,
15
15
  ClipboardList,
16
16
  Clock,
@@ -71,6 +71,7 @@ export const iconPickerIcons: Record<string, LucideIcon> = {
71
71
  briefcase: Briefcase,
72
72
  building: Building2,
73
73
  calendar: Calendar,
74
+ car: Car,
74
75
  "chart-bar": ChartBar,
75
76
  "chart-line": ChartLine,
76
77
  "chart-pie": ChartPie,
@@ -110,12 +111,111 @@ export const iconPickerIcons: Record<string, LucideIcon> = {
110
111
  zap: Zap,
111
112
  };
112
113
 
114
+ /** Rótulos e termos de busca separados dos identificadores persistidos. */
115
+ export const iconPickerSearch: Record<
116
+ string,
117
+ { label: string; keywords: string[] }
118
+ > = {
119
+ car: { label: "Carro", keywords: ["veículo", "automóvel", "vendas"] },
120
+ activity: { label: "Atividade", keywords: ["pulso", "saúde"] },
121
+ banknote: { label: "Dinheiro", keywords: ["nota", "pagamento", "valor"] },
122
+ bell: { label: "Sino", keywords: ["alerta", "notificação"] },
123
+ box: { label: "Caixa", keywords: ["estoque", "produto"] },
124
+ briefcase: { label: "Maleta", keywords: ["trabalho", "negócio"] },
125
+ building: { label: "Edifício", keywords: ["empresa", "unidade", "prédio"] },
126
+ calendar: { label: "Calendário", keywords: ["agenda", "data", "evento"] },
127
+ "chart-bar": {
128
+ label: "Gráfico de barras",
129
+ keywords: ["relatório", "análise"],
130
+ },
131
+ "chart-line": {
132
+ label: "Gráfico de linhas",
133
+ keywords: ["evolução", "relatório"],
134
+ },
135
+ "chart-pie": {
136
+ label: "Gráfico de pizza",
137
+ keywords: ["percentual", "relatório"],
138
+ },
139
+ "clipboard-list": {
140
+ label: "Prancheta",
141
+ keywords: ["tarefas", "lista", "checklist"],
142
+ },
143
+ clock: { label: "Relógio", keywords: ["hora", "tempo", "prazo"] },
144
+ "credit-card": { label: "Cartão", keywords: ["crédito", "pagamento"] },
145
+ database: { label: "Banco de dados", keywords: ["base", "registros"] },
146
+ "file-text": { label: "Documento", keywords: ["página", "texto", "arquivo"] },
147
+ flag: { label: "Bandeira", keywords: ["marco", "sinalização"] },
148
+ folder: { label: "Pasta", keywords: ["diretório", "arquivos"] },
149
+ gauge: { label: "Medidor", keywords: ["velocidade", "desempenho"] },
150
+ globe: { label: "Globo", keywords: ["mundo", "público", "internet"] },
151
+ heart: { label: "Coração", keywords: ["favorito", "saúde"] },
152
+ layers: { label: "Camadas", keywords: ["coleção", "conjunto"] },
153
+ "layout-grid": { label: "Grade", keywords: ["cartões", "painel"] },
154
+ lightbulb: { label: "Lâmpada", keywords: ["ideia", "sugestão"] },
155
+ mail: {
156
+ label: "Envelope",
157
+ keywords: ["email", "e-mail", "correio", "mensagem"],
158
+ },
159
+ "map-pin": { label: "Localização", keywords: ["endereço", "mapa", "lugar"] },
160
+ "message-circle": {
161
+ label: "Conversa",
162
+ keywords: ["chat", "mensagem", "atendimento"],
163
+ },
164
+ "message-square": { label: "Mensagem", keywords: ["chat", "comentário"] },
165
+ package: { label: "Pacote", keywords: ["entrega", "produto", "encomenda"] },
166
+ phone: { label: "Telefone", keywords: ["ligação", "contato"] },
167
+ rocket: { label: "Foguete", keywords: ["lançamento", "inovação"] },
168
+ settings: { label: "Configurações", keywords: ["ajustes", "engrenagem"] },
169
+ "shopping-cart": { label: "Carrinho", keywords: ["compra", "venda"] },
170
+ star: { label: "Estrela", keywords: ["favorito", "destaque"] },
171
+ store: { label: "Loja", keywords: ["comércio", "unidade"] },
172
+ table: { label: "Tabela", keywords: ["planilha", "catálogo"] },
173
+ target: { label: "Alvo", keywords: ["meta", "objetivo"] },
174
+ "trending-down": { label: "Queda", keywords: ["redução", "tendência"] },
175
+ "trending-up": { label: "Crescimento", keywords: ["aumento", "tendência"] },
176
+ truck: {
177
+ label: "Caminhão",
178
+ keywords: ["transporte", "veículo", "logística"],
179
+ },
180
+ user: { label: "Pessoa", keywords: ["usuário", "cliente"] },
181
+ users: { label: "Pessoas", keywords: ["equipe", "grupo", "clientes"] },
182
+ wallet: { label: "Carteira", keywords: ["saldo", "financeiro"] },
183
+ wrench: { label: "Ferramenta", keywords: ["chave", "manutenção", "serviço"] },
184
+ zap: { label: "Raio", keywords: ["automação", "energia", "fluxo"] },
185
+ };
186
+
187
+ export interface IconCatalogEntry {
188
+ name: string;
189
+ label: string;
190
+ keywords: string[];
191
+ }
192
+ export interface IconCatalog {
193
+ load: () => Promise<IconCatalogEntry[]>;
194
+ render: (name: string, className: string) => React.ReactNode;
195
+ }
196
+ const IconCatalogContext = React.createContext<IconCatalog | null>(null);
197
+ export function IconCatalogProvider({
198
+ catalog,
199
+ children,
200
+ }: {
201
+ catalog: IconCatalog;
202
+ children: React.ReactNode;
203
+ }) {
204
+ return (
205
+ <IconCatalogContext.Provider value={catalog}>
206
+ {children}
207
+ </IconCatalogContext.Provider>
208
+ );
209
+ }
210
+
113
211
  export interface IconPickerProps {
114
212
  /** Nome do ícone selecionado (chave da paleta) — '' = nenhum. */
115
213
  value: string;
116
214
  onChange: (name: string) => void;
117
215
  /** Paleta nome → componente. Default: `iconPickerIcons` (curada). */
118
216
  icons?: Record<string, LucideIcon>;
217
+ /** Rótulos e sinônimos para busca; não alteram o valor persistido. */
218
+ searchTerms?: typeof iconPickerSearch;
119
219
  placeholder?: string;
120
220
  disabled?: boolean;
121
221
  className?: string;
@@ -133,7 +233,8 @@ export interface IconPickerProps {
133
233
  export function IconPicker({
134
234
  value,
135
235
  onChange,
136
- icons = iconPickerIcons,
236
+ icons: providedIcons,
237
+ searchTerms = iconPickerSearch,
137
238
  placeholder = "Selecione um ícone…",
138
239
  disabled,
139
240
  className,
@@ -141,11 +242,80 @@ export function IconPicker({
141
242
  "aria-invalid": ariaInvalid,
142
243
  "aria-describedby": ariaDescribedBy,
143
244
  }: IconPickerProps): React.ReactElement {
245
+ const contextCatalog = React.useContext(IconCatalogContext);
246
+ const catalog = providedIcons ? null : contextCatalog;
247
+ const icons = providedIcons ?? iconPickerIcons;
248
+ const [entries, setEntries] = React.useState<IconCatalogEntry[] | null>(null);
249
+ const [loadError, setLoadError] = React.useState(false);
250
+ const [attempt, setAttempt] = React.useState(0);
251
+ const [query, setQuery] = React.useState("");
252
+ const [limit, setLimit] = React.useState(64);
253
+ const listRef = React.useRef<HTMLDivElement>(null);
144
254
  const [open, setOpen] = React.useState(false);
145
- const Current = value !== "" ? icons[value] : undefined;
255
+ const Current = Object.hasOwn(icons, value) ? icons[value] : undefined;
256
+ const [active, setActive] = React.useState("");
257
+ const loadedCatalog = React.useRef<typeof catalog>(null);
258
+ React.useEffect(() => {
259
+ if (!open || !catalog || loadedCatalog.current === catalog) return;
260
+ setEntries(null);
261
+ let cancelled = false;
262
+ setLoadError(false);
263
+ catalog
264
+ .load()
265
+ .then((items) => {
266
+ if (!cancelled) {
267
+ loadedCatalog.current = catalog;
268
+ setEntries(items);
269
+ }
270
+ })
271
+ .catch(() => {
272
+ if (!cancelled) setLoadError(true);
273
+ });
274
+ return () => {
275
+ cancelled = true;
276
+ };
277
+ }, [open, catalog, attempt]);
278
+ const normalize = (text: string) =>
279
+ text
280
+ .normalize("NFD")
281
+ .replace(/\p{Diacritic}/gu, "")
282
+ .toLocaleLowerCase("pt-BR");
283
+ const available = catalog
284
+ ? loadedCatalog.current === catalog
285
+ ? (entries ?? [])
286
+ : []
287
+ : Object.keys(icons).map((name) => ({
288
+ name,
289
+ label: searchTerms[name]?.label ?? name,
290
+ keywords: searchTerms[name]?.keywords ?? [],
291
+ }));
292
+ const filtered = available.filter((entry) =>
293
+ normalize(query)
294
+ .trim()
295
+ .split(/\s+/)
296
+ .every((term) =>
297
+ normalize(
298
+ [entry.name, entry.label, ...entry.keywords].join(" "),
299
+ ).includes(term),
300
+ ),
301
+ );
302
+ const renderIcon = (name: string, className: string) => {
303
+ if (catalog) return catalog.render(name, className);
304
+ const Icon = Object.hasOwn(icons, name) ? icons[name] : undefined;
305
+ return Icon ? <Icon className={className} aria-hidden="true" /> : null;
306
+ };
146
307
 
147
308
  return (
148
- <Popover open={open} onOpenChange={setOpen}>
309
+ <Popover
310
+ open={open}
311
+ onOpenChange={(next) => {
312
+ setOpen(next);
313
+ if (next) {
314
+ setQuery("");
315
+ setLimit(64);
316
+ }
317
+ }}
318
+ >
149
319
  <PopoverTrigger asChild>
150
320
  <button
151
321
  type="button"
@@ -164,11 +334,13 @@ export function IconPicker({
164
334
  className,
165
335
  )}
166
336
  >
167
- {Current !== undefined ? (
337
+ {(catalog && value) || Current !== undefined ? (
168
338
  <>
169
- <Current className="size-4 shrink-0" />
170
- <span data-slot="icon-picker-value" className="truncate">
171
- {value}
339
+ {renderIcon(value, "size-4 shrink-0")}
340
+ <span data-slot="icon-picker-value" className="sr-only">
341
+ {available.find((entry) => entry.name === value)?.label ??
342
+ searchTerms[value]?.label ??
343
+ "Ícone selecionado"}
172
344
  </span>
173
345
  </>
174
346
  ) : (
@@ -179,33 +351,98 @@ export function IconPicker({
179
351
  </PopoverTrigger>
180
352
  <PopoverContent
181
353
  align="start"
182
- className="pointer-events-auto w-(--radix-popover-trigger-width) p-0"
354
+ className="pointer-events-auto w-96 max-w-[calc(100vw-2rem)] p-0"
355
+ onWheel={(event) => event.stopPropagation()}
356
+ onTouchMove={(event) => event.stopPropagation()}
183
357
  >
184
- <Command>
185
- <CommandInput placeholder="Buscar ícone…" />
186
- <CommandList data-slot="icon-picker-list" className="max-h-60">
187
- <CommandEmpty>Nada encontrado.</CommandEmpty>
188
- <CommandGroup>
189
- {Object.entries(icons).map(([name, Icon]) => (
190
- <CommandItem
191
- key={name}
192
- data-slot="icon-picker-item"
193
- value={name}
194
- onSelect={() => {
195
- onChange(name === value ? "" : name);
196
- setOpen(false);
197
- }}
198
- >
199
- <Icon className="size-4" />
200
- <span className="truncate">{name}</span>
201
- <Check
202
- className={cn(
203
- "ml-auto size-4 shrink-0",
204
- value === name ? "opacity-100" : "opacity-0",
205
- )}
206
- />
207
- </CommandItem>
208
- ))}
358
+ <Command
359
+ shouldFilter={false}
360
+ value={active}
361
+ onValueChange={setActive}
362
+ onKeyDown={(event) => {
363
+ const step =
364
+ event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
365
+ if (!step) return;
366
+ event.preventDefault();
367
+ const items = Array.from(
368
+ event.currentTarget.querySelectorAll<HTMLElement>(
369
+ "[cmdk-item]:not([hidden])",
370
+ ),
371
+ );
372
+ const index = items.findIndex(
373
+ (item) => item.dataset.value === active,
374
+ );
375
+ const next =
376
+ items[Math.max(0, Math.min(items.length - 1, index + step))];
377
+ if (next) {
378
+ setActive(next.dataset.value ?? "");
379
+ next.scrollIntoView({ block: "nearest" });
380
+ }
381
+ }}
382
+ >
383
+ <CommandInput
384
+ placeholder="Buscar ícone…"
385
+ value={query}
386
+ onValueChange={(next) => {
387
+ if (listRef.current) listRef.current.scrollTop = 0;
388
+ setQuery(next);
389
+ setLimit(64);
390
+ }}
391
+ />
392
+ {catalog && !entries && !loadError ? (
393
+ <div role="status" className="p-4 text-sm">
394
+ Carregando ícones…
395
+ </div>
396
+ ) : loadError ? (
397
+ <div role="alert" className="p-4 text-sm">
398
+ Não foi possível carregar os ícones.{" "}
399
+ <button
400
+ type="button"
401
+ className="underline"
402
+ onClick={() => setAttempt((value) => value + 1)}
403
+ >
404
+ Tentar novamente
405
+ </button>
406
+ </div>
407
+ ) : null}
408
+ <CommandList
409
+ ref={listRef}
410
+ onScroll={(event) => {
411
+ const list = event.currentTarget;
412
+ if (
413
+ list.scrollHeight - list.scrollTop - list.clientHeight <=
414
+ list.clientHeight / 2
415
+ ) {
416
+ setLimit((count) => Math.min(count + 64, filtered.length));
417
+ }
418
+ }}
419
+ data-slot="icon-picker-list"
420
+ className="max-h-60 overscroll-contain"
421
+ >
422
+ {!loadError && (!catalog || entries) ? (
423
+ <CommandEmpty>Nada encontrado.</CommandEmpty>
424
+ ) : null}
425
+ <CommandGroup className="[&_[cmdk-group-items]]:grid [&_[cmdk-group-items]]:grid-cols-8 [&_[cmdk-group-items]]:gap-1">
426
+ {filtered.slice(0, limit).map(({ name, label }) => {
427
+ return (
428
+ <CommandItem
429
+ key={name}
430
+ data-slot="icon-picker-item"
431
+ value={name}
432
+ aria-label={label}
433
+ data-current={name === value}
434
+ className="h-10 justify-center p-0 data-[current=true]:ring-1 data-[current=true]:ring-inset data-[current=true]:ring-primary"
435
+ onSelect={() => {
436
+ onChange(name === value ? "" : name);
437
+ setOpen(false);
438
+ }}
439
+ >
440
+ <span className="flex h-full w-full items-center justify-center">
441
+ {renderIcon(name, "size-5")}
442
+ </span>
443
+ </CommandItem>
444
+ );
445
+ })}
209
446
  </CommandGroup>
210
447
  </CommandList>
211
448
  </Command>
@@ -3,7 +3,7 @@ import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-re
3
3
 
4
4
  import { cn } from '../../lib/cn.ts'
5
5
  import { buttonVariants, type ButtonProps } from './button.tsx'
6
- import { controlSquare, type ControlIconSize } from './control.ts'
6
+ import { controlBase, controlSquare, type ControlIconSize } from './control.ts'
7
7
 
8
8
  function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
9
9
  return (
@@ -46,12 +46,13 @@ export interface PaginationLinkProps extends Omit<React.ComponentProps<'a'>, 'ch
46
46
 
47
47
  /**
48
48
  * Um link de página. Altura fixa, largura mínima quadrada e largura que cresce com o
49
- * número (`min-w-9 w-auto px-2`, dígitos tabulares): 5726 e 5727 nunca se sobrepõem. Com
49
+ * número (largura mínima igual à altura do tamanho escolhido, dígitos tabulares): 5726 e 5727 nunca se sobrepõem. Com
50
50
  * `href` é `<a>`; sem `href` é `<button type="button">`, que preserva foco e teclado no
51
- * modo controlado. Ajuste a escala por `className` (ex.: `h-7 min-w-7 text-xs`).
51
+ * modo controlado. Ajuste a escala por `size`; a largura mínima acompanha a altura do degrau.
52
52
  */
53
53
  function PaginationLink({ className, isActive, page, disabled, href, size = 'default', children, ...props }: PaginationLinkProps) {
54
54
  const square = typeof size === 'string' && size.startsWith('icon')
55
+ const minWidth = { xs: 'min-w-6', sm: 'min-w-8', default: 'min-w-9', lg: 'min-w-10' }[controlBase(size)]
55
56
  const shared = {
56
57
  'aria-current': isActive ? ('page' as const) : undefined,
57
58
  'aria-label': props['aria-label'] ?? (page !== undefined ? `Página ${page}` : undefined),
@@ -61,7 +62,7 @@ function PaginationLink({ className, isActive, page, disabled, href, size = 'def
61
62
  buttonVariants({ variant: isActive ? 'outline' : 'ghost', size }),
62
63
  // Número: a largura mínima é quadrada e o conteúdo manda no resto. Escala `icon*`
63
64
  // fica quadrada de verdade (sem `has-[>svg]:px-*` do `default`).
64
- !square && 'h-9 w-auto min-w-9 px-2 tabular-nums',
65
+ !square && cn('w-auto px-2 tabular-nums', minWidth),
65
66
  className,
66
67
  ),
67
68
  }
@@ -2,6 +2,10 @@ import * as React from "react";
2
2
  import { Tabs as TabsPrimitive } from "radix-ui";
3
3
 
4
4
  import { cn } from "../../lib/cn.ts";
5
+ import {
6
+ structuralBodyRhythm,
7
+ structuralRegionGap,
8
+ } from "../patterns/surface-anatomy.ts";
5
9
  import { controlHeight, type ControlSize } from "./control.ts";
6
10
 
7
11
  // Divergência da casa (ejetado): o Tabs ganhou `size` — Button e Select têm `sm`, o Tabs
@@ -45,8 +49,10 @@ function Tabs({
45
49
  data-framed={framed}
46
50
  orientation={orientation}
47
51
  className={cn(
48
- "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
52
+ "group/tabs flex data-[orientation=horizontal]:flex-col",
53
+ structuralRegionGap,
49
54
  framed && "overflow-hidden rounded-xl border",
55
+ framed && orientation === "vertical" && "p-4",
50
56
  className,
51
57
  )}
52
58
  {...props}
@@ -192,10 +198,17 @@ function TabsPanel({
192
198
  className,
193
199
  ...props
194
200
  }: React.ComponentProps<typeof TabsPrimitive.Content>) {
201
+ const framed = React.useContext(TabsFramedContext);
202
+ const orientation = React.useContext(TabsOrientationContext);
195
203
  return (
196
204
  <TabsPrimitive.Content
197
205
  data-slot="tabs-panel"
198
- className={cn("flex-1 outline-none", className)}
206
+ className={cn(
207
+ "flex-1 outline-none data-[state=inactive]:hidden",
208
+ structuralBodyRhythm,
209
+ framed && orientation === "horizontal" && "px-4 pb-4",
210
+ className,
211
+ )}
199
212
  {...props}
200
213
  />
201
214
  );
@@ -19,7 +19,7 @@ const toggleVariants = cva(
19
19
  variant: {
20
20
  default: "bg-transparent",
21
21
  outline:
22
- "border border-input bg-transparent text-muted-foreground hover:bg-interaction-hover hover:text-foreground active:bg-interaction-pressed data-[state=on]:bg-interaction-selected data-[state=on]:text-foreground",
22
+ "border border-input bg-transparent text-muted-foreground hover:bg-interaction-hover/50 hover:text-foreground active:bg-interaction-pressed data-[state=on]:bg-interaction-selected/50 data-[state=on]:text-foreground",
23
23
  },
24
24
  // Alturas da escala de control.ts (`sm` = o mesmo h-8 de Button e Select).
25
25
  size: {
@@ -536,7 +536,7 @@ render(
536
536
  | `text` | `{ fields }` — liga a busca; convenção: param `q` no input. Ela aparece depois dos filtros inline e imediatamente antes do acesso aos filtros avançados. A busca participa da contagem de controles inline: com mais de um controle, a barra exibe as labels; quando ela é o único recorte, sua label é omitida. |
537
537
  | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
538
538
  | `periods` | `{ value, label, default? }[]` — o controle de período (presets + Personalizado com calendário); `default: true` marca o preset inicial, senão vale o primeiro; materializa em `from`/`to` no input. |
539
- | `grouping` | `{ fields }` — dimensões enumeráveis disponíveis em **Agrupar por**. Cada nome referencia um filtro `select` ou `lookup` com até 12 opções declaradas ou resolvidas por `filterOptions`. Não define agrupamento inicial. |
539
+ | `grouping` | `{ fields }` — dimensões enumeráveis disponíveis em **Agrupar por**. Cada nome referencia um filtro `select` ou `lookup` com até 12 opções declaradas ou resolvidas por `filterOptions`. Não define agrupamento inicial. |
540
540
 
541
541
  ## useListAction
542
542
 
@@ -547,6 +547,14 @@ declarativo (`items`, `total`, `error`, `isLoading`, `isFetching`, `refetch`) so
547
547
  chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem a opção, vale
548
548
  `true`.
549
549
 
550
+ ## Controles da visualização
551
+
552
+ Controles específicos da visualização ativa, como a orientação de um comparativo, usam
553
+ `viewControls`. O slot fica imediatamente após o seletor de visualização. As configurações da lista
554
+ aparecem antes desse conjunto. O consumidor controla seu estado e quando exibi-lo. Isso mantém a
555
+ configuração visual junto à escolha da visualização, sem deslocar ações sobre o recorte
556
+ para fora de `toolbarActions`.
557
+
550
558
  ## Propriedades de ActionList
551
559
 
552
560
  | Propriedade | Tipo | Padrão | Descrição |
@@ -555,7 +563,7 @@ chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem
555
563
  | `input` | `TInput` | | O escopo base, como `{ workspaceId }`. A toolbar acrescenta busca, filtros e paginação sem sobrescrever essas chaves. |
556
564
  | `layout` | `'flow' \| 'fill'` | `'fill'` como filha direta de `PageBody`, `SurfaceBody` ou do corpo da `Presentation` em uma página `fill`; `'flow'` nos demais casos | Em `fill`, ocupa a altura de um ancestral limitado e rola somente a região de resultados; na tabela, o cabeçalho fica fixo. Sem ancestral limitado, equivale a `flow`. |
557
565
  | `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
558
- | `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para filtros select/lookup. Em uma dimensão de agrupamento, a coleção precisa ser completa e ter de 1 a 12 itens. |
566
+ | `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para filtros select/lookup. Em uma dimensão de agrupamento, a coleção precisa ser completa e ter de 1 a 12 itens. |
559
567
  | `filterDefaults` | `Record<string, string>` | | Valores de repouso aplicados à consulta; o `X` só aparece quando o filtro diverge deles e retorna a eles. |
560
568
  | `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
561
569
  | `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode>; sectionVariant?: 'plain' \| 'framed' }` | `{ columns: 1, sectionVariant: 'plain' }` | Define as colunas, os ícones e o tratamento dos grupos; o Opus deriva a largura correspondente do diálogo. |
@@ -575,5 +583,6 @@ chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem
575
583
  | `errorMessage` | `string` | `'Não foi possível carregar.'` | Título do aviso de erro; a ação de tentar de novo refaz a consulta. |
576
584
  | `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
577
585
  | `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
586
+ | `viewControls` | `ReactNode` | | Controles da visualização ativa, imediatamente após o seletor de visualização. |
578
587
  | `toolbarActions` | `ReactNode` | | Operações ligadas ao recorte atual, no fim da barra e separadas do grupo de controles. Os nós são renderizados como recebidos: nome acessível e tooltip de uma ação somente com ícone ficam com o consumidor. |
579
588
  | `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita). A partir de duas, entram em um grupo com intervalo compacto; uma só fica solta, sem grupo. Cliques ali não disparam o `onRowClick`. |
@@ -32,22 +32,22 @@ há texto visível. O glifo dentro do controle acompanha o tamanho (0.875rem em
32
32
  | Nome | Medida | Uso |
33
33
  | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
34
34
  | `xs` | 1.5rem | Ação dentro de um campo (`InputGroupButton`). |
35
- | `sm` | 2rem | Operação densa em toolbar ou dentro de uma seção, ao lado de `Select` e `Tabs` `sm`. |
36
- | `default` | 2.25rem | Decisão da superfície: header de Page, criação no cabeçalho de uma coleção e footer de Dialog ou Drawer; é também a linha de `Input`. |
35
+ | `sm` | 2rem | Headers, criação de coleções e operações densas de toolbar ou seção. |
36
+ | `default` | 2.25rem | Footers de Dialog e Drawer e controles comuns; é também a linha de `Input`. |
37
37
  | `lg` | 2.5rem | Chamada principal com mais área de toque. |
38
38
  | `icon-xs` | 1.5rem | Ação só de ícone dentro de um campo ou de uma linha densa; é o quadrado de `ActionTrigger` com `icon`. |
39
- | `icon-sm` | 1.75rem | Ação só de ícone em composições compactas que precisam de mais presença; também é o quadrado das setas do pager de `ActionList`. |
39
+ | `icon-sm` | 2rem | Ação só de ícone em composições compactas que precisam de mais presença; também é o quadrado das setas do pager de `ActionList`. |
40
40
  | `icon` | 2.25rem | Ação só de ícone na linha padrão. |
41
41
  | `icon-lg` | 2.5rem | Ação só de ícone ao lado de um `lg`. |
42
42
 
43
43
  Escolha o tamanho pela região, não pela importância visual: `variant` e `context` resolvem a
44
- hierarquia da ação. Headers de Page, a criação no cabeçalho de uma coleção e footers de
45
- [`Dialog`](/ui/dialog) e [`Drawer`](/ui/drawer) usam `default`; a criação de uma coleção é um botão textual, sem
46
- ícone, como mostra [`Surface`](/ui/surface). Operações densas de toolbar ou de seção usam `sm`;
44
+ hierarquia da ação. Headers de Page, Dialog, Drawer e Surface usam `sm`/`icon-sm`. A criação
45
+ no cabeçalho de uma coleção usa `sm` textual, sem ícone. Footers de
46
+ [`Dialog`](/ui/dialog) e [`Drawer`](/ui/drawer) conservam `default`. Operações densas de toolbar ou de seção usam `sm`;
47
47
  ações dentro de linha ou célula usam `xs` ou `icon-xs`. Assim a mesma decisão mantém a mesma altura
48
48
  mesmo quando uma superfície troca uma ação secundária por uma primária.
49
49
 
50
- O retorno de `PageBack` usa `icon`. O fechamento de Dialog e Drawer é um `CloseButton` circular
50
+ O retorno de `PageBack` usa `icon-sm`. O fechamento de Dialog e Drawer é um `CloseButton` circular
51
51
  (`shape="pill"`), `neutral` e `subtle`, no quadrado `icon-sm` por padrão, ou `icon-xs` com
52
52
  `closeSize="xs"`.
53
53