@softize/opus 18.1.1 → 18.3.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.
@@ -22,20 +22,41 @@ function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
22
22
  function FieldLegend({
23
23
  className,
24
24
  variant = "legend",
25
+ icon,
26
+ children,
25
27
  ...props
26
- }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
28
+ }: React.ComponentProps<"legend"> & {
29
+ variant?: "legend" | "label" | "section"
30
+ icon?: React.ReactNode
31
+ }) {
27
32
  return (
28
33
  <legend
29
34
  data-slot="field-legend"
30
35
  data-variant={variant}
31
36
  className={cn(
32
37
  "mb-3 font-medium",
38
+ icon !== undefined && "flex items-center gap-2",
33
39
  "data-[variant=legend]:text-base",
34
40
  "data-[variant=label]:text-sm",
41
+ "data-[variant=section]:w-full data-[variant=section]:border-b data-[variant=section]:border-border/70 data-[variant=section]:pb-2 data-[variant=section]:text-sm data-[variant=section]:font-semibold",
35
42
  className
36
43
  )}
37
44
  {...props}
38
- />
45
+ >
46
+ {icon !== undefined ? (
47
+ <span
48
+ data-slot="field-legend-icon"
49
+ aria-hidden="true"
50
+ className={cn(
51
+ "inline-flex shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4",
52
+ variant === "section" && "size-7 rounded-md bg-muted",
53
+ )}
54
+ >
55
+ {icon}
56
+ </span>
57
+ ) : null}
58
+ {children}
59
+ </legend>
39
60
  )
40
61
  }
41
62
 
@@ -8,6 +8,15 @@ import { Skeleton } from './skeleton.tsx'
8
8
  export type MetricCardTone = 'default' | 'warning'
9
9
  export type MetricCardContext = Exclude<UiContext, 'primary'>
10
10
 
11
+ export interface MetricCardSecondaryMetric {
12
+ /** Nome curto da medida complementar. */
13
+ label: React.ReactNode
14
+ /** Valor complementar já formatado pelo consumidor. */
15
+ value: React.ReactNode
16
+ /** Controle complementar próprio, como uma explicação em tooltip. */
17
+ labelAction?: React.ReactNode
18
+ }
19
+
11
20
  const iconContextClasses: Record<MetricCardContext, string> = {
12
21
  neutral: 'bg-context-neutral-subtle text-context-neutral-emphasis',
13
22
  info: 'bg-context-info-subtle text-context-info-emphasis',
@@ -16,7 +25,7 @@ const iconContextClasses: Record<MetricCardContext, string> = {
16
25
  danger: 'bg-context-danger-subtle text-context-danger-emphasis',
17
26
  }
18
27
 
19
- interface MetricCardBaseProps extends Omit<React.ComponentProps<typeof Card>, 'children'> {
28
+ interface MetricCardBaseProps extends Omit<React.ComponentProps<typeof Card>, 'children' | 'asChild'> {
20
29
  /** Controle complementar exibido ao lado do rótulo, como uma explicação em tooltip. */
21
30
  labelAction?: React.ReactNode
22
31
  /** Contexto que explica recorte, proporção ou significado do valor. */
@@ -29,6 +38,8 @@ interface MetricCardBaseProps extends Omit<React.ComponentProps<typeof Card>, 'c
29
38
  tone?: MetricCardTone
30
39
  /** Ação relacionada diretamente à medida. */
31
40
  action?: React.ReactNode
41
+ /** Medida complementar exibida como uma lâmina posterior sob o card principal. */
42
+ secondaryMetric?: MetricCardSecondaryMetric
32
43
  }
33
44
 
34
45
  export type MetricCardProps = MetricCardBaseProps &
@@ -63,6 +74,7 @@ export function MetricCard({
63
74
  context,
64
75
  tone,
65
76
  action,
77
+ secondaryMetric,
66
78
  loading = false,
67
79
  className,
68
80
  ...props
@@ -73,12 +85,16 @@ export function MetricCard({
73
85
  const hasIcon = icon !== undefined && icon !== null
74
86
  const resolvedContext = context ?? (tone === 'warning' ? 'warning' : 'neutral')
75
87
 
76
- return (
88
+ const primaryCard = (
77
89
  <Card
78
90
  data-slot="metric-card"
79
- aria-busy={loading || undefined}
80
- className={cn('min-w-0 p-4', className)}
81
- {...props}
91
+ aria-busy={secondaryMetric === undefined ? loading || undefined : undefined}
92
+ className={cn(
93
+ 'min-w-0 p-4',
94
+ secondaryMetric !== undefined && 'relative z-10',
95
+ secondaryMetric === undefined && className,
96
+ )}
97
+ {...(secondaryMetric === undefined ? props : {})}
82
98
  >
83
99
  {loading ? (
84
100
  <div className="space-y-3">
@@ -130,4 +146,47 @@ export function MetricCard({
130
146
  )}
131
147
  </Card>
132
148
  )
149
+
150
+ if (secondaryMetric === undefined) return primaryCard
151
+
152
+ return (
153
+ <div
154
+ {...props}
155
+ data-slot="metric-card-stack"
156
+ aria-busy={loading || undefined}
157
+ className={cn('relative isolate min-w-0', className)}
158
+ >
159
+ {primaryCard}
160
+ <Card
161
+ data-slot="metric-card-secondary"
162
+ className="relative z-0 -mt-8 flex w-full min-w-0 items-center gap-1.5 bg-[color-mix(in_oklab,var(--card)_88%,black)] px-4 pt-9 pb-1"
163
+ >
164
+ {loading ? (
165
+ <Skeleton className="h-5 w-full" />
166
+ ) : (
167
+ <>
168
+ <div
169
+ data-slot="metric-card-secondary-value"
170
+ className="shrink-0 text-xs font-medium text-muted-foreground tabular-nums"
171
+ >
172
+ {secondaryMetric.value}
173
+ </div>
174
+ <div className="flex min-w-0 items-center gap-1.5">
175
+ <div
176
+ data-slot="metric-card-secondary-label"
177
+ className="min-w-0 truncate text-xs font-medium text-muted-foreground"
178
+ >
179
+ {secondaryMetric.label}
180
+ </div>
181
+ {secondaryMetric.labelAction !== undefined && (
182
+ <div data-slot="metric-card-secondary-label-action" className="shrink-0">
183
+ {secondaryMetric.labelAction}
184
+ </div>
185
+ )}
186
+ </div>
187
+ </>
188
+ )}
189
+ </Card>
190
+ </div>
191
+ )
133
192
  }
@@ -27,6 +27,7 @@ import { Command as CommandPrimitive } from 'cmdk'
27
27
  import { cn } from '../../lib/cn.ts'
28
28
  import { focusRing, focusRingWithin, type ControlShape, type ControlSize } from './control.ts'
29
29
  import { CommandEmpty, CommandGroup, CommandItem, CommandList } from './command.tsx'
30
+ import { InputGroupButton } from './input-group.tsx'
30
31
  import { Popover, PopoverAnchor, PopoverContent } from './popover.tsx'
31
32
 
32
33
  export interface SelectOption {
@@ -451,25 +452,6 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
451
452
  placeholder={props.multiple && selectedValues.length > 0 ? '' : placeholder}
452
453
  className="sr-only"
453
454
  />
454
- {clearable === true && selectedValues.length > 0 && disabled !== true && (
455
- <button
456
- type="button"
457
- data-slot="select-clear"
458
- aria-label="Limpar seleção"
459
- onPointerDown={(e) => e.preventDefault()}
460
- onClick={(e) => {
461
- e.stopPropagation()
462
- if (props.multiple) props.onChange([])
463
- else {
464
- props.onChange('')
465
- setQuery('')
466
- }
467
- }}
468
- className="flex shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
469
- >
470
- <X className="size-3.5" />
471
- </button>
472
- )}
473
455
  {/* Ação custom (ex.: botão que age sobre o valor) — o clique NÃO abre a lista:
474
456
  o slot para a propagação pro controle. */}
475
457
  {trailing !== undefined && (
@@ -492,16 +474,38 @@ function CustomSelect(props: Exclude<SelectProps, SelectNativeProps>): React.Rea
492
474
  affordance que diz isso. Antes ele sumia no buscável, porque ali o sinal
493
475
  era o cursor de texto — que não existe mais no campo. */}
494
476
  <ChevronDownIcon data-slot="select-icon" className={cn('size-4 shrink-0 opacity-50', ghost ? 'h-3.5 w-3.5 opacity-60' : '')} aria-hidden="true" />
477
+ {clearable === true && selectedValues.length > 0 && disabled !== true && (
478
+ <InputGroupButton
479
+ type="button"
480
+ size="icon-xs"
481
+ data-slot="select-clear"
482
+ aria-label="Limpar seleção"
483
+ onPointerDown={(e) => e.preventDefault()}
484
+ onClick={(e) => {
485
+ e.stopPropagation()
486
+ if (props.multiple) props.onChange([])
487
+ else {
488
+ props.onChange('')
489
+ setQuery('')
490
+ }
491
+ }}
492
+ className="-ml-1 -mr-1.5 text-muted-foreground hover:text-foreground"
493
+ >
494
+ <X className="size-3.5" />
495
+ </InputGroupButton>
496
+ )}
495
497
  </div>
496
498
  </PopoverAnchor>
497
499
  <PopoverContent
498
500
  align="start"
499
- // No campo de form a lista acompanha a largura do controle; no ghost, NÃO — o
500
- // gatilho é do tamanho do rótulo (uma chave curta), e a lista precisa caber o
501
- // título inteiro.
501
+ // No campo comum a lista parte da largura do controle, mas pode crescer para
502
+ // preservar rótulos maiores sem alargar o gatilho. No ghost, o gatilho já abraça
503
+ // o conteúdo e a lista mantém sua faixa compacta própria.
502
504
  className={cn(
503
505
  'pointer-events-auto overflow-hidden p-0',
504
- compact ? 'w-auto min-w-56 max-w-80' : 'w-(--radix-popover-trigger-width) min-w-40',
506
+ compact
507
+ ? 'w-auto min-w-56 max-w-80'
508
+ : 'w-max min-w-(--radix-popover-trigger-width) max-w-lg',
505
509
  )}
506
510
  /*
507
511
  * O foco ao abrir é SEMPRE nosso — o Radix nunca mexe.
@@ -8,9 +8,11 @@ const TableVariantContext = React.createContext<TableVariant>('plain')
8
8
  export interface TableProps extends React.ComponentProps<"table"> {
9
9
  /** `framed` aplica a moldura canônica de datagrid no contêiner da tabela. */
10
10
  variant?: TableVariant
11
+ /** Classes do contêiner responsável pela rolagem e pela moldura da tabela. */
12
+ containerClassName?: string
11
13
  }
12
14
 
13
- function Table({ className, variant = 'plain', ...props }: TableProps) {
15
+ function Table({ className, containerClassName, variant = 'plain', ...props }: TableProps) {
14
16
  return (
15
17
  <TableVariantContext.Provider value={variant}>
16
18
  <div
@@ -19,6 +21,7 @@ function Table({ className, variant = 'plain', ...props }: TableProps) {
19
21
  className={cn(
20
22
  "relative w-full overflow-x-auto",
21
23
  variant === 'framed' && 'rounded-lg border border-border',
24
+ containerClassName,
22
25
  )}
23
26
  >
24
27
  <table
@@ -60,9 +60,24 @@ superfícies que não são listagens, como relatórios. O estado é controlado p
60
60
  />
61
61
  ```
62
62
 
63
- Seletores inline mantêm largura previsível (`w-40`; lookup e múltiplo usam `w-52`). Rótulos longos
64
- são truncados no controle, mas permanecem completos na lista. No modal de filtros avançados, o
65
- seletor ocupa toda a largura. Busca, filtros e período aparecem somente quando declarados.
63
+ Seletores inline de valor único abraçam o valor selecionado até `max-w-80`. Valores maiores são
64
+ truncados no controle, enquanto o dropdown pode crescer além dele para preservar a opção.
65
+ Multiseletores mantêm `w-52`; no modal de filtros avançados, o seletor ocupa toda a largura.
66
+ `filterIcons` associa um ícone leading ao nome do filtro quando a superfície precisa reforçar
67
+ dimensões recorrentes, sem substituir o rótulo. Busca, filtros e período aparecem somente quando
68
+ declarados.
69
+
70
+ Quando o valor de repouso depende da superfície, use `filterDefaults`. O valor padrão participa da
71
+ consulta, mas não exibe a ação de limpar. Depois de uma alteração, o `X` aparece e restaura esse
72
+ valor, inclusive quando ele é definido em runtime, como a pessoa da sessão. Sem declaração, filtros
73
+ simples usam o valor vazio como padrão; por isso, opções como “Todos” também não nascem com `X`.
74
+
75
+ Use `periodPosition` para manter o período antes ou depois dos filtros inline. Quando ele precisa
76
+ apenas aparecer depois de um filtro específico, use `{ afterFilter: 'dateBasis' }`. Se tipo de data
77
+ e período formam uma única decisão, declare `period={{ basisFilter: 'dateBasis' }}`: o Opus os reúne
78
+ num só controle, com ícone no início e uma única ação de limpar no fim. Quando a pessoa muda o tipo
79
+ ou o período padrão, essa ação aparece e restaura os dois valores. Se a âncora de `periodPosition`
80
+ não existir, o período permanece visível no fim dos filtros inline.
66
81
 
67
82
  ## Onde cada filtro aparece
68
83
 
@@ -70,7 +85,10 @@ Cada filtro declara `placement`. `inline`, o padrão, mantém o controle na barr
70
85
  ao modal “Filtros”; `external` mantém o filtro no estado navegável e no input, mas deixa a
71
86
  apresentação para o consumidor, como cards de status acima da lista. No modal, filtros com a mesma
72
87
  `section` formam um grupo, e `advancedFilters.columns` distribui os grupos em até três colunas.
73
- Campos de data avançados usam calendário. A antiga `advanced: true` continua aceita durante a
88
+ Use `advancedFilters.sectionIcons` quando os grupos representarem dimensões recorrentes; a chave é
89
+ o mesmo rótulo de `section`, e a legenda mantém o agrupamento leve com ícone e divisor.
90
+ Um filtro `select` pode declarar `searchable: true` quando a lista fechada for longa; a opção geral
91
+ continua disponível e participa da busca. Campos de data avançados usam calendário. A antiga `advanced: true` continua aceita durante a
74
92
  migração e equivale a `placement: 'advanced'`.
75
93
 
76
94
  ```tsx
@@ -79,6 +97,7 @@ filters: {
79
97
  status: {
80
98
  label: 'Status',
81
99
  type: 'select',
100
+ searchable: true,
82
101
  placement: 'inline',
83
102
  options: { kind: 'dictionary', ref: 'workspace.status' },
84
103
  },
@@ -99,7 +118,17 @@ filters: {
99
118
  }
100
119
 
101
120
  // Na tela:
102
- <ActionList action={workspaceList} input={{}} advancedFilters={{ columns: 2 }} />
121
+ <ActionList
122
+ action={workspaceList}
123
+ input={{}}
124
+ advancedFilters={{
125
+ columns: 2,
126
+ sectionIcons: {
127
+ Relacionamento: <UserRound aria-hidden="true" />,
128
+ Datas: <CalendarDays aria-hidden="true" />,
129
+ },
130
+ }}
131
+ />
103
132
  ```
104
133
 
105
134
  ## Colunas de dicionário
@@ -327,27 +356,31 @@ render(
327
356
 
328
357
  ## Propriedades de ActionFilterBar
329
358
 
330
- | Propriedade | Tipo | Padrão | Descrição |
331
- | --------------- | ---------------------------------------------------- | ------- | ---------------------------------------------------------------------------------- |
332
- | `action` | `Pick<ListAction, 'filters' \| 'text' \| 'periods'>` | | Declara busca, filtros e períodos disponíveis. |
333
- | `state` | `ActionFilterState` | | Estado atual da barra. |
334
- | `onStateChange` | `(next) => void` | | Recebe o estado completo depois de cada alteração. |
335
- | `filterOptions` | `Record<string, SelectOption[]>` | | Fornece opções de runtime para filtros select e lookup. |
336
- | `advancedFilters` | `{ columns?: 1 \| 2 \| 3 }` | `{ columns: 1 }` | Define as colunas do diálogo de filtros avançados; o Opus deriva a largura correspondente. |
337
- | `onRefresh` | `() => Promise<void> \| void` | | Exibe a ação de recarregar e executa a consulta do consumidor. |
338
- | `refreshing` | `boolean` | `false` | Desabilita e anima a ação de recarregar durante a consulta. |
339
- | `controls` | `ReactNode` | | Controles auxiliares agrupados com recarregar em um `ButtonGroup` espaçado. |
340
- | `actions` | `ReactNode` | | Ações do consumidor exibidas depois do grupo de controles, com um intervalo maior. |
359
+ | Propriedade | Tipo | Padrão | Descrição |
360
+ | ----------------- | ---------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------ |
361
+ | `action` | `Pick<ListAction, 'filters' \| 'text' \| 'periods'>` | | Declara busca, filtros e períodos disponíveis. |
362
+ | `state` | `ActionFilterState` | | Estado atual da barra. |
363
+ | `onStateChange` | `(next) => void` | | Recebe o estado completo depois de cada alteração. |
364
+ | `filterOptions` | `Record<string, SelectOption[]>` | | Fornece opções de runtime para filtros select e lookup. |
365
+ | `filterDefaults` | `Record<string, string>` | | Define o valor de repouso de filtros simples e o destino da ação de limpar. |
366
+ | `filterIcons` | `Record<string, ReactNode>` | | Associa um ícone leading ao controle de cada filtro. |
367
+ | `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode> }` | `{ columns: 1 }` | Define as colunas do diálogo e os ícones opcionais das seções; o Opus deriva a largura correspondente. |
368
+ | `periodPosition` | `'before' \| 'after' \| { afterFilter: string }` | `'before'` | Posiciona o período antes, depois ou imediatamente após um filtro inline. |
369
+ | `period` | `{ basisFilter: string }` | | Reúne o período e o filtro inline indicado em um único controle. |
370
+ | `onRefresh` | `() => Promise<void> \| void` | | Exibe a ação de recarregar e executa a consulta do consumidor. |
371
+ | `refreshing` | `boolean` | `false` | Desabilita e anima a ação de recarregar durante a consulta. |
372
+ | `controls` | `ReactNode` | | Controles auxiliares agrupados com recarregar em um `ButtonGroup` espaçado. |
373
+ | `actions` | `ReactNode` | | Ações do consumidor exibidas depois do grupo de controles, com um intervalo maior. |
341
374
 
342
375
  ## Declaração na ListAction
343
376
 
344
- | Chave | O que declara |
345
- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
346
- | `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat?, dictionary?, empty? }` — a tabela. `hidden` nasce oculta e pode ser exibida pelo seletor de colunas; `dictionary` nomeia o dicionário do provider; `empty` dá o significado da ausência. |
347
- | `filters` | `{ [nome]: { label, type, options?, multiple?, placement?, section?, depends?, … } }` — a toolbar. `placement` classifica o controle como `inline`, `advanced` ou `external`; filtros avançados com `section` são agrupados no diálogo; `external` mantém estado e input, mas delega a apresentação ao consumidor. `depends` desabilita/cascateia; options `kind: 'lookup'` busca em uma action. Valor aplicado entra no input com o mesmo nome. |
348
- | `text` | `{ fields }` — liga a busca; convenção: param `q` no input. Com período/filtros no contrato ela fica à direita; sendo a ÚNICA forma de recorte, abre a linha. |
349
- | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
350
- | `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. |
377
+ | Chave | O que declara |
378
+ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
379
+ | `columns` | `{ key, label, type?, sortable?, fit?, hidden?, dateFormat?, dictionary?, empty? }` — a tabela. `hidden` nasce oculta e pode ser exibida pelo seletor de colunas; `dictionary` nomeia o dicionário do provider; `empty` dá o significado da ausência. |
380
+ | `filters` | `{ [nome]: { label, type, options?, multiple?, searchable?, allowEmpty?, placement?, section?, depends?, … } }` — a toolbar. `placement` classifica o controle como `inline`, `advanced` ou `external`; filtros avançados com `section` são agrupados no diálogo; `external` mantém estado e input, mas delega a apresentação ao consumidor. `searchable` habilita busca local em `select`; `allowEmpty: false` omite a opção vazia. `depends` desabilita/cascateia; options `kind: 'lookup'` busca em uma action. Valor aplicado entra no input com o mesmo nome. |
381
+ | `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. |
382
+ | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
383
+ | `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. |
351
384
 
352
385
  ## useListAction
353
386
 
@@ -359,26 +392,31 @@ chamar a action.
359
392
 
360
393
  ## Propriedades de ActionList
361
394
 
362
- | Propriedade | Tipo | Padrão | Descrição |
363
- | ----------------------- | ------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
364
- | `action` | `ListAction<TInput, TItem>` | | A ListAction do Opus (kind list, output = item). A paginação envia `limit`/`page` e usa o `total` devolvido. |
365
- | `input` | `TInput` | | O ESCOPO BASE (ex.: { workspaceId }) — a toolbar soma por cima, nunca sobrescreve. |
366
- | `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
367
- | `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para os filtros select/lookup (chave = nome do filtro). |
368
- | `advancedFilters` | `{ columns?: 1 \| 2 \| 3 }` | `{ columns: 1 }` | Define as colunas dos grupos de filtros avançados; o Opus deriva a largura correspondente do diálogo. |
369
- | `views` | `Record<string, ActionListView<TItem>>` | | Visualizações alternativas `{ label, icon?, render }`; o seletor alterna entre a tabela e estas. |
370
- | `beforeContent` | `ReactNode` | | Conteúdo entre a toolbar e o corpo, como um resumo que reage ao recorte atual. |
371
- | `columns` | `ActionListColumn<TItem>[]` | | Tabela EXPLÍCITA — sobrepõe as colunas do contrato (escape hatch). |
372
- | `children` | `(items, refetch) => ReactNode` | | Modo composição: layout livre; a toolbar segue. Tem precedência sobre columns. |
373
- | `batch` | `ActionListBatchAction[]` | | Ações em lote: `{ label, can?, run, confirm?: { title, message? }, destructive? }` — liga a multi-seleção. |
374
- | `rowId` | `(item) => string` | `item.id` | Identidade da linha para seleção. |
375
- | `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. |
376
- | `state / onStateChange` | `ActionListState / (next) => void` | interno | Estado da toolbar controlado — para quem embala sincronizar com a URL. |
377
- | `empty` | `(items) => boolean` | `items.length === 0` | Sobrepõe o vazio derivado, por exemplo quando um formulário inline aberto conta como conteúdo. |
378
- | `loading` | `boolean` | | Carga extra agregada à da consulta, como uma query irmã de que os `children` dependem. |
379
- | `emptyMessage` | `string` | `'Nenhum resultado.'` | Frase do estado vazio (o `Empty` do `DataState`). |
380
- | `errorMessage` | `string` | `'Não foi possível carregar.'` | Título do aviso de erro; a ação de tentar de novo refaz a consulta. |
381
- | `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
382
- | `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
383
- | `toolbarActions` | `ReactNode` | | Operações ligadas ao recorte atual, no fim da barra e separadas do grupo de controles. |
384
- | `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita). A partir de duas, entram num grupo com intervalo compacto; uma só fica solta, sem grupo. Cliques ali não disparam o `onRowClick`. |
395
+ | Propriedade | Tipo | Padrão | Descrição |
396
+ | ----------------------- | --------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
397
+ | `action` | `ListAction<TInput, TItem>` | | A ListAction do Opus (kind list, output = item). A paginação envia `limit`/`page` e usa o `total` devolvido. |
398
+ | `input` | `TInput` | | O ESCOPO BASE (ex.: { workspaceId }) — a toolbar soma por cima, nunca sobrescreve. |
399
+ | `layout` | `'flow' \| 'fill'` | `'flow'` | Em `fill`, ocupa a altura disponível e mantém a rolagem dentro da região de resultados. |
400
+ | `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
401
+ | `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para os filtros select/lookup (chave = nome do filtro). |
402
+ | `filterDefaults` | `Record<string, string>` | | Valores de repouso aplicados à consulta; o `X` só aparece quando o filtro diverge deles e retorna a eles. |
403
+ | `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
404
+ | `advancedFilters` | `{ columns?: 1 \| 2 \| 3; sectionIcons?: Record<string, ReactNode> }` | `{ columns: 1 }` | Define as colunas e os ícones opcionais dos grupos de filtros avançados; o Opus deriva a largura correspondente do diálogo. |
405
+ | `periodPosition` | `'before' \| 'after' \| { afterFilter: string }` | `'before'` | Posiciona o período antes, depois ou imediatamente após um filtro inline. |
406
+ | `period` | `{ basisFilter: string }` | | Reúne o período e o filtro inline indicado em um único controle. |
407
+ | `views` | `Record<string, ActionListView<TItem>>` | | Visualizações alternativas `{ label, icon?, render }`; o seletor alterna entre a tabela e estas. |
408
+ | `beforeContent` | `ReactNode` | | Conteúdo entre a toolbar e o corpo, como um resumo que reage ao recorte atual. |
409
+ | `columns` | `ActionListColumn<TItem>[]` | | Tabela EXPLÍCITA — sobrepõe as colunas do contrato (escape hatch). |
410
+ | `children` | `(items, refetch) => ReactNode` | | Modo composição: layout livre; a toolbar segue. Tem precedência sobre columns. |
411
+ | `batch` | `ActionListBatchAction[]` | | Ações em lote: `{ label, can?, run, confirm?: { title, message? }, destructive? }` — liga a multi-seleção. |
412
+ | `rowId` | `(item) => string` | `item.id` | Identidade da linha para seleção. |
413
+ | `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. |
414
+ | `state / onStateChange` | `ActionListState / (next) => void` | interno | Estado da toolbar controlado — para quem embala sincronizar com a URL. |
415
+ | `empty` | `(items) => boolean` | `items.length === 0` | Sobrepõe o vazio derivado, por exemplo quando um formulário inline aberto conta como conteúdo. |
416
+ | `loading` | `boolean` | | Carga extra agregada à da consulta, como uma query irmã de que os `children` dependem. |
417
+ | `emptyMessage` | `string` | `'Nenhum resultado.'` | Frase do estado vazio (o `Empty` do `DataState`). |
418
+ | `errorMessage` | `string` | `'Não foi possível carregar.'` | Título do aviso de erro; a ação de tentar de novo refaz a consulta. |
419
+ | `retryLabel` | `string` | `'Tentar de novo'` | Nome acessível e tooltip da ação de recuperação. |
420
+ | `onRowClick` | `(item: TItem) => void` | | Clique na linha (ex.: navegar para o detalhe) — só na tabela. |
421
+ | `toolbarActions` | `ReactNode` | | Operações ligadas ao recorte atual, no fim da barra e separadas do grupo de controles. |
422
+ | `rowActions` | `(item: TItem) => ReactNode` | | Ações por linha (coluna final, à direita). A partir de duas, entram num grupo com intervalo compacto; uma só fica solta, sem grupo. Cliques ali não disparam o `onRowClick`. |
@@ -38,6 +38,10 @@ vincula ao controle, ligue-o ao controle por `aria-labelledby` para manter o nom
38
38
  Declare `data-invalid="true"` no `Field` para que o rótulo acompanhe o contexto de erro;
39
39
  `aria-invalid` marca apenas o controle. O `ActionForm` define os dois atributos sozinho.
40
40
 
41
+ Quando vários conjuntos compartilham a mesma superfície, use `variant="section"` para separar cada
42
+ grupo com uma linha leve. `icon` acrescenta uma referência visual antes do título sem transformar o
43
+ conjunto em outro card; escolha um ícone que represente a dimensão do grupo, não uma ação.
44
+
41
45
  ```tsx preview col md
42
46
  <FieldSet>
43
47
  <FieldLegend>Novo agente</FieldLegend>
@@ -70,7 +74,8 @@ Declare `data-invalid="true"` no `Field` para que o rótulo acompanhe o contexto
70
74
 
71
75
  | Propriedade | Tipo | Padrão | Descrição |
72
76
  |---|---|---|---|
73
- | `variant` | `'legend' \| 'label'` | `'legend'` | Define o porte de título de seção ou de rótulo compacto. |
77
+ | `variant` | `'legend' \| 'label' \| 'section'` | `'legend'` | Define o porte de título, de rótulo compacto ou de seção com divisor. |
78
+ | `icon` | `ReactNode` | | Exibe um ícone decorativo antes da legenda. Na variante `section`, o ícone recebe um suporte tonal. |
74
79
 
75
80
  ## Propriedades de FieldError
76
81
 
@@ -31,6 +31,40 @@ relacionada à medida. Um controle que explica o rótulo, como um tooltip, perte
31
31
  />
32
32
  ```
33
33
 
34
+ ## Com medida complementar
35
+
36
+ Use `secondaryMetric` quando a mesma dimensão precisar mostrar uma segunda leitura sem competir
37
+ com o valor principal. A medida complementar aparece como uma lâmina posterior, com valor, rótulo
38
+ e `labelAction` próprios. Não use essa composição para duas métricas sem relação direta; nesse caso,
39
+ apresente dois cards independentes.
40
+
41
+ ```tsx preview col
42
+ <MetricCard
43
+ label="Agendados"
44
+ value="18"
45
+ labelAction={
46
+ <Tooltip>
47
+ <TooltipTrigger asChild>
48
+ <Button size="icon-xs" variant="ghost" aria-label="Sobre Agendados"><Info /></Button>
49
+ </TooltipTrigger>
50
+ <TooltipContent>Eventos atualmente agendados.</TooltipContent>
51
+ </Tooltip>
52
+ }
53
+ secondaryMetric={{
54
+ label: 'agendadas no período',
55
+ value: '7',
56
+ labelAction: (
57
+ <Tooltip>
58
+ <TooltipTrigger asChild>
59
+ <Button size="icon-xs" variant="ghost" aria-label="Sobre o período"><Info /></Button>
60
+ </TooltipTrigger>
61
+ <TooltipContent>Agendamentos registrados dentro do período.</TooltipContent>
62
+ </Tooltip>
63
+ ),
64
+ }}
65
+ />
66
+ ```
67
+
34
68
  ## Carregamento
35
69
 
36
70
  `loading` preserva a geometria do card com placeholders e marca a superfície como ocupada para
@@ -49,6 +83,15 @@ tecnologias assistivas. O contêiner da coleção continua responsável pela men
49
83
  | `loading` | `boolean` | `false` | Substitui rótulo e valor por skeletons e marca `aria-busy`. |
50
84
  | `labelAction` | `ReactNode` | | Controle ao lado do rótulo, como uma explicação em tooltip. |
51
85
  | `description` | `ReactNode` | | Contexto que explica recorte, proporção ou significado do valor. |
86
+ | `secondaryMetric` | `MetricCardSecondaryMetric` | | Medida complementar exibida como uma lâmina posterior sob o card principal. |
52
87
  | `icon` | `ReactNode` | | Ícone decorativo que identifica a natureza da medida. |
53
88
  | `context` | `'neutral' \| 'info' \| 'success' \| 'warning' \| 'danger'` | `'neutral'` | Contexto semântico do ícone; não altera a superfície do card. |
54
89
  | `action` | `ReactNode` | | Ação relacionada diretamente à medida. |
90
+
91
+ ## Propriedades de MetricCardSecondaryMetric
92
+
93
+ | Propriedade | Tipo | Padrão | Descrição |
94
+ |---|---|---|---|
95
+ | `label` | `ReactNode` | | O nome da medida complementar. |
96
+ | `value` | `ReactNode` | | O valor complementar já formatado pelo consumidor. |
97
+ | `labelAction` | `ReactNode` | | Controle próprio ao lado do rótulo, como uma explicação em tooltip. |