@softize/opus 30.0.0 → 31.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 (55) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/bin/lib/check.mjs +109 -101
  3. package/bin/lib/copy.mjs +2970 -1801
  4. package/docs/adr/0043-neutral-surfaces-and-interactions-use-opaque-role-tokens.md +52 -0
  5. package/docs/adr/0044-neutral-appearance-derives-from-bounded-axes-per-mode.md +27 -2
  6. package/docs/adr/0060-surface-composition-separates-chrome-and-intro.md +142 -0
  7. package/docs/adr/0064-action-list-com-itens-e-fontes-externas.md +47 -0
  8. package/docs/adr/0065-presentation-composes-settings-sections.md +295 -0
  9. package/docs/adr/README.md +4 -0
  10. package/package.json +1 -1
  11. package/registry/skills/build-opus-ui/SKILL.md +21 -19
  12. package/registry/skills/build-opus-ui/references/evaluations.md +13 -11
  13. package/registry/skills/build-opus-ui/references/ui-patterns.md +24 -27
  14. package/src/core/presentation.ts +938 -32
  15. package/src/core/types.ts +1 -0
  16. package/src/ui/components/patterns/form.tsx +2 -0
  17. package/src/ui/components/patterns/grouped-list.ts +2 -2
  18. package/src/ui/components/patterns/list.tsx +572 -358
  19. package/src/ui/components/patterns/page-heading-context.tsx +12 -5
  20. package/src/ui/components/patterns/page.tsx +227 -197
  21. package/src/ui/components/patterns/presentation-fields.tsx +246 -0
  22. package/src/ui/components/patterns/presentation-sections.tsx +494 -0
  23. package/src/ui/components/patterns/presentation.tsx +1142 -229
  24. package/src/ui/components/patterns/structural-header.tsx +23 -32
  25. package/src/ui/components/patterns/surface-anatomy.ts +27 -217
  26. package/src/ui/components/patterns/surface.tsx +19 -29
  27. package/src/ui/components/patterns/trigger.tsx +10 -2
  28. package/src/ui/components/primitives/close-button.tsx +11 -3
  29. package/src/ui/components/primitives/command.tsx +2 -8
  30. package/src/ui/components/primitives/detail.tsx +1 -1
  31. package/src/ui/components/primitives/dialog.tsx +19 -81
  32. package/src/ui/components/primitives/drawer.tsx +17 -79
  33. package/src/ui/components/primitives/field.tsx +1 -1
  34. package/src/ui/components/primitives/pagination.tsx +3 -2
  35. package/src/ui/components/primitives/table.tsx +1 -1
  36. package/src/ui/components/primitives/tabs.tsx +13 -2
  37. package/src/ui/docs/content/action-form.md +2 -1
  38. package/src/ui/docs/content/action-list.md +134 -68
  39. package/src/ui/docs/content/action-trigger.md +5 -0
  40. package/src/ui/docs/content/button.md +5 -6
  41. package/src/ui/docs/content/command.md +62 -50
  42. package/src/ui/docs/content/compositions.md +129 -459
  43. package/src/ui/docs/content/dialog.md +44 -112
  44. package/src/ui/docs/content/drawer.md +44 -103
  45. package/src/ui/docs/content/field.md +2 -1
  46. package/src/ui/docs/content/page.md +204 -412
  47. package/src/ui/docs/content/presentation.md +371 -23
  48. package/src/ui/docs/content/separator.md +1 -1
  49. package/src/ui/docs/content/surface.md +65 -120
  50. package/src/ui/docs/content/tokens.md +18 -4
  51. package/src/ui/drivers/context.tsx +11 -4
  52. package/src/ui/drivers/react.tsx +27 -7
  53. package/src/ui/meta.ts +5 -5
  54. package/src/ui/react.tsx +34 -8
  55. package/src/ui/theme.css +44 -33
@@ -14,7 +14,7 @@
14
14
  * quem embala sincronizar com a URL). O `input` da prop é o ESCOPO BASE (ex.:
15
15
  * { workspaceId }) — a toolbar soma por cima e nunca o sobrescreve.
16
16
  *
17
- * Modos de render: tabela derivada (default) · `columns` prop (tabela explícita,
17
+ * Modos de render: itens por campos declarados · tabela derivada (default) · `columns` prop (tabela explícita,
18
18
  * legado) · children `(items, refetch) => nó` (layout livre; a toolbar continua).
19
19
  * Empty/loading/error padronizados. `data-action` na raiz (selector E2E).
20
20
  */
@@ -63,7 +63,8 @@ import {
63
63
  import {
64
64
  useDicts,
65
65
  useGroupedListAction,
66
- useListAction,
66
+ useListResource,
67
+ type ListDataSource,
67
68
  useLookupAction,
68
69
  type DictLike,
69
70
  } from "../../drivers/react.tsx";
@@ -123,6 +124,15 @@ import {
123
124
  type FieldSetVariant,
124
125
  } from "../primitives/field.tsx";
125
126
  import { Surface } from "./surface.tsx";
127
+ import {
128
+ Item,
129
+ ItemGroup,
130
+ ItemHeader,
131
+ ItemTitle,
132
+ ItemDescription,
133
+ ItemActions,
134
+ ItemSeparator,
135
+ } from "../primitives/item.tsx";
126
136
  import { Search } from "lucide-react";
127
137
  import { Select, type SelectOption } from "../primitives/select.tsx";
128
138
  import {
@@ -266,6 +276,7 @@ export function presetRange(
266
276
  /** Estrutural de propósito (como o useListAction): aceita o CONTRATO compartilhado
267
277
  * (sem handler) e a action completa — só o que a UI deriva importa aqui. */
268
278
  export interface ListActionLike {
279
+ paginate?: "cursor" | "offset" | false;
269
280
  name: string;
270
281
  kind: "list";
271
282
  /** Schema de saída (item). Coluna cujo campo carrega a meta de `t.dict` renderiza
@@ -349,8 +360,34 @@ export interface ActionListBatchAction<TItem> {
349
360
  destructive?: boolean;
350
361
  }
351
362
 
352
- export interface ActionListProps<TInput, TItem> {
353
- action: ListActionLike;
363
+ /** Campos da apresentação de itens; serializável sem callbacks ou JSX. */
364
+ export interface ActionListItemSpec {
365
+ title: string;
366
+ description?: string;
367
+ }
368
+
369
+ export interface ActionListContext<TItem> {
370
+ items: TItem[];
371
+ total: number | undefined;
372
+ isFetching: boolean;
373
+ isError: boolean;
374
+ refetch: () => Promise<void>;
375
+ }
376
+
377
+ export type ActionListProps<TInput, TItem> = ActionListBaseProps<
378
+ TInput,
379
+ TItem
380
+ > &
381
+ (
382
+ | { action: ListActionLike; source?: never }
383
+ | { source: ListDataSource<TItem>; action?: never }
384
+ );
385
+
386
+ interface ActionListBaseProps<TInput, TItem> {
387
+ /** Título da coleção; as ações da toolbar acompanham seu cabeçalho. */
388
+ title?: string;
389
+ /** Apresenta itens conectados em uma moldura, em vez de colunas de tabela. */
390
+ item?: ActionListItemSpec;
354
391
  /** Input BASE (escopo fixo, ex.: { workspaceId }) — a toolbar soma por cima. */
355
392
  input: TInput;
356
393
  /**
@@ -360,8 +397,9 @@ export interface ActionListProps<TInput, TItem> {
360
397
  * `flow` nos demais casos (ADR 0024).
361
398
  */
362
399
  layout?: "flow" | "fill";
363
- /** Ações do consumidor no fim da barra de filtros, depois dos controles de exibição. */
364
- toolbarActions?: ReactNode;
400
+ /** Ações da coleção (criar, importar, exportar) após os controles de exibição. */
401
+ toolbarActions?:
402
+ ReactNode | ((context: ActionListContext<TItem>) => ReactNode);
365
403
  /** Controles da visualização ativa, imediatamente após seu seletor. */
366
404
  viewControls?: ReactNode;
367
405
  /** Tabela explícita — sobrepõe as colunas do contrato. */
@@ -404,8 +442,8 @@ export interface ActionListProps<TInput, TItem> {
404
442
  /** Click handler por linha (ex: navegar pro detalhe) — só na tabela. */
405
443
  onRowClick?: (item: TItem) => void;
406
444
  /** Ações POR LINHA (coluna final, alinhada à direita) — apresentação de quem chama,
407
- * como `cells`. Cliques ali não disparam o onRowClick. Só na tabela. */
408
- rowActions?: (item: TItem) => ReactNode;
445
+ * como `cells`. Cliques ali não disparam o onRowClick. Também aparece à direita dos itens. */
446
+ rowActions?: (item: TItem, context: ActionListContext<TItem>) => ReactNode;
409
447
  /**
410
448
  * Conteúdo entre a toolbar e o corpo da listagem — ex.: o resumo do recorte atual
411
449
  * ("12 itens, R$ 4.300 no período"), que precisa reagir ao filtro e por isso não cabe
@@ -415,7 +453,7 @@ export interface ActionListProps<TInput, TItem> {
415
453
  * enquanto viveu assim, todo bump do Opus o descartava em silêncio e o app parava de
416
454
  * compilar sem ninguém entender por quê. Subiu pra cá em 8.6.9.
417
455
  */
418
- beforeContent?: ReactNode;
456
+ beforeContent?: ReactNode | ((input: TInput) => ReactNode);
419
457
  /** Modo COMPOSIÇÃO: layout livre ÚNICO (sem segment); toolbar e estados seguem. */
420
458
  children?: (items: TItem[], refetch: () => Promise<void>) => ReactNode;
421
459
  /** Estado da toolbar CONTROLADO (URL sync de quem embala). Default: interno. */
@@ -1778,7 +1816,10 @@ function RowActions({ children }: { children: ReactNode }) {
1778
1816
  }
1779
1817
 
1780
1818
  export function ActionList<TInput, TItem>({
1781
- action,
1819
+ action: actionProp,
1820
+ source,
1821
+ title,
1822
+ item: itemSpec,
1782
1823
  input,
1783
1824
  layout: layoutProp,
1784
1825
  toolbarActions,
@@ -1807,16 +1848,40 @@ export function ActionList<TInput, TItem>({
1807
1848
  state: controlledState,
1808
1849
  onStateChange,
1809
1850
  }: ActionListProps<TInput, TItem>) {
1851
+ if (source !== undefined && actionProp !== undefined)
1852
+ throw new Error("ActionList aceita action ou source, nunca ambos.");
1853
+ if (source !== undefined && source.key.length === 0)
1854
+ throw new Error("A fonte de ActionList exige uma chave com escopo.");
1855
+ if (
1856
+ itemSpec !== undefined &&
1857
+ (children !== undefined || views !== undefined || batch !== undefined)
1858
+ )
1859
+ throw new Error(
1860
+ "ActionList em itens não combina children, views ou seleção em lote.",
1861
+ );
1862
+ const action: ListActionLike = actionProp ?? {
1863
+ name: "external",
1864
+ kind: "list",
1865
+ };
1866
+ const paginated = action.paginate !== false;
1810
1867
  const confirmBodyId = useId();
1811
1868
  const groupRegionId = useId();
1869
+ const optionsPanelRef = useRef<HTMLDivElement>(null);
1812
1870
  const { layout, ref: rootRef } = useFillLeaf<HTMLDivElement>(layoutProp);
1813
1871
  // Query-backed (useListAction): busca declarativa por [name, input] E refetch
1814
1872
  // automático quando um form/trigger invalida a action (`invalidates: ['x.list']`).
1815
1873
  // O input efetivo é computado abaixo; o hook re-busca quando ele muda.
1816
- const hasTable = columns !== undefined || action.columns !== undefined;
1817
- if (children === undefined && !hasTable && views === undefined) {
1874
+ const hasTable =
1875
+ itemSpec === undefined &&
1876
+ (columns !== undefined || action.columns !== undefined);
1877
+ if (
1878
+ itemSpec === undefined &&
1879
+ children === undefined &&
1880
+ !hasTable &&
1881
+ views === undefined
1882
+ ) {
1818
1883
  throw new Error(
1819
- "ActionList precisa de children (composição), columns (prop/contrato) ou views.",
1884
+ "ActionList precisa de item, children (composição), columns (prop/contrato) ou views.",
1820
1885
  );
1821
1886
  }
1822
1887
 
@@ -1883,6 +1948,7 @@ export function ActionList<TInput, TItem>({
1883
1948
  });
1884
1949
  const requestedGroupBy = state.groupBy ?? null;
1885
1950
  const activeGroupBy =
1951
+ itemSpec === undefined &&
1886
1952
  children === undefined &&
1887
1953
  activeView === "table" &&
1888
1954
  requestedGroupBy !== null &&
@@ -1893,7 +1959,8 @@ export function ActionList<TInput, TItem>({
1893
1959
  activeGroupBy === null
1894
1960
  ? null
1895
1961
  : text(action.filters?.[activeGroupBy]?.label, activeGroupBy);
1896
- const hasSegment = children === undefined && viewEntries.length > 1;
1962
+ const hasSegment =
1963
+ itemSpec === undefined && children === undefined && viewEntries.length > 1;
1897
1964
  const periods = action.periods ?? [];
1898
1965
  const hasToolbar =
1899
1966
  hasSearch ||
@@ -1901,54 +1968,18 @@ export function ActionList<TInput, TItem>({
1901
1968
  periods.length > 0 ||
1902
1969
  hasSegment ||
1903
1970
  viewControls != null ||
1971
+ (title === undefined && toolbarActions != null) ||
1904
1972
  hasTable;
1905
1973
 
1906
- // — Input efetivo: escopo base + filtros preenchidos + período (→ from/to) + q + sort —
1907
- const effectiveInput = useMemo(() => {
1908
- const filled: Record<string, unknown> = {};
1909
- for (const [name, v] of Object.entries({
1910
- ...filterDefaults,
1911
- ...state.filters,
1912
- })) {
1913
- if (isEmptyValue(v)) continue;
1914
- const filter = action.filters?.[name];
1915
- if (
1916
- filter?.type === "date" &&
1917
- !isValidDateFilterValue(v, filter.multiple === true)
1918
- )
1919
- continue;
1920
- filled[name] = v;
1921
- }
1922
- // Período: preset materializa AQUI (a URL guarda o preset relativo); custom usa o
1923
- // range. state.period null = o default do contrato (sempre aplicado).
1924
- const effectivePeriod =
1925
- state.period ??
1926
- action.periods?.find((p) => p.default === true)?.value ??
1927
- action.periods?.[0]?.value ??
1928
- null;
1929
- let range: { from: string; to: string } | null = null;
1930
- if (
1931
- effectivePeriod === "custom" &&
1932
- state.from !== null &&
1933
- state.to !== null
1934
- ) {
1935
- range = { from: state.from, to: state.to };
1936
- } else if (effectivePeriod !== null && effectivePeriod !== "custom") {
1937
- range = presetRange(effectivePeriod);
1938
- }
1939
- return {
1940
- ...(input as Record<string, unknown>),
1941
- ...filled,
1942
- ...(range !== null ? range : {}),
1943
- ...(state.q.trim() !== "" ? { q: state.q.trim() } : {}),
1944
- ...(state.sort !== null
1945
- ? { sort: `${state.sort.field}:${state.sort.dir}` }
1946
- : {}),
1947
- // Paginação server-driven: o handler implementa OFFSET e devolve `total`.
1948
- limit: state.limit ?? pageSize,
1949
- page: state.page,
1950
- } as TInput;
1951
- }, [input, state, pageSize, filterDefaults]);
1974
+ const effectiveInput = useMemo(
1975
+ () =>
1976
+ listStateToInput(state, action, {
1977
+ input: input as Record<string, unknown>,
1978
+ filterDefaults,
1979
+ pageSize,
1980
+ }) as TInput,
1981
+ [input, state, pageSize, filterDefaults, action],
1982
+ );
1952
1983
 
1953
1984
  const inputKey = JSON.stringify([effectiveInput, activeGroupBy]);
1954
1985
  const groupSpec =
@@ -1986,8 +2017,8 @@ export function ActionList<TInput, TItem>({
1986
2017
  }>({ key: "", values: new Set() });
1987
2018
  const collapsedGroups =
1988
2019
  groupCollapse.key === inputKey ? groupCollapse.values : new Set<string>();
1989
- const flat = useListAction<TItem, Record<string, unknown>>(
1990
- action as unknown as { name: string; kind: "list" },
2020
+ const flat = useListResource<TItem, Record<string, unknown>>(
2021
+ source ?? action,
1991
2022
  effectiveInput as Record<string, unknown>,
1992
2023
  { enabled: activeGroupBy === null },
1993
2024
  );
@@ -2050,6 +2081,17 @@ export function ActionList<TInput, TItem>({
2050
2081
  if (activeGroupBy === null) return flat.refetch();
2051
2082
  await Promise.all(grouped.map((group) => group.refetch()));
2052
2083
  };
2084
+ const listContext: ActionListContext<TItem> = {
2085
+ items,
2086
+ total,
2087
+ isFetching,
2088
+ isError,
2089
+ refetch,
2090
+ };
2091
+ const collectionActions =
2092
+ typeof toolbarActions === "function"
2093
+ ? toolbarActions(listContext)
2094
+ : toolbarActions;
2053
2095
  // — Colunas: prop explícita > derivadas do contrato (+ células custom). O column
2054
2096
  // picker (state.columns) escolhe o subconjunto visível; null = o default (!hidden).
2055
2097
  const visibleKeys = state.columns;
@@ -2164,11 +2206,16 @@ export function ActionList<TInput, TItem>({
2164
2206
  const displayCustomized =
2165
2207
  activeGroupBy !== null ||
2166
2208
  state.columns !== null ||
2167
- state.limit !== null ||
2209
+ (paginated && state.limit !== null) ||
2168
2210
  sortCustomized;
2169
- const displayLabel = displayCustomized
2170
- ? "Exibição personalizada"
2171
- : "Exibição";
2211
+ const hasDisplayOptions =
2212
+ paginated ||
2213
+ (children === undefined &&
2214
+ activeView === "table" &&
2215
+ (sortableFields.length > 0 ||
2216
+ availableGroupingFields.length > 0 ||
2217
+ pickerColumns.length > 0));
2218
+ const displayLabel = "Opções da lista";
2172
2219
 
2173
2220
  // — Multi-seleção (batch): `can` de QUALQUER ação torna a linha marcável; nenhuma
2174
2221
  // elegível na página → a coluna de checkbox nem aparece. Seleção é transitória
@@ -2216,239 +2263,257 @@ export function ActionList<TInput, TItem>({
2216
2263
  advancedFilters={advancedFilters}
2217
2264
  periodPosition={periodPosition}
2218
2265
  period={period}
2219
- onRefresh={() => refetch()}
2220
- refreshing={isLoading}
2221
2266
  controls={
2222
2267
  <>
2223
- {/* Configuração de EXIBIÇÃO — sempre presente (em qualquer view): colunas quando a
2224
- tabela está ativa, itens por página sempre, e o que vier depois. */}
2225
- <Popover>
2226
- {/* Tooltip e PopoverTrigger compõem via asChild aninhado no MESMO botão. */}
2227
- <Tooltip>
2228
- <TooltipTrigger asChild>
2229
- <PopoverTrigger asChild>
2230
- <Button
2231
- variant="ghost"
2232
- size="icon"
2233
- className="relative shrink-0"
2234
- aria-label={displayLabel}
2235
- >
2236
- <Settings2 className="h-3.5 w-3.5" />
2237
- {displayCustomized && (
2238
- <Dot
2239
- context="primary"
2240
- className="absolute top-1 right-1 ring-2 ring-background"
2241
- />
2242
- )}
2243
- </Button>
2244
- </PopoverTrigger>
2245
- </TooltipTrigger>
2246
- <TooltipContent>{displayLabel}</TooltipContent>
2247
- </Tooltip>
2248
- <PopoverContent
2249
- align="end"
2250
- className="w-60 p-3"
2251
- data-slot="action-list-display"
2252
- >
2253
- <div className="space-y-3">
2254
- {children === undefined &&
2255
- activeView === "table" &&
2256
- sortableFields.length > 0 && (
2257
- <>
2258
- <div className="space-y-2">
2259
- <div className="flex items-center justify-between">
2260
- <span className="text-xs font-medium text-muted-foreground">
2261
- Ordenação
2262
- </span>
2263
- {sortCustomized && (
2264
- <button
2265
- type="button"
2266
- onClick={() =>
2267
- setState({
2268
- ...state,
2269
- sort: defaultSort,
2270
- page: 1,
2271
- })
2272
- }
2273
- className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
2274
- >
2275
- <Eraser className="size-3" />
2276
- Padrão
2277
- </button>
2278
- )}
2279
- </div>
2280
- <Select
2281
- aria-label="Ordenar por"
2282
- size="sm"
2283
- className="w-full"
2284
- value={activeSort?.field ?? "__none__"}
2285
- onChange={(field) =>
2286
- setState({
2287
- ...state,
2288
- sort:
2289
- field === "__none__"
2290
- ? null
2291
- : {
2292
- field,
2293
- dir:
2294
- activeSort?.field === field
2295
- ? activeSort.dir
2296
- : "asc",
2297
- },
2298
- page: 1,
2299
- })
2300
- }
2301
- options={[
2302
- ...(defaultSort === null
2303
- ? [
2304
- {
2305
- value: "__none__",
2306
- label: "Sem ordenação",
2307
- },
2308
- ]
2309
- : []),
2310
- ...sortableFields,
2311
- ]}
2312
- trailing={
2313
- activeSort !== null ? (
2314
- <Button
2268
+ {/* Opções da lista: preferências disponíveis para a visualização. */}
2269
+ {hasDisplayOptions && (
2270
+ <Popover>
2271
+ {/* Tooltip e PopoverTrigger compõem via asChild aninhado no MESMO botão. */}
2272
+ <Tooltip>
2273
+ <TooltipTrigger asChild>
2274
+ <PopoverTrigger asChild>
2275
+ <Button
2276
+ variant="ghost"
2277
+ size="icon"
2278
+ className="relative shrink-0"
2279
+ aria-label={displayLabel}
2280
+ >
2281
+ <Settings2 className="h-3.5 w-3.5" />
2282
+ {displayCustomized && (
2283
+ <Dot
2284
+ context="primary"
2285
+ className="absolute top-1 right-1 ring-2 ring-background"
2286
+ />
2287
+ )}
2288
+ </Button>
2289
+ </PopoverTrigger>
2290
+ </TooltipTrigger>
2291
+ <TooltipContent>{displayLabel}</TooltipContent>
2292
+ </Tooltip>
2293
+ <PopoverContent
2294
+ ref={optionsPanelRef}
2295
+ tabIndex={-1}
2296
+ onOpenAutoFocus={(event) => {
2297
+ // O Select abre ao receber foco; entrar no painel não deve abrir suas opções.
2298
+ event.preventDefault();
2299
+ optionsPanelRef.current?.focus();
2300
+ }}
2301
+ align="end"
2302
+ className="w-60 p-3"
2303
+ data-slot="action-list-display"
2304
+ >
2305
+ <div className="space-y-3">
2306
+ {children === undefined &&
2307
+ activeView === "table" &&
2308
+ sortableFields.length > 0 && (
2309
+ <>
2310
+ <div className="space-y-2">
2311
+ <div className="flex items-center justify-between">
2312
+ <span className="text-xs font-medium text-muted-foreground">
2313
+ Ordenação
2314
+ </span>
2315
+ {sortCustomized && (
2316
+ <button
2315
2317
  type="button"
2316
- variant="ghost"
2317
- size="icon-xs"
2318
- aria-label={
2319
- activeSort.dir === "asc"
2320
- ? "Ordem crescente. Alterar para decrescente"
2321
- : "Ordem decrescente. Alterar para crescente"
2322
- }
2323
2318
  onClick={() =>
2324
2319
  setState({
2325
2320
  ...state,
2326
- sort: {
2327
- ...activeSort,
2328
- dir:
2329
- activeSort.dir === "asc"
2330
- ? "desc"
2331
- : "asc",
2332
- },
2321
+ sort: defaultSort,
2333
2322
  page: 1,
2334
2323
  })
2335
2324
  }
2325
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
2336
2326
  >
2337
- {activeSort.dir === "asc" ? (
2338
- <ArrowUp aria-hidden="true" />
2339
- ) : (
2340
- <ArrowDown aria-hidden="true" />
2341
- )}
2342
- </Button>
2343
- ) : undefined
2344
- }
2345
- />
2346
- </div>
2347
- <Separator />
2348
- </>
2349
- )}
2350
- {children === undefined &&
2351
- activeView === "table" &&
2352
- availableGroupingFields.length > 0 && (
2327
+ <Eraser className="size-3" />
2328
+ Padrão
2329
+ </button>
2330
+ )}
2331
+ </div>
2332
+ <Select
2333
+ aria-label="Ordenar por"
2334
+ size="sm"
2335
+ className="w-full"
2336
+ value={activeSort?.field ?? "__none__"}
2337
+ onChange={(field) =>
2338
+ setState({
2339
+ ...state,
2340
+ sort:
2341
+ field === "__none__"
2342
+ ? null
2343
+ : {
2344
+ field,
2345
+ dir:
2346
+ activeSort?.field === field
2347
+ ? activeSort.dir
2348
+ : "asc",
2349
+ },
2350
+ page: 1,
2351
+ })
2352
+ }
2353
+ options={[
2354
+ ...(defaultSort === null
2355
+ ? [
2356
+ {
2357
+ value: "__none__",
2358
+ label: "Sem ordenação",
2359
+ },
2360
+ ]
2361
+ : []),
2362
+ ...sortableFields,
2363
+ ]}
2364
+ trailing={
2365
+ activeSort !== null ? (
2366
+ <Button
2367
+ type="button"
2368
+ variant="ghost"
2369
+ size="icon-xs"
2370
+ aria-label={
2371
+ activeSort.dir === "asc"
2372
+ ? "Ordem crescente. Alterar para decrescente"
2373
+ : "Ordem decrescente. Alterar para crescente"
2374
+ }
2375
+ onClick={() =>
2376
+ setState({
2377
+ ...state,
2378
+ sort: {
2379
+ ...activeSort,
2380
+ dir:
2381
+ activeSort.dir === "asc"
2382
+ ? "desc"
2383
+ : "asc",
2384
+ },
2385
+ page: 1,
2386
+ })
2387
+ }
2388
+ >
2389
+ {activeSort.dir === "asc" ? (
2390
+ <ArrowUp aria-hidden="true" />
2391
+ ) : (
2392
+ <ArrowDown aria-hidden="true" />
2393
+ )}
2394
+ </Button>
2395
+ ) : undefined
2396
+ }
2397
+ />
2398
+ </div>
2399
+ <Separator className="last:hidden" />
2400
+ </>
2401
+ )}
2402
+ {children === undefined &&
2403
+ activeView === "table" &&
2404
+ availableGroupingFields.length > 0 && (
2405
+ <>
2406
+ <div className="space-y-2">
2407
+ <span className="text-xs font-medium text-muted-foreground">
2408
+ Agrupar por
2409
+ </span>
2410
+ <Select
2411
+ aria-label="Agrupar por"
2412
+ size="sm"
2413
+ className="w-full"
2414
+ value={activeGroupBy ?? "__none__"}
2415
+ onChange={(value) =>
2416
+ setState({
2417
+ ...state,
2418
+ groupBy: value === "__none__" ? null : value,
2419
+ page: 1,
2420
+ })
2421
+ }
2422
+ options={[
2423
+ { value: "__none__", label: "Sem agrupamento" },
2424
+ ...availableGroupingFields.map((field) => ({
2425
+ value: field,
2426
+ label: text(
2427
+ action.filters?.[field]?.label,
2428
+ field,
2429
+ ),
2430
+ })),
2431
+ ]}
2432
+ />
2433
+ </div>
2434
+ <Separator className="last:hidden" />
2435
+ </>
2436
+ )}
2437
+ {children === undefined &&
2438
+ activeView === "table" &&
2439
+ pickerColumns.length > 0 && (
2440
+ <>
2441
+ <div className="space-y-2">
2442
+ <div className="flex items-center justify-between">
2443
+ <span className="text-xs font-medium text-muted-foreground">
2444
+ Colunas
2445
+ </span>
2446
+ {state.columns !== null && (
2447
+ <button
2448
+ type="button"
2449
+ onClick={() =>
2450
+ setState({ ...state, columns: null })
2451
+ }
2452
+ className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
2453
+ >
2454
+ <Eraser className="size-3" />
2455
+ Padrão
2456
+ </button>
2457
+ )}
2458
+ </div>
2459
+ <div className="space-y-1.5">
2460
+ {pickerColumns.map((c) => (
2461
+ <label
2462
+ key={c.key}
2463
+ className="flex items-center gap-2 text-sm"
2464
+ >
2465
+ <Checkbox
2466
+ checked={(
2467
+ state.columns ?? defaultVisible
2468
+ ).includes(c.key)}
2469
+ onCheckedChange={() => toggleColumn(c.key)}
2470
+ />
2471
+ {text(c.label, c.key)}
2472
+ </label>
2473
+ ))}
2474
+ </div>
2475
+ </div>
2476
+ <Separator className="last:hidden" />
2477
+ </>
2478
+ )}
2479
+ {paginated && (
2353
2480
  <>
2354
2481
  <div className="space-y-2">
2355
2482
  <span className="text-xs font-medium text-muted-foreground">
2356
- Agrupar por
2483
+ {activeGroupBy === null
2484
+ ? "Itens por página"
2485
+ : "Itens por grupo"}
2357
2486
  </span>
2358
2487
  <Select
2359
- aria-label="Agrupar por"
2360
2488
  size="sm"
2361
2489
  className="w-full"
2362
- value={activeGroupBy ?? "__none__"}
2363
- onChange={(value) =>
2490
+ value={String(state.limit ?? pageSize)}
2491
+ onChange={(v) => {
2492
+ const n = Number(v);
2493
+ // O default do caller fica FORA da URL (limit null).
2364
2494
  setState({
2365
2495
  ...state,
2366
- groupBy: value === "__none__" ? null : value,
2496
+ limit: n === pageSize ? null : n,
2367
2497
  page: 1,
2368
- })
2369
- }
2370
- options={[
2371
- { value: "__none__", label: "Sem agrupamento" },
2372
- ...availableGroupingFields.map((field) => ({
2373
- value: field,
2374
- label: text(
2375
- action.filters?.[field]?.label,
2376
- field,
2377
- ),
2378
- })),
2379
- ]}
2498
+ });
2499
+ }}
2500
+ options={Array.from(
2501
+ new Set([10, 25, 50, 100, pageSize]),
2502
+ )
2503
+ .sort((a, b) => a - b)
2504
+ .map((n) => ({
2505
+ value: String(n),
2506
+ label: String(n),
2507
+ }))}
2380
2508
  />
2381
2509
  </div>
2382
- <Separator />
2510
+ <Separator className="last:hidden" />
2383
2511
  </>
2384
2512
  )}
2385
- {activeView === "table" && pickerColumns.length > 0 && (
2386
- <>
2387
- <div className="space-y-2">
2388
- <div className="flex items-center justify-between">
2389
- <span className="text-xs font-medium text-muted-foreground">
2390
- Colunas
2391
- </span>
2392
- {state.columns !== null && (
2393
- <button
2394
- type="button"
2395
- onClick={() =>
2396
- setState({ ...state, columns: null })
2397
- }
2398
- className="inline-flex items-center gap-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
2399
- >
2400
- <Eraser className="size-3" />
2401
- Padrão
2402
- </button>
2403
- )}
2404
- </div>
2405
- <div className="space-y-1.5">
2406
- {pickerColumns.map((c) => (
2407
- <label
2408
- key={c.key}
2409
- className="flex items-center gap-2 text-sm"
2410
- >
2411
- <Checkbox
2412
- checked={(
2413
- state.columns ?? defaultVisible
2414
- ).includes(c.key)}
2415
- onCheckedChange={() => toggleColumn(c.key)}
2416
- />
2417
- {text(c.label, c.key)}
2418
- </label>
2419
- ))}
2420
- </div>
2421
- </div>
2422
- <Separator />
2423
- </>
2424
- )}
2425
- <div className="space-y-2">
2426
- <span className="text-xs font-medium text-muted-foreground">
2427
- {activeGroupBy === null
2428
- ? "Itens por página"
2429
- : "Itens por grupo"}
2430
- </span>
2431
- <Select
2432
- size="sm"
2433
- className="w-full"
2434
- value={String(state.limit ?? pageSize)}
2435
- onChange={(v) => {
2436
- const n = Number(v);
2437
- // O default do caller fica FORA da URL (limit null).
2438
- setState({
2439
- ...state,
2440
- limit: n === pageSize ? null : n,
2441
- page: 1,
2442
- });
2443
- }}
2444
- options={Array.from(new Set([10, 25, 50, 100, pageSize]))
2445
- .sort((a, b) => a - b)
2446
- .map((n) => ({ value: String(n), label: String(n) }))}
2447
- />
2448
2513
  </div>
2449
- </div>
2450
- </PopoverContent>
2451
- </Popover>
2514
+ </PopoverContent>
2515
+ </Popover>
2516
+ )}
2452
2517
  {hasSegment && (
2453
2518
  <ToggleGroup
2454
2519
  type="single"
@@ -2481,7 +2546,7 @@ export function ActionList<TInput, TItem>({
2481
2546
  {viewControls}
2482
2547
  </>
2483
2548
  }
2484
- actions={toolbarActions}
2549
+ actions={title === undefined ? collectionActions : undefined}
2485
2550
  />
2486
2551
  </div>
2487
2552
  ) : null;
@@ -2492,7 +2557,10 @@ export function ActionList<TInput, TItem>({
2492
2557
  const initialLoading =
2493
2558
  busy && items.length === 0 && !(empty !== undefined && !isEmpty);
2494
2559
  const tableFillsAvailableHeight =
2495
- layout === "fill" && children === undefined && activeView === "table";
2560
+ layout === "fill" &&
2561
+ itemSpec === undefined &&
2562
+ children === undefined &&
2563
+ activeView === "table";
2496
2564
  const tableStateActive =
2497
2565
  tableFillsAvailableHeight && (initialLoading || isError || isEmpty);
2498
2566
  const tableColumnCount = Math.max(
@@ -2549,14 +2617,56 @@ export function ActionList<TInput, TItem>({
2549
2617
  className="w-px whitespace-nowrap text-right"
2550
2618
  onClick={(e) => e.stopPropagation()}
2551
2619
  >
2552
- <RowActions>{rowActions(item)}</RowActions>
2620
+ <RowActions>{rowActions(item, listContext)}</RowActions>
2553
2621
  </TableCell>
2554
2622
  )}
2555
2623
  </TableRow>
2556
2624
  ));
2557
- const tableRows = renderTableRows(items);
2625
+ const tableRows = itemSpec === undefined ? renderTableRows(items) : null;
2558
2626
  let content: ReactNode;
2559
- if (children !== undefined) {
2627
+ if (itemSpec !== undefined) {
2628
+ const fieldValue = (entry: TItem, field: string): ReactNode => {
2629
+ const value =
2630
+ entry !== null &&
2631
+ typeof entry === "object" &&
2632
+ Object.prototype.hasOwnProperty.call(entry, field)
2633
+ ? (entry as Record<string, unknown>)[field]
2634
+ : undefined;
2635
+ return typeof value === "string" || typeof value === "number"
2636
+ ? value
2637
+ : null;
2638
+ };
2639
+ content = (
2640
+ <ItemGroup variant="framed">
2641
+ {items.map((entry, index) => {
2642
+ const description =
2643
+ itemSpec.description === undefined
2644
+ ? null
2645
+ : fieldValue(entry, itemSpec.description);
2646
+ return (
2647
+ <Fragment key={rowId(entry)}>
2648
+ {index > 0 && <ItemSeparator />}
2649
+ <Item>
2650
+ <ItemHeader>
2651
+ <ItemTitle className="block w-full truncate">
2652
+ {fieldValue(entry, itemSpec.title) ?? <EmptyValue />}
2653
+ </ItemTitle>
2654
+ {description !== null && description !== "" && (
2655
+ <ItemDescription className="line-clamp-1 text-nowrap">
2656
+ {description}
2657
+ </ItemDescription>
2658
+ )}
2659
+ </ItemHeader>
2660
+ {rowActions !== undefined && (
2661
+ <ItemActions>{rowActions(entry, listContext)}</ItemActions>
2662
+ )}
2663
+ </Item>
2664
+ </Fragment>
2665
+ );
2666
+ })}
2667
+ </ItemGroup>
2668
+ );
2669
+ } else if (children !== undefined) {
2560
2670
  // Modo COMPOSIÇÃO: o layout é de quem compõe; a toolbar e os estados seguem daqui.
2561
2671
  content = children(items, () => refetch());
2562
2672
  } else if (activeView !== "table" && views?.[activeView] !== undefined) {
@@ -2887,95 +2997,126 @@ export function ActionList<TInput, TItem>({
2887
2997
  )}
2888
2998
  </span>
2889
2999
  ) : null;
2890
- const footer =
2891
- total !== undefined && !isError && activeGroupBy !== null ? (
2892
- <div
2893
- data-slot="action-list-footer"
2894
- className="flex min-h-10 flex-wrap items-center justify-between gap-3 border-t border-border px-3 py-0.5 text-xs text-muted-foreground"
2895
- >
2896
- {displaySummary}
2897
- <span className={cn(displaySummary === null && "ml-auto")}>
2898
- {total} {total === 1 ? "item" : "itens"} no total
2899
- </span>
2900
- </div>
2901
- ) : total !== undefined && !isError ? (
2902
- <div
2903
- data-slot="action-list-footer"
2904
- 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-0.5 text-xs text-muted-foreground"
2905
- >
2906
- <span className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
2907
- <span className="whitespace-nowrap">
2908
- Página {state.page} de {pages}
2909
- </span>
2910
- {displaySummary !== null && <span aria-hidden="true">·</span>}
2911
- {displaySummary}
2912
- </span>
2913
- {pages > 1 && (
2914
- // A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
2915
- // números `sm` que crescem com os dígitos (5726 e 5727 não se colam).
2916
- <Pagination className="mx-0 w-auto">
2917
- <PaginationContent className="gap-0.5">
2918
- <PaginationItem>
2919
- <PaginationPrevious
2920
- iconOnly
2921
- size="icon-sm"
2922
- disabled={state.page <= 1}
2923
- onClick={() => goTo(state.page - 1)}
2924
- />
2925
- </PaginationItem>
2926
- {pageWindow(state.page, pages).map((n, i) =>
2927
- n === "…" ? (
2928
- // eslint-disable-next-line react/no-array-index-key
2929
- <PaginationItem key={`e${i}`}>
2930
- <PaginationEllipsis size="icon-sm" />
2931
- </PaginationItem>
2932
- ) : (
2933
- <PaginationItem key={n}>
2934
- <PaginationLink
2935
- page={n}
2936
- isActive={n === state.page}
2937
- size="sm"
2938
- className="text-xs"
2939
- onClick={() => goTo(n)}
2940
- />
2941
- </PaginationItem>
2942
- ),
2943
- )}
2944
- <PaginationItem>
2945
- <PaginationNext
2946
- iconOnly
2947
- size="icon-sm"
2948
- disabled={state.page >= pages}
2949
- onClick={() => goTo(state.page + 1)}
2950
- />
2951
- </PaginationItem>
2952
- </PaginationContent>
2953
- </Pagination>
2954
- )}
2955
- {/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
2956
- {pages <= 1 && <span />}
2957
- <span className="justify-self-end whitespace-nowrap">
2958
- {total} {total === 1 ? "item" : "itens"} no total
3000
+ useEffect(() => {
3001
+ if (
3002
+ paginated &&
3003
+ itemSpec !== undefined &&
3004
+ !isLoading &&
3005
+ !isFetching &&
3006
+ !isError &&
3007
+ total !== undefined &&
3008
+ state.page > pages
3009
+ )
3010
+ setState({ ...state, page: pages });
3011
+ }, [
3012
+ paginated,
3013
+ itemSpec,
3014
+ isLoading,
3015
+ isFetching,
3016
+ isError,
3017
+ total,
3018
+ state.page,
3019
+ pages,
3020
+ ]);
3021
+ // O piso soma os controles e o padding em rem; a compensação fixa cobre apenas a borda superior.
3022
+ const footer = !paginated || total === 0 ? null : itemSpec !== undefined &&
3023
+ pages <= 1 ? null : total !== undefined &&
3024
+ !isError &&
3025
+ activeGroupBy !== null ? (
3026
+ <div
3027
+ data-slot="action-list-footer"
3028
+ className="flex min-h-[calc(3.5rem+1px)] flex-wrap items-center justify-between gap-3 border-t border-border px-3 py-3 text-xs text-muted-foreground"
3029
+ >
3030
+ {displaySummary}
3031
+ <span className={cn(displaySummary === null && "ml-auto")}>
3032
+ {total} {total === 1 ? "item" : "itens"} no total
3033
+ </span>
3034
+ </div>
3035
+ ) : total !== undefined && !isError ? (
3036
+ <div
3037
+ data-slot="action-list-footer"
3038
+ className="grid min-h-[calc(3.5rem+1px)] grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-t border-border px-3 py-3 text-xs text-muted-foreground"
3039
+ >
3040
+ <span className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
3041
+ <span className="whitespace-nowrap">
3042
+ Página {state.page} de {pages}
2959
3043
  </span>
2960
- </div>
2961
- ) : null;
3044
+ {displaySummary !== null && <span aria-hidden="true">·</span>}
3045
+ {displaySummary}
3046
+ </span>
3047
+ {pages > 1 && (
3048
+ // A primitiva pública na escala densa do rodapé: setas no quadrado `icon-sm`,
3049
+ // números `sm` que crescem com os dígitos (5726 e 5727 não se colam).
3050
+ <Pagination className="mx-0 w-auto">
3051
+ <PaginationContent className="gap-0.5">
3052
+ <PaginationItem>
3053
+ <PaginationPrevious
3054
+ iconOnly
3055
+ size="icon-sm"
3056
+ disabled={state.page <= 1}
3057
+ onClick={() => goTo(state.page - 1)}
3058
+ />
3059
+ </PaginationItem>
3060
+ {pageWindow(state.page, pages).map((n, i) =>
3061
+ n === "…" ? (
3062
+ // eslint-disable-next-line react/no-array-index-key
3063
+ <PaginationItem key={`e${i}`}>
3064
+ <PaginationEllipsis size="icon-sm" />
3065
+ </PaginationItem>
3066
+ ) : (
3067
+ <PaginationItem key={n}>
3068
+ <PaginationLink
3069
+ page={n}
3070
+ isActive={n === state.page}
3071
+ size="sm"
3072
+ className="text-xs"
3073
+ onClick={() => goTo(n)}
3074
+ />
3075
+ </PaginationItem>
3076
+ ),
3077
+ )}
3078
+ <PaginationItem>
3079
+ <PaginationNext
3080
+ iconOnly
3081
+ size="icon-sm"
3082
+ disabled={state.page >= pages}
3083
+ onClick={() => goTo(state.page + 1)}
3084
+ />
3085
+ </PaginationItem>
3086
+ </PaginationContent>
3087
+ </Pagination>
3088
+ )}
3089
+ {/* Com 1 página o pager some; o placeholder segura a coluna do meio. */}
3090
+ {pages <= 1 && <span />}
3091
+ <span className="justify-self-end whitespace-nowrap">
3092
+ {total} {total === 1 ? "item" : "itens"} no total
3093
+ </span>
3094
+ </div>
3095
+ ) : null;
2962
3096
 
2963
3097
  return (
2964
3098
  <TooltipProvider>
2965
3099
  <div
2966
3100
  ref={rootRef}
2967
- data-action={action.name}
3101
+ data-slot="action-list"
3102
+ data-action={actionProp?.name}
3103
+ data-source={source === undefined ? undefined : "external"}
2968
3104
  data-layout={layout}
2969
3105
  className={cn(
2970
3106
  layout === "fill"
2971
- ? "flex h-full min-h-0 min-w-0 max-w-full flex-1 flex-col gap-3"
2972
- : "min-w-0 max-w-full space-y-3",
3107
+ ? "flex h-full min-h-0 min-w-0 max-w-full flex-1 flex-col gap-4"
3108
+ : "min-w-0 max-w-full space-y-4",
2973
3109
  )}
2974
3110
  >
2975
3111
  {/* A lista é a folha da cadeia de altura: o que ela hospeda decide a própria. */}
2976
3112
  <FillBoundary>
3113
+ {title !== undefined && (
3114
+ <Surface title={title} actions={collectionActions} />
3115
+ )}
2977
3116
  {toolbar}
2978
- {beforeContent}
3117
+ {typeof beforeContent === "function"
3118
+ ? beforeContent(effectiveInput)
3119
+ : beforeContent}
2979
3120
  {selectionBar}
2980
3121
  {/* Tabela e rodapé formam um bloco só: o `gap` da pilha separa a barra de
2981
3122
  ferramentas do conteúdo, nunca o conteúdo do seu próprio rodapé. Duas
@@ -3120,12 +3261,83 @@ export function listParamsToState(
3120
3261
  to: range?.[2] ?? params.get("to"),
3121
3262
  view: params.get("view"),
3122
3263
  columns: cols !== null && cols !== "" ? cols.split(",") : null,
3123
- limit: Number.isInteger(limitParam) && limitParam > 0 ? limitParam : null,
3124
- page: Number.isInteger(page) && page > 1 ? page : 1,
3264
+ limit:
3265
+ action.paginate !== false &&
3266
+ Number.isInteger(limitParam) &&
3267
+ limitParam > 0
3268
+ ? limitParam
3269
+ : null,
3270
+ page:
3271
+ action.paginate !== false && Number.isInteger(page) && page > 1
3272
+ ? page
3273
+ : 1,
3125
3274
  groupBy,
3126
3275
  };
3127
3276
  }
3128
3277
 
3278
+ /** Materializa o mesmo recorte da coleção para consulta e hidratação de detalhes roteados. */
3279
+ export function listStateToInput(
3280
+ state: ActionListState,
3281
+ action: ListActionLike,
3282
+ {
3283
+ input = {},
3284
+ filterDefaults,
3285
+ pageSize = 50,
3286
+ }: {
3287
+ input?: Record<string, unknown>;
3288
+ filterDefaults?: Record<string, string>;
3289
+ pageSize?: number;
3290
+ } = {},
3291
+ ): Record<string, unknown> {
3292
+ const sort = state.sort ?? action.sort?.default?.[0] ?? null;
3293
+ const filled: Record<string, unknown> = {};
3294
+ for (const [name, v] of Object.entries({
3295
+ ...filterDefaults,
3296
+ ...state.filters,
3297
+ })) {
3298
+ if (isEmptyValue(v)) continue;
3299
+ const filter = action.filters?.[name];
3300
+ if (
3301
+ filter?.type === "date" &&
3302
+ !isValidDateFilterValue(v, filter.multiple === true)
3303
+ )
3304
+ continue;
3305
+ filled[name] = v;
3306
+ }
3307
+ // Período: preset materializa AQUI (a URL guarda o preset relativo); custom usa o
3308
+ // range. state.period null = o default do contrato (sempre aplicado).
3309
+ const effectivePeriod =
3310
+ state.period ??
3311
+ action.periods?.find((p) => p.default === true)?.value ??
3312
+ action.periods?.[0]?.value ??
3313
+ null;
3314
+ let range: { from: string; to: string } | null = null;
3315
+ if (
3316
+ effectivePeriod === "custom" &&
3317
+ state.from !== null &&
3318
+ state.to !== null
3319
+ ) {
3320
+ range = { from: state.from, to: state.to };
3321
+ } else if (effectivePeriod !== null && effectivePeriod !== "custom") {
3322
+ range = presetRange(effectivePeriod);
3323
+ }
3324
+ const result: Record<string, unknown> = {
3325
+ ...input,
3326
+ ...filled,
3327
+ ...(range !== null ? range : {}),
3328
+ ...(state.q.trim() !== "" ? { q: state.q.trim() } : {}),
3329
+ ...(sort !== null ? { sort: `${sort.field}:${sort.dir}` } : {}),
3330
+ // Paginação server-driven: o handler implementa OFFSET e devolve `total`.
3331
+ limit: state.limit ?? pageSize,
3332
+ page: state.page,
3333
+ };
3334
+ if (action.paginate === false) {
3335
+ delete result.limit;
3336
+ delete result.page;
3337
+ }
3338
+ return result;
3339
+ }
3340
+
3129
3341
  /** Estado da toolbar → querystring ('' ou '?…'), omitindo defaults (URL limpa). */
3130
3342
  export function listStateToParams(
3131
3343
  state: ActionListState,
@@ -3164,8 +3376,10 @@ export function listStateToParams(
3164
3376
  }
3165
3377
  if (state.view !== null) qs.set("view", state.view);
3166
3378
  if (state.columns !== null) qs.set("cols", state.columns.join(","));
3167
- if (state.limit !== null) qs.set("limit", String(state.limit));
3168
- if (state.page > 1) qs.set("page", String(state.page));
3379
+ if (action.paginate !== false && state.limit !== null)
3380
+ qs.set("limit", String(state.limit));
3381
+ if (action.paginate !== false && state.page > 1)
3382
+ qs.set("page", String(state.page));
3169
3383
  if (
3170
3384
  state.groupBy !== undefined &&
3171
3385
  state.groupBy !== null &&