@softize/opus 25.3.1 → 25.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ Depois de qualquer bump, rode os gates (`typecheck` · `test` · `opus check` ·
7
7
  `opus copy --check` · `base copy check` · `manifest:check`) — eles apontam o que a
8
8
  mudança cobra do seu código.
9
9
 
10
+ ## 25.4.0 — 2026-10-05
11
+
12
+ `ListAction.grouping.fields` passa a aceitar filtros `select` ou `lookup` cujas opções completas
13
+ sejam fornecidas em runtime por `ActionList.filterOptions`. A dimensão só aparece em **Agrupar por**
14
+ com 1 a 12 opções; catálogo ausente, vazio ou maior mantém a lista plana, sem agrupar uma amostra.
15
+ A ADR 0055 registra essa extensão da decisão original.
16
+
17
+ As preferências de ordenação passam para **Exibição**, com a direção como ação dentro do próprio
18
+ campo. O botão sinaliza quando ordenação, agrupamento, colunas ou quantidade por página diferem do
19
+ padrão, e o rodapé explicita **Agrupado por** e **Ordenado por** quando aplicáveis.
20
+
21
+ Também corrige a `ActionList` agrupada em aplicações Vite que consomem o código-fonte TypeScript do
22
+ Opus. Ao ativar um agrupamento, a lista podia deixar de reconhecer o `OpusProvider` já presente na
23
+ aplicação e interromper a renderização. O agrupamento agora usa o mesmo driver React dos demais
24
+ hooks de action. Não há migração para o consumidor.
25
+
10
26
  ## 25.3.1 — 2026-10-05
11
27
 
12
28
  Com um `Dialog` ou `Drawer` aberto e a pessoa numa `FloatingLayer` sem `onEscapeKeyDown`, o Esc
@@ -1,9 +1,10 @@
1
1
  # ADR 0054 — Agrupamento da ActionList é um recorte declarado e consultado no servidor
2
2
 
3
- - **Status:** aceita.
3
+ - **Status:** aceita, com a fonte de opções ampliada pela ADR 0055.
4
4
  - **Data:** 2026-10-03.
5
5
  - **Relacionadas:** ADR 0013 (Presentation orientada a action) e ADR 0024 (preenchimento das
6
- regiões de lista).
6
+ regiões de lista). A ADR 0055 substitui somente a restrição desta decisão a opções estáticas ou
7
+ de dicionário; recorte no servidor, limite de cardinalidade e demais garantias permanecem.
7
8
 
8
9
  ## Contexto
9
10
 
@@ -0,0 +1,57 @@
1
+ # ADR 0055 — Opções resolvidas em runtime podem formar grupos limitados da ActionList
2
+
3
+ - **Status:** aceita.
4
+ - **Data:** 2026-10-05.
5
+ - **Relacionada:** ADR 0054 (agrupamento da ActionList é um recorte declarado e consultado no
6
+ servidor).
7
+
8
+ ## Contexto
9
+
10
+ A ADR 0054 limitou `grouping.fields` a filtros `select` com opções estáticas ou de dicionário. A
11
+ restrição garantia que a interface conhecesse todos os grupos antes de consultar a listagem e que o
12
+ custo não passasse de doze requisições paralelas.
13
+
14
+ Alguns catálogos fechados, porém administráveis, chegam ao consumidor como opções resolvidas em
15
+ runtime. Equipe, unidade e serviço são exemplos: continuam sendo dimensões enumeráveis no recorte
16
+ da tela, mas não pertencem ao contrato como literais nem a um dicionário versionado. Proibir esses
17
+ campos força o produto a duplicar dados administráveis no contrato; aceitar qualquer lookup, por
18
+ outro lado, permitiria agrupar por resultados parciais de uma busca e produziria contagens
19
+ enganosas.
20
+
21
+ ## Decisão
22
+
23
+ ### 1. O contrato aceita filtros enumeráveis de runtime
24
+
25
+ Um campo declarado em `grouping.fields` pode referenciar um filtro `select` ou `lookup`. As opções
26
+ estáticas continuam sendo validadas na declaração e precisam conter entre uma e doze entradas.
27
+ Filtros sem opções estáticas são estruturalmente válidos porque sua cardinalidade só é conhecida na
28
+ interface.
29
+
30
+ ### 2. `filterOptions` precisa representar o catálogo completo do recorte
31
+
32
+ A `ActionList` só oferece a dimensão quando consegue resolver entre uma e doze opções. Para
33
+ `lookup`, isso exige que o consumidor forneça explicitamente em `filterOptions[campo]` o catálogo
34
+ completo aplicável à tela; os resultados transitórios da busca do próprio lookup não são usados
35
+ como grupos.
36
+
37
+ Se as opções estiverem ausentes, vazias ou acima do limite, a dimensão não aparece em **Agrupar
38
+ por** e um `groupBy` recebido por estado ou URL é ignorado. Não há agrupamento parcial silencioso.
39
+
40
+ ### 3. O protocolo de consulta não muda
41
+
42
+ Cada opção continua produzindo um recorte da mesma `ListAction`, como definido na ADR 0054. O
43
+ limite de doze consultas paralelas, a exigência de `paginate: 'offset'`, o `total` autoritativo e a
44
+ paginação independente por grupo permanecem iguais.
45
+
46
+ Catálogos maiores precisam de uma futura fronteira agregada no servidor; esta decisão não antecipa
47
+ esse protocolo.
48
+
49
+ ## Consequências
50
+
51
+ - Produtos podem agrupar por catálogos administráveis sem copiá-los para o contrato.
52
+ - O consumidor assume explicitamente que `filterOptions[campo]` é exaustivo para o recorte atual.
53
+ - Um lookup pesquisável ou de alta cardinalidade não se torna uma dimensão de agrupamento apenas
54
+ por estar declarado.
55
+ - Serviço deixa de ser oferecido como agrupamento quando o catálogo resolvido tiver mais de doze
56
+ opções; isso é uma limitação visível e segura, não uma amostra dos primeiros resultados.
57
+
package/docs/protocol.md CHANGED
@@ -207,9 +207,11 @@ type OptionsSpec =
207
207
  **`search`**
208
208
  - `filters?: Record<string, FilterSpec>` — filtros disponíveis (chave é nome).
209
209
  - `sort?`, `paginate?`, `text?`, `periods?`, `grouping?` — ver tipos abaixo.
210
- - `grouping.fields` referencia filtros `select` com opções fechadas (`static` ou
211
- `dictionary`). A interface consulta cada valor como um recorte da mesma action; não agrupa só a
212
- página carregada.
210
+ - `grouping.fields` referencia filtros enumeráveis `select` ou `lookup`. Opções estáticas e de
211
+ dicionário podem vir da declaração/provider; catálogos administráveis vêm de `filterOptions` no
212
+ consumidor. A interface só oferece dimensões com 1 a 12 opções completas e consulta cada valor
213
+ como um recorte da mesma action; não agrupa só a página carregada nem resultados parciais de uma
214
+ busca.
213
215
 
214
216
  ```ts
215
217
  type FilterSpec = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softize/opus",
3
- "version": "25.3.1",
3
+ "version": "25.4.0",
4
4
  "description": "End-to-end action protocol for TypeScript. Single package with subpath exports (core + adapters).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -3,7 +3,7 @@ import type { ListAction } from './types.ts'
3
3
  const MAX_GROUP_OPTIONS = 12
4
4
 
5
5
  /** Valida a parte estrutural de `ListAction.grouping` no momento da declaração.
6
- * Dicionários são resolvidos pelo provider e têm a cardinalidade conferida pela UI. */
6
+ * Dicionários e opções resolvidas em runtime têm a cardinalidade conferida pela UI. */
7
7
  export function assertValidListGrouping(
8
8
  action: Pick<ListAction<unknown, unknown>, 'name' | 'filters' | 'grouping' | 'paginate'>,
9
9
  ): void {
@@ -26,14 +26,14 @@ export function assertValidListGrouping(
26
26
  if (filter === undefined) {
27
27
  throw new Error(`A action "${action.name}" agrupa por "${field}", mas esse filtro não existe.`)
28
28
  }
29
- if (filter.type !== 'select' || (filter.options?.kind !== 'static' && filter.options?.kind !== 'dictionary')) {
29
+ if (filter.type !== 'select' && filter.type !== 'lookup') {
30
30
  throw new Error(
31
31
  `A action "${action.name}" agrupa por "${field}", mas a dimensão precisa ser ` +
32
- 'um filtro select com opções static ou dictionary.',
32
+ 'um filtro select ou lookup com opções enumeráveis.',
33
33
  )
34
34
  }
35
35
  if (
36
- filter.options.kind === 'static' &&
36
+ filter.options?.kind === 'static' &&
37
37
  (filter.options.items.length === 0 || filter.options.items.length > MAX_GROUP_OPTIONS)
38
38
  ) {
39
39
  throw new Error(
package/src/core/types.ts CHANGED
@@ -924,8 +924,8 @@ export interface SearchFilters {
924
924
  paginate?: 'cursor' | 'offset' | false
925
925
  text?: { fields: string[]; placeholder?: I18nRef }
926
926
  periods?: PeriodSpec[]
927
- /** Dimensões fechadas que a ActionList pode usar para agrupar a tabela. Cada nome
928
- * referencia um filtro select com opções static ou dictionary. */
927
+ /** Dimensões enumeráveis que a ActionList pode usar para agrupar a tabela. Cada nome
928
+ * referencia um filtro select/lookup com até 12 opções declaradas ou resolvidas em runtime. */
929
929
  grouping?: { fields: string[] }
930
930
  /**
931
931
  * Cláusula DSL aplicada **sempre** pelo repository do consumer (ex:
@@ -959,7 +959,7 @@ export interface ListAction<In, Out, ParsedIn = In>
959
959
  paginate?: 'cursor' | 'offset' | false
960
960
  text?: { fields: string[]; placeholder?: I18nRef }
961
961
  periods?: PeriodSpec[]
962
- /** Dimensões fechadas que a ActionList pode usar para agrupar a tabela. */
962
+ /** Dimensões enumeráveis que a ActionList pode usar para agrupar a tabela. */
963
963
  grouping?: { fields: string[] }
964
964
  /**
965
965
  * Ver `SearchFilters.where`. Baseline DSL aplicado pelo repository
@@ -1,7 +1,6 @@
1
1
  import { useQueries } from '@tanstack/react-query'
2
2
  import { useRef } from 'react'
3
- import type { ActionDef, ActionError, Paginated } from '../../../core/index.ts'
4
- import { useClient } from '../../drivers/context.tsx'
3
+ import type { ActionDef, ActionError, ClientAdapter, Paginated } from '../../../core/index.ts'
5
4
 
6
5
  export interface ListActionGroup<TItem> {
7
6
  value: string
@@ -15,7 +14,8 @@ export interface ListActionGroup<TItem> {
15
14
  }
16
15
 
17
16
  /** Implementação interna do ActionList para executar um recorte paginado por grupo. */
18
- export function useGroupedListAction<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
17
+ export function useGroupedListQueries<TItem = unknown, TInput extends Record<string, unknown> = Record<string, unknown>>(
18
+ client: ClientAdapter,
19
19
  action: { name: string; kind: 'list' },
20
20
  input: TInput,
21
21
  field: string | null,
@@ -24,7 +24,6 @@ export function useGroupedListAction<TItem = unknown, TInput extends Record<stri
24
24
  pages: Record<string, number>,
25
25
  pageSize: number,
26
26
  ): ListActionGroup<TItem>[] {
27
- const client = useClient('ActionList grouping')
28
27
  const previous = useRef<{
29
28
  field: string | null
30
29
  groups: Map<string, { items: TItem[]; total: number }>
@@ -62,11 +62,11 @@ import {
62
62
  } from "../../../core/index.ts";
63
63
  import {
64
64
  useDicts,
65
+ useGroupedListAction,
65
66
  useListAction,
66
67
  useLookupAction,
67
68
  type DictLike,
68
69
  } from "../../drivers/react.tsx";
69
- import { useGroupedListAction } from "./grouped-list.ts";
70
70
  import { cn } from "../../lib/cn.ts";
71
71
  import { FillBoundary, useFillLeaf } from "./fill-region.ts";
72
72
  import { objectSchemaShape } from "../../lib/object-schema.ts";
@@ -1779,6 +1779,7 @@ export function ActionList<TInput, TItem>({
1779
1779
  const raw = controlledState ?? internal;
1780
1780
  const state: ActionListState = {
1781
1781
  ...raw,
1782
+ sort: raw.sort ?? defaultSort,
1782
1783
  period: raw.period ?? null,
1783
1784
  from: raw.from ?? null,
1784
1785
  to: raw.to ?? null,
@@ -1818,11 +1819,7 @@ export function ActionList<TInput, TItem>({
1818
1819
  const availableGroupingFields = groupingFields.filter((field) => {
1819
1820
  if (action.filters?.group !== undefined) return false;
1820
1821
  const spec = action.filters?.[field];
1821
- if (
1822
- spec?.type !== "select" ||
1823
- (spec.options?.kind !== "static" && spec.options?.kind !== "dictionary")
1824
- )
1825
- return false;
1822
+ if (spec?.type !== "select" && spec?.type !== "lookup") return false;
1826
1823
  const options = [
1827
1824
  ...optionsFor(field, spec, filterOptions),
1828
1825
  ...dictOptionsFor(spec, dicts),
@@ -1841,6 +1838,10 @@ export function ActionList<TInput, TItem>({
1841
1838
  availableGroupingFields.includes(requestedGroupBy)
1842
1839
  ? requestedGroupBy
1843
1840
  : null;
1841
+ const activeGroupingLabel =
1842
+ activeGroupBy === null
1843
+ ? null
1844
+ : text(action.filters?.[activeGroupBy]?.label, activeGroupBy);
1844
1845
  const hasSegment = children === undefined && viewEntries.length > 1;
1845
1846
  const periods = action.periods ?? [];
1846
1847
  const hasToolbar =
@@ -2090,6 +2091,34 @@ export function ActionList<TInput, TItem>({
2090
2091
  state.sort?.field === key && state.sort.dir === "asc" ? "desc" : "asc";
2091
2092
  setStateResetPage({ ...state, sort: { field: key, dir } });
2092
2093
  };
2094
+ const activeSort = state.sort;
2095
+ const sortLabelFor = (field: string): string | null => {
2096
+ const declared = action.columns?.find((column) => column.key === field);
2097
+ if (declared !== undefined) return text(declared.label, field);
2098
+ const explicit = columns?.find((column) => column.key === field)?.header;
2099
+ return typeof explicit === "string" ? explicit : null;
2100
+ };
2101
+ const sortableFields = (action.sort?.fields ?? []).flatMap((field) => {
2102
+ const label = sortLabelFor(field);
2103
+ return label === null ? [] : [{ value: field, label }];
2104
+ });
2105
+ const activeSortLabel =
2106
+ activeSort === null
2107
+ ? null
2108
+ : sortLabelFor(activeSort.field);
2109
+ const activeSortDirection =
2110
+ activeSort?.dir === "asc" ? "crescente" : "decrescente";
2111
+ const sortCustomized =
2112
+ activeSort?.field !== defaultSort?.field ||
2113
+ activeSort?.dir !== defaultSort?.dir;
2114
+ const displayCustomized =
2115
+ activeGroupBy !== null ||
2116
+ state.columns !== null ||
2117
+ state.limit !== null ||
2118
+ sortCustomized;
2119
+ const displayLabel = displayCustomized
2120
+ ? "Exibição personalizada"
2121
+ : "Exibição";
2093
2122
 
2094
2123
  // — Multi-seleção (batch): `can` de QUALQUER ação torna a linha marcável; nenhuma
2095
2124
  // elegível na página → a coluna de checkbox nem aparece. Seleção é transitória
@@ -2180,14 +2209,20 @@ export function ActionList<TInput, TItem>({
2180
2209
  <Button
2181
2210
  variant="ghost"
2182
2211
  size="icon"
2183
- className="shrink-0"
2184
- aria-label="Exibição"
2212
+ className="relative shrink-0"
2213
+ aria-label={displayLabel}
2185
2214
  >
2186
2215
  <Settings2 className="h-3.5 w-3.5" />
2216
+ {displayCustomized && (
2217
+ <Dot
2218
+ context="primary"
2219
+ className="absolute top-1 right-1 ring-2 ring-background"
2220
+ />
2221
+ )}
2187
2222
  </Button>
2188
2223
  </PopoverTrigger>
2189
2224
  </TooltipTrigger>
2190
- <TooltipContent>Exibição</TooltipContent>
2225
+ <TooltipContent>{displayLabel}</TooltipContent>
2191
2226
  </Tooltip>
2192
2227
  <PopoverContent
2193
2228
  align="end"
@@ -2195,6 +2230,93 @@ export function ActionList<TInput, TItem>({
2195
2230
  data-slot="action-list-display"
2196
2231
  >
2197
2232
  <div className="space-y-3">
2233
+ {children === undefined &&
2234
+ activeView === "table" &&
2235
+ sortableFields.length > 0 && (
2236
+ <>
2237
+ <div className="space-y-2">
2238
+ <div className="flex items-center justify-between">
2239
+ <span className="text-xs font-medium text-muted-foreground">
2240
+ Ordenação
2241
+ </span>
2242
+ {sortCustomized && (
2243
+ <button
2244
+ type="button"
2245
+ onClick={() =>
2246
+ setState({ ...state, sort: defaultSort, page: 1 })
2247
+ }
2248
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
2249
+ >
2250
+ <Eraser className="size-3" />
2251
+ Padrão
2252
+ </button>
2253
+ )}
2254
+ </div>
2255
+ <Select
2256
+ aria-label="Ordenar por"
2257
+ size="sm"
2258
+ className="w-full"
2259
+ value={activeSort?.field ?? "__none__"}
2260
+ onChange={(field) =>
2261
+ setState({
2262
+ ...state,
2263
+ sort:
2264
+ field === "__none__"
2265
+ ? null
2266
+ : {
2267
+ field,
2268
+ dir:
2269
+ activeSort?.field === field
2270
+ ? activeSort.dir
2271
+ : "asc",
2272
+ },
2273
+ page: 1,
2274
+ })
2275
+ }
2276
+ options={[
2277
+ ...(defaultSort === null
2278
+ ? [{ value: "__none__", label: "Sem ordenação" }]
2279
+ : []),
2280
+ ...sortableFields,
2281
+ ]}
2282
+ trailing={
2283
+ activeSort !== null ? (
2284
+ <Button
2285
+ type="button"
2286
+ variant="ghost"
2287
+ size="icon-xs"
2288
+ aria-label={
2289
+ activeSort.dir === "asc"
2290
+ ? "Ordem crescente. Alterar para decrescente"
2291
+ : "Ordem decrescente. Alterar para crescente"
2292
+ }
2293
+ onClick={() =>
2294
+ setState({
2295
+ ...state,
2296
+ sort: {
2297
+ ...activeSort,
2298
+ dir:
2299
+ activeSort.dir === "asc"
2300
+ ? "desc"
2301
+ : "asc",
2302
+ },
2303
+ page: 1,
2304
+ })
2305
+ }
2306
+ >
2307
+ {activeSort.dir === "asc" ? (
2308
+ <ArrowUp aria-hidden="true" />
2309
+ ) : (
2310
+ <ArrowDown aria-hidden="true" />
2311
+ )}
2312
+ </Button>
2313
+ ) : undefined
2314
+ }
2315
+ />
2316
+ </div>
2317
+ <Separator />
2318
+ </>
2319
+ )}
2198
2320
  {children === undefined &&
2199
2321
  activeView === "table" &&
2200
2322
  availableGroupingFields.length > 0 && (
@@ -2676,21 +2798,49 @@ export function ActionList<TInput, TItem>({
2676
2798
  ? Math.max(1, Math.ceil(total / (state.limit ?? pageSize)))
2677
2799
  : 1;
2678
2800
  const goTo = (page: number): void => setState({ ...state, page });
2801
+ const displaySummary =
2802
+ activeGroupingLabel !== null || activeSortLabel !== null ? (
2803
+ <span
2804
+ data-slot="action-list-display-summary"
2805
+ className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"
2806
+ >
2807
+ {activeGroupingLabel !== null && (
2808
+ <span className="min-w-0 break-words">
2809
+ Agrupado por {activeGroupingLabel}
2810
+ </span>
2811
+ )}
2812
+ {activeGroupingLabel !== null && activeSortLabel !== null && (
2813
+ <span aria-hidden="true">·</span>
2814
+ )}
2815
+ {activeSortLabel !== null && (
2816
+ <span className="min-w-0 break-words">
2817
+ Ordenado por {activeSortLabel} ({activeSortDirection})
2818
+ </span>
2819
+ )}
2820
+ </span>
2821
+ ) : null;
2679
2822
  const footer =
2680
2823
  total !== undefined && !isError && activeGroupBy !== null ? (
2681
2824
  <div
2682
2825
  data-slot="action-list-footer"
2683
- className="flex min-h-10 items-center justify-end border-t border-border px-3 py-1 text-xs text-muted-foreground"
2826
+ className="flex min-h-10 flex-wrap items-center justify-between gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
2684
2827
  >
2685
- {total} {total === 1 ? "item" : "itens"} no total
2828
+ {displaySummary}
2829
+ <span className={cn(displaySummary === null && "ml-auto")}>
2830
+ {total} {total === 1 ? "item" : "itens"} no total
2831
+ </span>
2686
2832
  </div>
2687
2833
  ) : total !== undefined && !isError ? (
2688
2834
  <div
2689
2835
  data-slot="action-list-footer"
2690
- className="grid min-h-10 grid-cols-[1fr_auto_1fr] items-center gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
2836
+ className="grid min-h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-t border-border px-3 py-1 text-xs text-muted-foreground"
2691
2837
  >
2692
- <span>
2693
- Página {state.page} de {pages}
2838
+ <span className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
2839
+ <span className="whitespace-nowrap">
2840
+ Página {state.page} de {pages}
2841
+ </span>
2842
+ {displaySummary !== null && <span aria-hidden="true">·</span>}
2843
+ {displaySummary}
2694
2844
  </span>
2695
2845
  {pages > 1 && (
2696
2846
  // A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
@@ -2735,7 +2885,7 @@ export function ActionList<TInput, TItem>({
2735
2885
  )}
2736
2886
  {/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
2737
2887
  {pages <= 1 && <span />}
2738
- <span className="justify-self-end">
2888
+ <span className="justify-self-end whitespace-nowrap">
2739
2889
  {total} {total === 1 ? "item" : "itens"} no total
2740
2890
  </span>
2741
2891
  </div>
@@ -217,11 +217,11 @@ periods: [
217
217
 
218
218
  ## Agrupamento
219
219
 
220
- Declare `grouping.fields` quando uma dimensão fechada puder organizar a tabela sem mudar o recorte
221
- de negócio. Cada campo precisa ser um filtro `select` com opções `static` ou `dictionary`; texto,
222
- número, data e lookup não são dimensões de agrupamento. O filtro pode aceitar uma ou várias opções:
223
- cada consulta de grupo envia um único valor na forma declarada. A lista continua plana por padrão,
224
- e a pessoa escolhe **Agrupar por** em **Exibição**.
220
+ Declare `grouping.fields` quando uma dimensão enumerável puder organizar a tabela sem mudar o
221
+ recorte de negócio. Cada campo precisa ser um filtro `select` ou `lookup`; texto, número e data não
222
+ são dimensões de agrupamento. O filtro pode aceitar uma ou várias opções: cada consulta de grupo
223
+ envia um único valor na forma declarada. A lista continua plana por padrão, e a pessoa escolhe
224
+ **Agrupar por** em **Exibição**.
225
225
 
226
226
  ```ts
227
227
  filters: {
@@ -242,6 +242,12 @@ autoritativa, e **Carregar mais** expande apenas aquele grupo. A soma aparece no
242
242
  global. O limite é de doze opções por dimensão. Se um dicionário não estiver disponível ou exceder
243
243
  esse limite, a dimensão não é oferecida em **Agrupar por**.
244
244
 
245
+ Opções administráveis também podem chegar por `filterOptions`. Nesse caso, o consumidor precisa
246
+ fornecer o catálogo completo aplicável ao recorte — não apenas os resultados correntes de uma busca.
247
+ Um `lookup` só é oferecido para agrupamento quando `filterOptions[campo]` contém de uma a doze
248
+ opções. Sem opções, ou acima desse limite, a dimensão e qualquer `groupBy` recebido pela URL são
249
+ ignorados; a lista nunca agrupa uma amostra parcial.
250
+
245
251
  O agrupamento pertence à tabela derivada. Views alternativas e o modo `children` continuam planos.
246
252
  `groupBy` integra `ActionListState`; `listStateToParams` o serializa como `group=<campo>`, e filtros
247
253
  aplicados à mesma dimensão restringem os grupos exibidos.
@@ -427,9 +433,17 @@ filters: {
427
433
  ## Exibição e estado na URL
428
434
 
429
435
  O botão de exibição abre as preferências da listagem em qualquer visualização. Na tabela, permite
430
- mostrar ou ocultar colunas, inclusive as declaradas com `hidden`; em todas as visualizações, permite
431
- alterar a quantidade de itens por página. Busca, ordenação, filtros, visualização, colunas e limite
432
- podem ser serializados na query string pelos helpers do componente:
436
+ escolher a ordenação, inverter sua direção, agrupar e mostrar ou ocultar colunas, inclusive as
437
+ declaradas com `hidden`; em todas as visualizações, permite alterar a quantidade de itens por
438
+ página. Um ponto sinaliza quando alguma preferência diverge do padrão. O rodapé explicita a
439
+ ordenação e o agrupamento ativos junto da paginação e do total. Busca, ordenação, filtros,
440
+ visualização, colunas e limite podem ser serializados na query string pelos helpers do componente:
441
+
442
+ Cada campo de `sort.fields` precisa de uma coluna com rótulo textual para aparecer no seletor e no
443
+ resumo do rodapé; a coluna pode estar `hidden`. Campos sem coluna continuam válidos para o handler e
444
+ para uma ordenação padrão, mas o Opus não expõe seu identificador técnico na interface. **Sem
445
+ ordenação** só aparece quando o contrato não declara `sort.default`; com padrão, use **Padrão** para
446
+ restaurá-lo.
433
447
 
434
448
  ```tsx
435
449
  <ActionList
@@ -519,7 +533,7 @@ render(
519
533
  | `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. |
520
534
  | `sort` | `{ fields, default }` — ordenação inicial; header ordenável escreve `sort: 'chave:dir'`. |
521
535
  | `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. |
522
- | `grouping` | `{ fields }` — dimensões fechadas disponíveis em **Agrupar por**. Cada nome referencia um filtro `select` com opções estáticas ou de dicionário. Não define agrupamento inicial. |
536
+ | `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. |
523
537
 
524
538
  ## useListAction
525
539
 
@@ -538,7 +552,7 @@ chamar a action. `options.enabled` suspende a consulta enquanto for `false`; sem
538
552
  | `input` | `TInput` | | O escopo base, como `{ workspaceId }`. A toolbar acrescenta busca, filtros e paginação sem sobrescrever essas chaves. |
539
553
  | `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`. |
540
554
  | `cells` | `Record<string, (item) => ReactNode>` | | Células custom por cima das colunas do contrato (chave = column.key). |
541
- | `filterOptions` | `Record<string, SelectOption[]>` | | Opções de runtime para os filtros select/lookup (chave = nome do filtro). |
555
+ | `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. |
542
556
  | `filterDefaults` | `Record<string, string>` | | Valores de repouso aplicados à consulta; o `X` só aparece quando o filtro diverge deles e retorna a eles. |
543
557
  | `filterIcons` | `Record<string, ReactNode>` | | Ícones leading dos filtros, indexados pelo nome declarado no contrato. |
544
558
  | `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. |
@@ -47,6 +47,10 @@ export {
47
47
  } from './context.tsx'
48
48
  import { zodErrorMapPtBr } from '../lib/zod-pt-br.ts'
49
49
  import { objectSchemaShape } from '../lib/object-schema.ts'
50
+ import {
51
+ useGroupedListQueries,
52
+ type ListActionGroup,
53
+ } from '../components/patterns/grouped-list.ts'
50
54
 
51
55
  // =============================================================================
52
56
  // useAction
@@ -238,6 +242,32 @@ export function useListAction<TItem = unknown, TInput extends Record<string, unk
238
242
  }
239
243
  }
240
244
 
245
+ /** Implementação interna do ActionList para executar recortes paginados por grupo. */
246
+ export function useGroupedListAction<
247
+ TItem = unknown,
248
+ TInput extends Record<string, unknown> = Record<string, unknown>,
249
+ >(
250
+ action: { name: string; kind: 'list' },
251
+ input: TInput,
252
+ field: string | null,
253
+ values: string[],
254
+ multiple: boolean,
255
+ pages: Record<string, number>,
256
+ pageSize: number,
257
+ ): ListActionGroup<TItem>[] {
258
+ const client = useClient('ActionList grouping')
259
+ return useGroupedListQueries<TItem, TInput>(
260
+ client,
261
+ action,
262
+ input,
263
+ field,
264
+ values,
265
+ multiple,
266
+ pages,
267
+ pageSize,
268
+ )
269
+ }
270
+
241
271
  // =============================================================================
242
272
  // useFormAction
243
273
  // =============================================================================
package/src/ui/react.tsx CHANGED
@@ -5,7 +5,31 @@
5
5
  * `meta` de cada componente NÃO é re-exportado aqui de propósito — é metadado de
6
6
  * build-time (lido pelo manifesto/MCP da fonte), fora do bundle de runtime.
7
7
  */
8
- export * from "./drivers/react.tsx";
8
+ export {
9
+ OpusProvider,
10
+ useAction,
11
+ useDicts,
12
+ useFormAction,
13
+ useListAction,
14
+ useLookupAction,
15
+ useTriggerAction,
16
+ useViewAction,
17
+ } from "./drivers/react.tsx";
18
+ export type {
19
+ ActionError,
20
+ ActionState,
21
+ DictLike,
22
+ ListState,
23
+ OpusProviderProps,
24
+ UseActionResult,
25
+ UseFormActionOptions,
26
+ UseFormActionResult,
27
+ UseListActionResult,
28
+ UseLookupActionResult,
29
+ UseTriggerActionOptions,
30
+ UseTriggerActionResult,
31
+ UseViewActionResult,
32
+ } from "./drivers/react.tsx";
9
33
 
10
34
  // Utilitário
11
35
  export { cn } from "./lib/cn.ts";